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 <mail@conblem.me>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Co-authored-by: Florian Forster <florian@zitadel.com>
This commit is contained in:
Max Peintner
2025-08-28 11:23:14 +02:00
committed by GitHub
parent cccba3f8f3
commit f963ea9f86
117 changed files with 3286 additions and 2360 deletions

View File

@@ -39,7 +39,7 @@ Generated files:
To generate proto files:
```bash
pnpm run generate
pnpm turbo generate --filter=./console
```
This automatically runs both generations in the correct order via Turbo dependencies.
@@ -49,7 +49,7 @@ This automatically runs both generations in the correct order via Turbo dependen
To start the development server:
```bash
pnpm start
pnpm turbo start --filter=./console
```
This will:
@@ -62,7 +62,7 @@ This will:
To build for production:
```bash
pnpm run build
pnpm turbo build --filter=./console
```
This will:
@@ -75,13 +75,13 @@ This will:
To run linting and formatting checks:
```bash
pnpm run lint
pnpm turbo lint --filter=./console
```
To auto-fix formatting issues:
```bash
pnpm run lint:fix
pnpm turbo lint:fix --filter=./console
```
## Project Structure

View File

@@ -34,7 +34,10 @@
"@fortawesome/angular-fontawesome": "^0.13.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@ng-icons/core": "^25.0.0",
"@ng-icons/heroicons": "^25.0.0",
"@ngx-translate/core": "^15.0.0",
"@tanstack/angular-query-experimental": "^5.85.5",
"@zitadel/client": "workspace:*",
"@zitadel/proto": "workspace:*",
"angular-oauth2-oidc": "^15.0.1",
@@ -94,4 +97,4 @@
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "5.1"
}
}
}

View File

@@ -10,7 +10,7 @@ const routes: Routes = [
{
path: '',
loadChildren: () => import('./pages/home/home.module'),
canActivate: [authGuard, roleGuard],
canActivate: [authGuard],
data: {
roles: ['.'],
},
@@ -31,7 +31,10 @@ const routes: Routes = [
{
path: 'orgs',
loadChildren: () => import('./pages/org-list/org-list.module'),
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
data: {
roles: ['org.read'],
},
},
{
path: 'granted-projects',
@@ -75,7 +78,15 @@ const routes: Routes = [
loadChildren: () => import('./pages/actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['org.action.read', 'org.flow.read'],
roles: ['iam.read', 'iam.read'],
},
},
{
path: 'actions-v1',
loadChildren: () => import('./pages/org-actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['iam.read', 'iam.read'],
},
},
{

View File

@@ -1,17 +1,17 @@
<div class="main-container">
<ng-container *ngIf="authService.user | async as user">
<cnsl-header
[org]="org"
[org]="activeOrganizationQuery.data()"
[user]="user"
[isDarkTheme]="componentCssClass === 'dark-theme'"
(changedActiveOrg)="changedOrg($event)"
></cnsl-header>
(changedActiveOrg)="changedOrg()"
/>
<cnsl-nav
id="mainnav"
class="nav"
[ngClass]="{ shadow: yoffset > 60 }"
[org]="org"
[org]="activeOrganizationQuery.data()"
[user]="user"
[isDarkTheme]="componentCssClass === 'dark-theme'"
></cnsl-nav>

View File

@@ -1,14 +1,14 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay';
import { DOCUMENT, ViewportScroller } from '@angular/common';
import { Component, DestroyRef, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core';
import { Component, DestroyRef, effect, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
import { Observable, of, Subject, switchMap } from 'rxjs';
import { filter, map, startWith, takeUntil, tap } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations';
import { Org } from './proto/generated/zitadel/org_pb';
@@ -22,6 +22,8 @@ import { UpdateService } from './services/update.service';
import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language';
import { PosthogService } from './services/posthog.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NewOrganizationService } from './services/new-organization.service';
import { NewAuthService } from './services/new-auth.service';
@Component({
selector: 'cnsl-root',
@@ -42,12 +44,14 @@ export class AppComponent {
@HostListener('window:scroll', ['$event']) onScroll(event: Event): void {
this.yoffset = this.viewPortScroller.getScrollPosition()[1];
}
public org!: Org.AsObject;
public orgs$: Observable<Org.AsObject[]> = of([]);
public showAccount: boolean = false;
public isDarkTheme: Observable<boolean> = of(true);
public showProjectSection: boolean = false;
public activeOrganizationQuery = this.newOrganizationService.activeOrganizationQuery();
private listMyZitadelPermissionsQuery = this.newAuthService.listMyZitadelPermissionsQuery();
public language: string = 'en';
public privacyPolicy!: PrivacyPolicy.AsObject;
@@ -70,6 +74,8 @@ export class AppComponent {
@Inject(DOCUMENT) private document: Document,
private posthog: PosthogService,
private readonly destroyRef: DestroyRef,
private readonly newOrganizationService: NewOrganizationService,
private readonly newAuthService: NewAuthService,
) {
console.log(
'%cWait!',
@@ -199,9 +205,9 @@ export class AppComponent {
this.getProjectCount();
this.authService.activeOrgChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((org) => {
if (org) {
this.org = org;
effect(() => {
const orgId = this.newOrganizationService.orgId();
if (orgId) {
this.getProjectCount();
}
});
@@ -212,21 +218,28 @@ export class AppComponent {
filter(Boolean),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((org) => this.authService.getActiveOrg(org));
.subscribe((orgId) => this.newOrganizationService.setOrgId(orgId));
this.authenticationService.authenticationChanged
.pipe(
filter(Boolean),
switchMap(() => this.authService.getActiveOrg()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (org) => (this.org = org),
error: async (err) => {
console.error(err);
return this.router.navigate(['/users/me']);
},
});
effect(() => {
const permissions = this.listMyZitadelPermissionsQuery.data();
const error = this.listMyZitadelPermissionsQuery.error();
if (!permissions && !error) {
// not loaded yet
return;
}
// if we have an error this is gonna be false anyway as permissions will be undefined
if (permissions?.includes('org.read')) {
return;
}
if (error) {
console.error(error);
}
this.router.navigate(['/users/me']).then();
});
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => {
@@ -237,7 +250,6 @@ export class AppComponent {
this.translate.onLangChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((language: LangChangeEvent) => {
this.document.documentElement.lang = language.lang;
this.language = language.lang;
});
}
@@ -266,7 +278,7 @@ export class AppComponent {
this.componentCssClass = theme;
}
public changedOrg(org: Org.AsObject): void {
public changedOrg(): void {
// Reference: https://stackoverflow.com/a/58114797
const currentUrl = this.router.url;
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
@@ -287,7 +299,6 @@ export class AppComponent {
? userprofile.human.profile?.preferredLanguage
: fallbackLang;
this.translate.use(lang);
this.language = lang;
this.document.documentElement.lang = lang;
});
}

View File

@@ -74,6 +74,10 @@ import { ThemeService } from './services/theme.service';
import { ToastService } from './services/toast.service';
import { LanguagesService } from './services/languages.service';
import { PosthogService } from './services/posthog.service';
import { NewHeaderComponent } from './modules/new-header/new-header.component';
import { provideTanStackQuery, QueryClient, withDevtools } from '@tanstack/angular-query-experimental';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
import { provideNgIconsConfig } from '@ng-icons/core';
registerLocaleData(localeDe);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/de.json'));
@@ -171,6 +175,8 @@ const authConfig: AuthConfig = {
MatDialogModule,
KeyboardShortcutsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }),
NewHeaderComponent,
CdkOverlayOrigin,
],
providers: [
ThemeService,
@@ -245,8 +251,16 @@ const authConfig: AuthConfig = {
LanguagesService,
PosthogService,
{ provide: 'windowObject', useValue: window },
provideTanStackQuery(
new QueryClient(),
withDevtools(() => ({ loadDevtools: 'auto' })),
),
provideNgIconsConfig({
size: '1rem',
}),
],
bootstrap: [AppComponent],
exports: [],
})
export class AppModule {
constructor() {}

View File

@@ -21,7 +21,7 @@ import {
} from '@zitadel/proto/zitadel/feature/v2/instance_pb';
import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { MessageInitShape } from '@bufbuild/protobuf';
import { firstValueFrom, Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs';
import { Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-feature-toggle/login-v2-feature-toggle.component';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = '';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(['/']);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,107 +1,23 @@
<div class="max-width-container">
<div class="enlarged-container actions-enlarged-container">
<div class="actions-title-row">
<h1>{{ 'DESCRIPTIONS.ACTIONS.TITLE' | translate }}</h1>
<a mat-icon-button href="https://zitadel.com/docs/concepts/features/actions" rel="noreferrer" target="_blank">
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<cnsl-info-section [type]="InfoSectionType.ALERT">
{{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }}
</cnsl-info-section>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}</p>
<cnsl-info-section class="max-actions" *ngIf="maxActions"
>{{ 'FLOWS.ACTIONSMAX' | translate: { value: maxActions } }}
<div class="enlarged-container">
<h1>{{ 'ACTIONSTWO.EXECUTION.TITLE' | translate }}</h1>
<cnsl-info-section [type]="InfoSectionType.INFO">
{{ 'ACTIONSTWO.BETA_NOTE' | translate }}
</cnsl-info-section>
<ng-template cnslHasRole [hasRole]="['org.action.read']">
<cnsl-card
title="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.TITLE' | translate }}"
description="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.DESCRIPTION' | translate }}"
>
<cnsl-action-table (changedSelection)="selection = $event"></cnsl-action-table>
</cnsl-card>
</ng-template>
<cnsl-sidenav
[indented]="true"
[setting]="currentSetting$()"
(settingChange)="currentSetting$.set($event)"
[settingsList]="settingsList"
>
<ng-container *ngIf="currentSetting$().id === 'actions'">
<cnsl-actions-two-actions></cnsl-actions-two-actions>
</ng-container>
<div class="title-section">
<h2>{{ 'DESCRIPTIONS.ACTIONS.FLOWS.TITLE' | translate }}</h2>
<i class="las la-exchange-alt"></i>
</div>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.FLOWS.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['org.flow.read']">
<div class="actions-flow">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'FLOWS.FLOWTYPE' | translate }}</cnsl-label>
<mat-select [formControl]="typeControl">
<mat-option *ngFor="let type of typesForSelection" [value]="type">
{{ type.name?.localizedMessage }}
</mat-option>
</mat-select>
</cnsl-form-field>
<div *ngIf="flow" class="trigger-wrapper">
<div class="actions-topbottomline"></div>
<div class="flow-type">
<i class="type-icon las la-dot-circle"></i>
<span>{{ flow.type?.name?.localizedMessage }}</span>
<button
*ngIf="flow.type && (flow.triggerActionsList?.length ?? 0) > 0"
matTooltip="{{ 'ACTIONS.CLEAR' | translate }}"
mat-icon-button
color="warn"
(click)="clearFlow(flow.type.id)"
>
<i class="type-button-icon las la-trash"></i>
</button>
</div>
<cnsl-card *ngFor="let trigger of flow.triggerActionsList; index as i" class="trigger">
<div class="trigger-top">
<mat-icon svgIcon="mdi_arrow_right_bottom" class="icon"></mat-icon>
<span>{{ trigger.triggerType?.name?.localizedMessage }}</span>
<span class="fill-space"></span>
<button color="warn" mat-icon-button (click)="removeTriggerActionsList(i)">
<i class="las la-trash"></i>
</button>
</div>
<span class="fill-space"></span>
<div class="flow-action-wrapper" cdkDropList (cdkDropListDropped)="drop(i, trigger.actionsList, $event)">
<div
cdkDrag
cdkDragLockAxis="y"
cdkDragBoundary=".action-wrapper"
class="flow-action"
*ngFor="let action of trigger.actionsList"
>
<i class="las la-code"></i>
<span class="flow-action-name">{{ action.name }}</span>
<span class="fill-space"></span>
<span
class="state"
[ngClass]="{
active: action.state === ActionState.ACTION_STATE_ACTIVE,
inactive: action.state === ActionState.ACTION_STATE_INACTIVE,
}"
>
{{ 'FLOWS.STATES.' + action.state | translate }}</span
>
</div>
</div>
</cnsl-card>
<button *ngIf="flow.type" class="add-btn" mat-raised-button color="primary" (click)="openAddTrigger(flow.type)">
<div class="cnsl-action-button">
<mat-icon>add</mat-icon>
<span>{{ 'FLOWS.ADDTRIGGER' | translate }}</span>
<span *ngIf="selection && selection.length">&nbsp;({{ selection.length }})</span>
</div>
</button>
</div>
</div>
</ng-template>
<ng-container *ngIf="currentSetting$().id === 'targets'">
<cnsl-actions-two-targets></cnsl-actions-two-targets>
</ng-container>
</cnsl-sidenav>
</div>
</div>

View File

@@ -1,18 +1,9 @@
.actions-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
h1 {
margin: 0;
}
a {
.icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
}
}
.org-desc {
font-size: 14px;
}
@mixin actions-theme($theme) {

View File

@@ -1,19 +1,19 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ActionsComponent } from './actions.component';
import { OrgListComponent } from './actions.component';
describe('ActionsComponent', () => {
let component: ActionsComponent;
let fixture: ComponentFixture<ActionsComponent>;
describe('OrgListComponent', () => {
let component: OrgListComponent;
let fixture: ComponentFixture<OrgListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ActionsComponent],
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [OrgListComponent],
}).compileComponents();
});
}));
beforeEach(() => {
fixture = TestBed.createComponent(ActionsComponent);
fixture = TestBed.createComponent(OrgListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@@ -1,180 +1,30 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, DestroyRef } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component';
import { Component, signal } from '@angular/core';
import { enterAnimations } from 'src/app/animations';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb';
import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const ACTIONS: SidenavSetting = { id: 'actions', i18nKey: 'MENU.ACTIONS' };
const TARGETS: SidenavSetting = { id: 'targets', i18nKey: 'MENU.TARGETS' };
@Component({
selector: 'cnsl-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.scss'],
animations: [enterAnimations],
})
export class ActionsComponent {
protected flow!: Flow.AsObject;
public settingsList: SidenavSetting[] = [ACTIONS, TARGETS];
protected readonly currentSetting$ = signal<SidenavSetting>(this.settingsList[0]);
protected readonly InfoSectionType = InfoSectionType;
protected typeControl: UntypedFormControl = new UntypedFormControl();
protected typesForSelection: FlowType.AsObject[] = [];
protected selection: Action.AsObject[] = [];
protected InfoSectionType = InfoSectionType;
protected ActionKeysType = ActionKeysType;
protected maxActions: number | null = null;
protected ActionState = ActionState;
constructor(
private mgmtService: ManagementService,
breadcrumbService: BreadcrumbService,
private dialog: MatDialog,
private toast: ToastService,
destroyRef: DestroyRef,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
breadcrumbService.setBreadcrumb([bread]);
this.getFlowTypes().then();
this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
this.loadFlow((value as FlowType.AsObject).id);
});
}
private async getFlowTypes(): Promise<void> {
try {
let resp = await this.mgmtService.listFlowTypes();
this.typesForSelection = resp.resultList;
if (!this.flow && resp.resultList[0]) {
const type = resp.resultList[0];
this.typeControl.setValue(type);
}
} catch (error) {
this.toast.showError(error);
}
}
private loadFlow(id: string) {
this.mgmtService.getFlow(id).then((flowResponse) => {
if (flowResponse.flow) {
this.flow = flowResponse.flow;
}
});
}
public clearFlow(id: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.CLEAR.TITLE',
descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION',
},
width: '400px',
constructor(breadcrumbService: BreadcrumbService) {
const iamBread = new Breadcrumb({
type: BreadcrumbType.INSTANCE,
name: 'Instance',
routerLink: ['/instance'],
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.clearFlow(id)
.then(() => {
this.toast.showInfo('FLOWS.FLOWCLEARED', true);
this.loadFlow(id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void {
const dialogRef = this.dialog.open(AddFlowDialogComponent, {
data: {
flowType: flow,
actions: this.selection && this.selection.length ? this.selection : [],
},
width: '400px',
});
dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => {
if (req) {
this.mgmtService
.setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType())
.then(() => {
this.toast.showInfo('FLOWS.FLOWCHANGED', true);
this.loadFlow(flow.id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop<Action.AsObject[]>) {
moveItemInArray(array, event.previousIndex, event.currentIndex);
this.saveFlow(triggerActionsListIndex);
}
saveFlow(index: number) {
if (
this.flow.type &&
this.flow.triggerActionsList &&
this.flow.triggerActionsList[index] &&
this.flow.triggerActionsList[index]?.triggerType
) {
this.mgmtService
.setTriggerActions(
this.flow.triggerActionsList[index].actionsList.map((action) => action.id),
this.flow.type.id,
this.flow.triggerActionsList[index].triggerType?.id ?? '',
)
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
protected removeTriggerActionsList(index: number) {
if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE',
descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '')
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
this.loadFlow(this.flow?.type?.id ?? '');
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
breadcrumbService.setBreadcrumb([iamBread]);
}
}

View File

@@ -1,68 +1,29 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { FormFieldModule } from 'src/app/modules/form-field/form-field.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { InputModule } from 'src/app/modules/input/input.module';
import { PaginatorModule } from 'src/app/modules/paginator/paginator.module';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { OrgTableModule } from 'src/app/modules/org-table/org-table.module';
import { ActionTableComponent } from './action-table/action-table.component';
import { ActionsRoutingModule } from './actions-routing.module';
import { ActionsComponent } from './actions.component';
import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module';
import { SidenavModule } from 'src/app/modules/sidenav/sidenav.module';
import { ActionsTwoActionsComponent } from 'src/app/modules/actions-two/actions-two-actions/actions-two-actions.component';
import ActionsTwoModule from 'src/app/modules/actions-two/actions-two.module';
import { FormsModule } from '@angular/forms';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
@NgModule({
declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent],
declarations: [ActionsComponent],
imports: [
CommonModule,
FormsModule,
ActionsRoutingModule,
OrgTableModule,
TranslateModule,
MatDialogModule,
RefreshTableModule,
MatTableModule,
PaginatorModule,
MatButtonModule,
ReactiveFormsModule,
MatIconModule,
DurationToSecondsPipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
HasRoleModule,
ActionKeysModule,
MatTooltipModule,
CardModule,
MatCheckboxModule,
InputModule,
FormFieldModule,
MatSelectModule,
WarnDialogModule,
DragDropModule,
InfoSectionModule,
HasRolePipeModule,
TableActionsModule,
CodemirrorModule,
SidenavModule,
ActionsTwoModule,
],
exports: [ActionsComponent],
})
export default class ActionsModule {}

View File

@@ -5,16 +5,14 @@
<cnsl-quickstart></cnsl-quickstart>
<ng-container *ngIf="['iam.read$'] | hasRole | async; else defaultHome">
<cnsl-onboarding></cnsl-onboarding>
<cnsl-onboarding />
</ng-container>
<ng-template #defaultHome>
<cnsl-shortcuts></cnsl-shortcuts>
<cnsl-shortcuts />
</ng-template>
<p class="disclaimer cnsl-secondary-text">{{ 'HOME.DISCLAIMER' | translate }}</p>
<span class="fill-space"></span>
<h2 class="desc">{{ 'ONBOARDING.MOREDESCRIPTION' | translate }}</h2>
<h2 class="home-desc">{{ 'ONBOARDING.MOREDESCRIPTION' | translate }}</h2>
<div class="home-grid-container">
<a href="https://zitadel.com/docs/guides/start/quickstart" target="_blank" rel="noreferrer" class="grid-item">

View File

@@ -38,9 +38,9 @@
}
}
.desc {
.home-desc {
font-size: 1.2rem;
margin-top: 0;
margin-top: 2rem;
text-transform: uppercase;
}
@@ -77,49 +77,6 @@
cursor: move;
}
.grid-item-avatar {
height: 40px;
width: 40px;
margin-right: 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
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, #03704e 30%, #047857);
}
&.blue {
background: linear-gradient(40deg, #306ccc 30%, #4f46e5);
}
&.yellow {
background: linear-gradient(40deg, #f59e0b 30%, #b45309);
}
&.black {
background: linear-gradient(40deg, #1f2937, #111827);
}
.icon,
i {
font-size: 1.5rem;
height: 1.5rem;
line-height: 1.5rem;
color: white;
}
}
.icon-wrapper {
display: flex;
justify-content: center;
@@ -143,13 +100,6 @@
}
}
.disclaimer {
font-size: 14px;
margin-top: 0;
margin-bottom: 5rem;
font-style: italic;
}
.fill-space {
flex: 1;
}

View File

@@ -1,8 +1,11 @@
import { Component } from '@angular/core';
import { Component, effect } from '@angular/core';
import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ThemeService } from 'src/app/services/theme.service';
import { COLORS } from 'src/app/utils/color';
import { NewAuthService } from 'src/app/services/new-auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'cnsl-home',
@@ -21,19 +24,36 @@ export class HomeComponent {
public dark: boolean = true;
protected readonly PolicyComponentServiceType = PolicyComponentServiceType;
private readonly permissions = this.newAuthService.listMyZitadelPermissionsQuery();
constructor(
public authService: GrpcAuthService,
private readonly newAuthService: NewAuthService,
breadcrumbService: BreadcrumbService,
public themeService: ThemeService,
private readonly router: Router,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
type: BreadcrumbType.INSTANCE,
routerLink: ['/'],
};
breadcrumbService.setBreadcrumb([bread]);
const theme = localStorage.getItem('theme');
this.dark = theme === 'dark-theme' ? true : theme === 'light-theme' ? false : true;
effect(() => {
const permission = this.permissions.data();
if (!permission) {
return;
}
if (permission.includes('iam.read')) {
return;
}
this.router.navigate(['/org']).then();
});
}
}

View File

@@ -8,20 +8,6 @@
stateTooltip="{{ 'INSTANCE.STATE.' + instance?.state | translate }}"
>
<div topContributors class="instance-action-wrapper">
<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>
<cnsl-contributors
[totalResult]="totalMemberResult"
[loading]="loading$ | async"

View File

@@ -14,14 +14,6 @@
.instance-action-wrapper {
display: flex;
align-items: center;
.portal-link {
margin-right: 1rem;
.portal-span {
margin-right: 0.5rem;
}
}
}
.instance-table-desc {

View File

@@ -33,10 +33,7 @@ import {
VIEWS,
FAILEDEVENTS,
EVENTS,
ORGANIZATIONS,
FEATURESETTINGS,
ACTIONS,
ACTIONS_TARGETS,
} from 'src/app/modules/settings-list/settings';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
@@ -58,10 +55,7 @@ export class InstanceComponent {
protected id: string = '';
protected readonly defaultSettingsList: SidenavSetting[] = [
ORGANIZATIONS,
FEATURESETTINGS,
ACTIONS,
ACTIONS_TARGETS,
// notifications
// { showWarn: true, ...NOTIFICATIONS },
NOTIFICATIONS,
@@ -92,7 +86,6 @@ export class InstanceComponent {
];
protected readonly settingsList$: Observable<SidenavSetting[]>;
protected readonly customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal));
constructor(
protected readonly adminService: AdminService,
@@ -101,7 +94,6 @@ export class InstanceComponent {
breadcrumbService: BreadcrumbService,
private readonly router: Router,
private readonly authService: GrpcAuthService,
private readonly envService: EnvironmentService,
activatedRoute: ActivatedRoute,
private readonly destroyRef: DestroyRef,
) {

View File

@@ -21,7 +21,6 @@ import { InputModule } from 'src/app/modules/input/input.module';
import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module';
import { OrgTableModule } from 'src/app/modules/org-table/org-table.module';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { SettingsGridModule } from 'src/app/modules/settings-grid/settings-grid.module';
import { TopViewModule } from 'src/app/modules/top-view/top-view.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
@@ -64,7 +63,6 @@ import { SettingsListModule } from 'src/app/modules/settings-list/settings-list.
HasRolePipeModule,
SettingsListModule,
MatSortModule,
SettingsGridModule,
],
})
export default class InstanceModule {}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ActionsComponent } from './actions.component';
const routes: Routes = [
{
path: '',
component: ActionsComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ActionsRoutingModule {}

View File

@@ -0,0 +1,107 @@
<div class="max-width-container">
<div class="enlarged-container actions-enlarged-container">
<div class="actions-title-row">
<h1>{{ 'DESCRIPTIONS.ACTIONS.TITLE' | translate }}</h1>
<a mat-icon-button href="https://zitadel.com/docs/concepts/features/actions" rel="noreferrer" target="_blank">
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<cnsl-info-section [type]="InfoSectionType.ALERT">
{{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }}
</cnsl-info-section>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}</p>
<cnsl-info-section class="max-actions" *ngIf="maxActions"
>{{ 'FLOWS.ACTIONSMAX' | translate: { value: maxActions } }}
</cnsl-info-section>
<ng-template cnslHasRole [hasRole]="['org.action.read']">
<cnsl-card
title="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.TITLE' | translate }}"
description="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.DESCRIPTION' | translate }}"
>
<cnsl-action-table (changedSelection)="selection = $event"></cnsl-action-table>
</cnsl-card>
</ng-template>
<div class="title-section">
<h2>{{ 'DESCRIPTIONS.ACTIONS.FLOWS.TITLE' | translate }}</h2>
<i class="las la-exchange-alt"></i>
</div>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.FLOWS.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['org.flow.read']">
<div class="actions-flow">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'FLOWS.FLOWTYPE' | translate }}</cnsl-label>
<mat-select [formControl]="typeControl">
<mat-option *ngFor="let type of typesForSelection" [value]="type">
{{ type.name?.localizedMessage }}
</mat-option>
</mat-select>
</cnsl-form-field>
<div *ngIf="flow" class="trigger-wrapper">
<div class="actions-topbottomline"></div>
<div class="flow-type">
<i class="type-icon las la-dot-circle"></i>
<span>{{ flow.type?.name?.localizedMessage }}</span>
<button
*ngIf="flow.type && (flow.triggerActionsList?.length ?? 0) > 0"
matTooltip="{{ 'ACTIONS.CLEAR' | translate }}"
mat-icon-button
color="warn"
(click)="clearFlow(flow.type.id)"
>
<i class="type-button-icon las la-trash"></i>
</button>
</div>
<cnsl-card *ngFor="let trigger of flow.triggerActionsList; index as i" class="trigger">
<div class="trigger-top">
<mat-icon svgIcon="mdi_arrow_right_bottom" class="icon"></mat-icon>
<span>{{ trigger.triggerType?.name?.localizedMessage }}</span>
<span class="fill-space"></span>
<button color="warn" mat-icon-button (click)="removeTriggerActionsList(i)">
<i class="las la-trash"></i>
</button>
</div>
<span class="fill-space"></span>
<div class="flow-action-wrapper" cdkDropList (cdkDropListDropped)="drop(i, trigger.actionsList, $event)">
<div
cdkDrag
cdkDragLockAxis="y"
cdkDragBoundary=".action-wrapper"
class="flow-action"
*ngFor="let action of trigger.actionsList"
>
<i class="las la-code"></i>
<span class="flow-action-name">{{ action.name }}</span>
<span class="fill-space"></span>
<span
class="state"
[ngClass]="{
active: action.state === ActionState.ACTION_STATE_ACTIVE,
inactive: action.state === ActionState.ACTION_STATE_INACTIVE,
}"
>
{{ 'FLOWS.STATES.' + action.state | translate }}</span
>
</div>
</div>
</cnsl-card>
<button *ngIf="flow.type" class="add-btn" mat-raised-button color="primary" (click)="openAddTrigger(flow.type)">
<div class="cnsl-action-button">
<mat-icon>add</mat-icon>
<span>{{ 'FLOWS.ADDTRIGGER' | translate }}</span>
<span *ngIf="selection && selection.length">&nbsp;({{ selection.length }})</span>
</div>
</button>
</div>
</div>
</ng-template>
</div>
</div>

View File

@@ -0,0 +1,186 @@
.actions-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
a {
.icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
}
}
}
@mixin actions-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$is-dark-theme: map-get($theme, is-dark);
$primary: map-get($theme, primary);
$primary-color: map-get($primary, 500);
.actions-enlarged-container {
h1 {
margin: 0;
}
.desc {
margin-bottom: 2rem;
font-size: 14px;
}
.title-section {
display: flex;
align-items: center;
margin-top: 3rem;
margin-bottom: 1rem;
h2 {
margin: 0;
}
i {
margin-left: 0.5rem;
}
}
.actions-flow {
display: flex;
flex-direction: column;
max-width: 1000px;
position: relative;
.formfield {
max-width: 300px;
}
.flow-type {
margin: 0.5rem 0;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 1.5rem;
.type-icon {
color: $primary-color;
}
.type-button-icon,
.type-icon,
span {
margin-right: 1rem;
}
.type-icon,
.type-button-icon {
position: relative;
}
}
.trigger-wrapper {
position: relative;
.trigger {
display: flex;
align-items: center;
position: relative;
.trigger-top {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding-left: 7px;
.fill-space {
flex: 1;
}
}
.icon {
margin-right: 1rem;
color: $primary-color;
}
.fill-space {
flex: 1;
}
.flow-action-wrapper {
padding: 0 0.5rem;
margin: 0;
.flow-action {
display: flex;
align-items: center;
font-size: 14px;
padding: 0.5rem 0;
cursor: move;
.flow-action-name {
margin-right: 1rem;
}
.fill-space {
flex: 1;
}
i {
margin-right: 0.5rem;
}
}
.state {
margin-left: 1rem;
}
}
}
}
.actions-topbottomline {
position: absolute;
top: 26px;
bottom: 1.5rem;
left: 35px;
width: 2px;
z-index: 0;
background-color: $primary-color;
}
.add-btn {
display: flex;
align-items: center;
align-self: flex-start;
margin: 1rem 0;
}
}
.cdk-drag-preview {
color: white;
display: flex;
align-items: center;
font-size: 14px;
border-radius: 0.5rem;
padding: 0 0.5rem;
background-color: $primary-color;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
i {
margin-right: 0.5rem;
}
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
}
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActionsComponent } from './actions.component';
describe('ActionsComponent', () => {
let component: ActionsComponent;
let fixture: ComponentFixture<ActionsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ActionsComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ActionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,180 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, DestroyRef } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb';
import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.scss'],
})
export class ActionsComponent {
protected flow!: Flow.AsObject;
protected typeControl: UntypedFormControl = new UntypedFormControl();
protected typesForSelection: FlowType.AsObject[] = [];
protected selection: Action.AsObject[] = [];
protected InfoSectionType = InfoSectionType;
protected ActionKeysType = ActionKeysType;
protected maxActions: number | null = null;
protected ActionState = ActionState;
constructor(
private mgmtService: ManagementService,
breadcrumbService: BreadcrumbService,
private dialog: MatDialog,
private toast: ToastService,
destroyRef: DestroyRef,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
breadcrumbService.setBreadcrumb([bread]);
this.getFlowTypes().then();
this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
this.loadFlow((value as FlowType.AsObject).id);
});
}
private async getFlowTypes(): Promise<void> {
try {
let resp = await this.mgmtService.listFlowTypes();
this.typesForSelection = resp.resultList;
if (!this.flow && resp.resultList[0]) {
const type = resp.resultList[0];
this.typeControl.setValue(type);
}
} catch (error) {
this.toast.showError(error);
}
}
private loadFlow(id: string) {
this.mgmtService.getFlow(id).then((flowResponse) => {
if (flowResponse.flow) {
this.flow = flowResponse.flow;
}
});
}
public clearFlow(id: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.CLEAR.TITLE',
descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.clearFlow(id)
.then(() => {
this.toast.showInfo('FLOWS.FLOWCLEARED', true);
this.loadFlow(id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void {
const dialogRef = this.dialog.open(AddFlowDialogComponent, {
data: {
flowType: flow,
actions: this.selection && this.selection.length ? this.selection : [],
},
width: '400px',
});
dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => {
if (req) {
this.mgmtService
.setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType())
.then(() => {
this.toast.showInfo('FLOWS.FLOWCHANGED', true);
this.loadFlow(flow.id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop<Action.AsObject[]>) {
moveItemInArray(array, event.previousIndex, event.currentIndex);
this.saveFlow(triggerActionsListIndex);
}
saveFlow(index: number) {
if (
this.flow.type &&
this.flow.triggerActionsList &&
this.flow.triggerActionsList[index] &&
this.flow.triggerActionsList[index]?.triggerType
) {
this.mgmtService
.setTriggerActions(
this.flow.triggerActionsList[index].actionsList.map((action) => action.id),
this.flow.type.id,
this.flow.triggerActionsList[index].triggerType?.id ?? '',
)
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
protected removeTriggerActionsList(index: number) {
if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE',
descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '')
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
this.loadFlow(this.flow?.type?.id ?? '');
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
}
}

View File

@@ -0,0 +1,68 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { FormFieldModule } from 'src/app/modules/form-field/form-field.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { InputModule } from 'src/app/modules/input/input.module';
import { PaginatorModule } from 'src/app/modules/paginator/paginator.module';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { ActionTableComponent } from './action-table/action-table.component';
import { ActionsRoutingModule } from './actions-routing.module';
import { ActionsComponent } from './actions.component';
import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
@NgModule({
declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent],
imports: [
CommonModule,
FormsModule,
ActionsRoutingModule,
TranslateModule,
MatDialogModule,
RefreshTableModule,
MatTableModule,
PaginatorModule,
MatButtonModule,
ReactiveFormsModule,
MatIconModule,
DurationToSecondsPipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
HasRoleModule,
ActionKeysModule,
MatTooltipModule,
CardModule,
MatCheckboxModule,
InputModule,
FormFieldModule,
MatSelectModule,
WarnDialogModule,
DragDropModule,
InfoSectionModule,
HasRolePipeModule,
TableActionsModule,
CodemirrorModule,
],
})
export default class ActionsModule {}

View File

@@ -5,17 +5,17 @@ import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { Router } from '@angular/router';
import { passwordConfirmValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { SetUpOrgRequest } from 'src/app/proto/generated/zitadel/admin_pb';
import { Gender } from 'src/app/proto/generated/zitadel/user_pb';
import { AdminService } from 'src/app/services/admin.service';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { LanguagesService } from 'src/app/services/languages.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { PasswordComplexityPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { PasswordComplexityValidatorFactoryService } from 'src/app/services/password-complexity-validator-factory.service';
import { injectMutation } from '@tanstack/angular-query-experimental';
import { NewOrganizationService } from '../../services/new-organization.service';
import { MessageInitShape } from '@bufbuild/protobuf';
import { SetUpOrgRequestSchema } from '@zitadel/proto/zitadel/admin_pb';
@Component({
selector: 'cnsl-org-create',
@@ -32,32 +32,33 @@ import { PasswordComplexityValidatorFactoryService } from 'src/app/services/pass
],
})
export class OrgCreateComponent {
public orgForm: UntypedFormGroup = this.fb.group({
protected orgForm = this.fb.group({
name: ['', [requiredValidator]],
domain: [''],
});
public userForm?: UntypedFormGroup;
public pwdForm?: UntypedFormGroup;
protected userForm?: UntypedFormGroup;
protected pwdForm?: UntypedFormGroup;
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
protected readonly genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
public policy?: PasswordComplexityPolicy;
public usePassword: boolean = false;
protected policy?: PasswordComplexityPolicy;
protected usePassword: boolean = false;
public forSelf: boolean = true;
protected forSelf: boolean = true;
protected readonly setupOrgMutation = injectMutation(this.newOrganizationService.setupOrgMutationOptions);
protected readonly addOrgMutation = injectMutation(this.newOrganizationService.addOrgMutationOptions);
constructor(
private readonly router: Router,
private readonly toast: ToastService,
private readonly adminService: AdminService,
private readonly _location: Location,
private readonly location: Location,
private readonly fb: UntypedFormBuilder,
private readonly mgmtService: ManagementService,
private readonly newMgmtService: NewMgmtService,
private readonly authService: GrpcAuthService,
private readonly passwordComplexityValidatorFactory: PasswordComplexityValidatorFactoryService,
public readonly langSvc: LanguagesService,
private readonly newOrganizationService: NewOrganizationService,
breadcrumbService: BreadcrumbService,
) {
const instanceBread = new Breadcrumb({
@@ -73,38 +74,38 @@ export class OrgCreateComponent {
public createSteps: number = 2;
public currentCreateStep: number = 1;
public finish(): void {
const createOrgRequest: SetUpOrgRequest.Org = new SetUpOrgRequest.Org();
createOrgRequest.setName(this.name?.value);
createOrgRequest.setDomain(this.domain?.value);
public async finish(): Promise<void> {
const req: MessageInitShape<typeof SetUpOrgRequestSchema> = {
org: {
name: this.name?.value,
domain: this.domain?.value,
},
user: {
case: 'human',
value: {
email: {
email: this.email?.value,
isEmailVerified: this.isVerified?.value,
},
userName: this.userName?.value,
profile: {
firstName: this.firstName?.value,
lastName: this.lastName?.value,
nickName: this.nickName?.value,
gender: this.gender?.value,
preferredLanguage: this.preferredLanguage?.value,
},
password: this.usePassword && this.password ? this.password.value : undefined,
},
},
};
const humanRequest: SetUpOrgRequest.Human = new SetUpOrgRequest.Human();
humanRequest.setEmail(
new SetUpOrgRequest.Human.Email().setEmail(this.email?.value).setIsEmailVerified(this.isVerified?.value),
);
humanRequest.setUserName(this.userName?.value);
const profile: SetUpOrgRequest.Human.Profile = new SetUpOrgRequest.Human.Profile();
profile.setFirstName(this.firstName?.value);
profile.setLastName(this.lastName?.value);
profile.setNickName(this.nickName?.value);
profile.setGender(this.gender?.value);
profile.setPreferredLanguage(this.preferredLanguage?.value);
humanRequest.setProfile(profile);
if (this.usePassword && this.password) {
humanRequest.setPassword(this.password?.value);
try {
await this.setupOrgMutation.mutateAsync(req);
await this.router.navigate(['/orgs']);
} catch (error) {
this.toast.showError(error);
}
this.adminService
.SetUpOrg(createOrgRequest, humanRequest)
.then(() => {
this.authService.revalidateOrgs().then();
this.router.navigate(['/orgs']).then();
})
.catch((error) => {
this.toast.showError(error);
});
}
public next(): void {
@@ -161,17 +162,15 @@ export class OrgCreateComponent {
}
}
public createOrgForSelf(): void {
if (this.name && this.name.value) {
this.mgmtService
.addOrg(this.name.value)
.then(() => {
this.authService.revalidateOrgs().then();
this.router.navigate(['/orgs']).then();
})
.catch((error) => {
this.toast.showError(error);
});
public async createOrgForSelf() {
if (!this.name?.value) {
return;
}
try {
await this.addOrgMutation.mutateAsync(this.name.value);
await this.router.navigate(['/orgs']);
} catch (error) {
this.toast.showError(error);
}
}
@@ -224,6 +223,6 @@ export class OrgCreateComponent {
}
public close(): void {
this._location.back();
this.location.back();
}
}

View File

@@ -3,6 +3,6 @@
<h1>{{ 'ORG.PAGES.LIST' | translate }}</h1>
<p class="org-desc cnsl-secondary-text">{{ 'ORG.PAGES.LISTDESCRIPTION' | translate }}</p>
<cnsl-org-table></cnsl-org-table>
<cnsl-org-table />
</div>
</div>

View File

@@ -1,78 +1,60 @@
<cnsl-top-view
*ngIf="['org.write:' + org?.id, 'org.write$'] | hasRole as hasWrite$"
[hasBackButton]="false"
title="{{ org?.name }}"
[isActive]="org?.state === OrgState.ORG_STATE_ACTIVE"
[isInactive]="org?.state === OrgState.ORG_STATE_INACTIVE"
[hasContributors]="true"
stateTooltip="{{ 'ORG.STATE.' + org?.state | translate }}"
[hasActions]="hasWrite$ | async"
>
<ng-container topActions *ngIf="hasWrite$ | async">
<button
mat-menu-item
*ngIf="org?.state === OrgState.ORG_STATE_ACTIVE"
(click)="changeState(OrgState.ORG_STATE_INACTIVE)"
>
{{ 'ORG.PAGES.DEACTIVATE' | translate }}
</button>
<button
mat-menu-item
*ngIf="org?.state === OrgState.ORG_STATE_INACTIVE"
(click)="changeState(OrgState.ORG_STATE_ACTIVE)"
>
{{ 'ORG.PAGES.REACTIVATE' | translate }}
</button>
<button data-e2e="rename" mat-menu-item (click)="renameOrg()">
{{ 'ORG.PAGES.RENAME.ACTION' | translate }}
</button>
<button data-e2e="delete" mat-menu-item (click)="deleteOrg()">
{{ 'ORG.PAGES.DELETE' | translate }}
</button>
</ng-container>
<cnsl-contributors
topContributors
[totalResult]="totalMemberResult"
[loading]="loading$ | async"
[membersSubject]="membersSubject"
title="{{ 'PROJECT.MEMBER.TITLE' | translate }}"
description="{{ 'PROJECT.MEMBER.TITLEDESC' | translate }}"
(addClicked)="openAddMember()"
(showDetailClicked)="showDetail()"
(refreshClicked)="loadMembers()"
[disabled]="(['org.member.write'] | hasRole | async) === false"
<ng-container *ngIf="orgQuery.data() as org">
<cnsl-top-view
*ngIf="['org.write:' + org.id, 'org.write$'] | hasRole as hasWrite$"
[hasBackButton]="false"
title="{{ org.name }}"
[isActive]="org.state === OrganizationState.ACTIVE"
[isInactive]="org.state === OrganizationState.INACTIVE"
[hasContributors]="true"
stateTooltip="{{ 'ORG.STATE.' + org.state | translate }}"
[hasActions]="hasWrite$ | async"
>
</cnsl-contributors>
<ng-container topActions *ngIf="hasWrite$ | async">
<button mat-menu-item *ngIf="org.state === OrganizationState.ACTIVE" (click)="changeState(OrganizationState.INACTIVE)">
{{ 'ORG.PAGES.DEACTIVATE' | translate }}
</button>
<cnsl-info-row topContent *ngIf="org" [org]="org"></cnsl-info-row>
</cnsl-top-view>
<div class="max-width-container">
<cnsl-meta-layout>
<ng-container *ngIf="['policy.read'] | hasRole | async; else nopolicyreadpermission">
<cnsl-settings-grid [type]="PolicyComponentServiceType.MGMT"></cnsl-settings-grid>
<button mat-menu-item *ngIf="org.state === OrganizationState.INACTIVE" (click)="changeState(OrganizationState.ACTIVE)">
{{ 'ORG.PAGES.REACTIVATE' | translate }}
</button>
<button data-e2e="rename" mat-menu-item (click)="renameOrg(org)">
{{ 'ORG.PAGES.RENAME.ACTION' | translate }}
</button>
<button data-e2e="delete" mat-menu-item (click)="deleteOrg(org)">
{{ 'ORG.PAGES.DELETE' | translate }}
</button>
</ng-container>
<cnsl-contributors
topContributors
[totalResult]="totalMemberResult"
[loading]="loading$ | async"
[membersSubject]="membersSubject"
title="{{ 'PROJECT.MEMBER.TITLE' | translate }}"
description="{{ 'PROJECT.MEMBER.TITLEDESC' | translate }}"
(addClicked)="openAddMember()"
(showDetailClicked)="showDetail()"
(refreshClicked)="loadMembers()"
[disabled]="(['org.member.write'] | hasRole | async) === false"
>
</cnsl-contributors>
<cnsl-metadata
[description]="'DESCRIPTIONS.ORG.METADATA' | translate"
[metadata]="metadata"
[disabled]="(['org.write'] | hasRole | async) === false"
(editClicked)="editMetadata()"
(refresh)="loadMetadata()"
></cnsl-metadata>
<cnsl-info-row topContent [org]="org"></cnsl-info-row>
</cnsl-top-view>
<div class="max-width-container">
<cnsl-meta-layout>
<cnsl-metadata
[description]="'DESCRIPTIONS.ORG.METADATA' | translate"
[metadata]="metadata"
[disabled]="(['org.write'] | hasRole | async) === false"
(editClicked)="editMetadata()"
(refresh)="loadMetadata()"
></cnsl-metadata>
<ng-template #nopolicyreadpermission>
<div class="no-permission-warn-wrapper">
<cnsl-info-section class="info-section-warn" [fitWidth]="true" [type]="InfoSectionType.ALERT">{{
'ORG.PAGES.NOPERMISSION' | translate
}}</cnsl-info-section>
<div metainfo>
<cnsl-changes *ngIf="org && reloadChanges()" [changeType]="ChangeType.ORG" [id]="org.id"></cnsl-changes>
</div>
</ng-template>
<div metainfo>
<cnsl-changes *ngIf="org" [changeType]="ChangeType.ORG" [id]="org.id"></cnsl-changes>
</div>
</cnsl-meta-layout>
</div>
</cnsl-meta-layout>
</div>
</ng-container>

View File

@@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, effect, OnInit, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { BehaviorSubject, from, Observable, of, Subject, takeUntil } from 'rxjs';
import { BehaviorSubject, from, lastValueFrom, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { CreationType, MemberCreateDialogComponent } from 'src/app/modules/add-member-dialog/member-create-dialog.component';
import { ChangeType } from 'src/app/modules/changes/changes.component';
@@ -12,24 +12,24 @@ import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-comp
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Member } from 'src/app/proto/generated/zitadel/member_pb';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { Org, OrgState } 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 { Breadcrumb, 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 { ToastService } from 'src/app/services/toast.service';
import { NewOrganizationService } from '../../../services/new-organization.service';
import { injectMutation } from '@tanstack/angular-query-experimental';
import { Organization, OrganizationState } from '@zitadel/proto/zitadel/org/v2/org_pb';
import { toObservable } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-org-detail',
templateUrl: './org-detail.component.html',
styleUrls: ['./org-detail.component.scss'],
})
export class OrgDetailComponent implements OnInit, OnDestroy {
public org?: Org.AsObject;
export class OrgDetailComponent implements OnInit {
public PolicyComponentServiceType: any = PolicyComponentServiceType;
public OrgState: any = OrgState;
public OrganizationState = OrganizationState;
public ChangeType: any = ChangeType;
public metadata: Metadata.AsObject[] = [];
@@ -40,18 +40,25 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
public totalMemberResult: number = 0;
public membersSubject: BehaviorSubject<Member.AsObject[]> = new BehaviorSubject<Member.AsObject[]>([]);
private destroy$: Subject<void> = new Subject();
public InfoSectionType: any = InfoSectionType;
protected readonly orgQuery = this.newOrganizationService.activeOrganizationQuery();
private readonly reactivateOrgMutation = injectMutation(this.newOrganizationService.reactivateOrgMutationOptions);
private readonly deactivateOrgMutation = injectMutation(this.newOrganizationService.deactivateOrgMutationOptions);
private readonly deleteOrgMutation = injectMutation(this.newOrganizationService.deleteOrgMutationOptions);
private readonly renameOrgMutation = injectMutation(this.newOrganizationService.renameOrgMutationOptions);
protected reloadChanges = signal(true);
constructor(
private auth: GrpcAuthService,
private dialog: MatDialog,
public mgmtService: ManagementService,
private adminService: AdminService,
private toast: ToastService,
private router: Router,
private readonly dialog: MatDialog,
private readonly mgmtService: ManagementService,
private readonly toast: ToastService,
private readonly router: Router,
private readonly newOrganizationService: NewOrganizationService,
breadcrumbService: BreadcrumbService,
cdr: ChangeDetectorRef,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
@@ -59,26 +66,30 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
};
breadcrumbService.setBreadcrumb([bread]);
auth.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => {
if (this.org && org) {
this.getData();
this.loadMetadata();
effect(() => {
const orgId = this.newOrganizationService.orgId();
if (!orgId) {
return;
}
this.loadMembers();
this.loadMetadata();
});
// force rerender changes because it is not reactive to orgId changes
toObservable(this.newOrganizationService.orgId).subscribe(() => {
this.reloadChanges.set(false);
cdr.detectChanges();
this.reloadChanges.set(true);
});
}
public ngOnInit(): void {
this.getData();
this.loadMembers();
this.loadMetadata();
}
public ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public changeState(newState: OrgState): void {
if (newState === OrgState.ORG_STATE_ACTIVE) {
public async changeState(newState: OrganizationState) {
if (newState === OrganizationState.ACTIVE) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.REACTIVATE',
@@ -88,20 +99,20 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.reactivateOrg()
.then(() => {
this.toast.showInfo('ORG.TOAST.REACTIVATED', true);
this.org!.state = OrgState.ORG_STATE_ACTIVE;
})
.catch((error) => {
this.toast.showError(error);
});
}
});
} else if (newState === OrgState.ORG_STATE_INACTIVE) {
const resp = await lastValueFrom(dialogRef.afterClosed());
if (!resp) {
return;
}
try {
await this.reactivateOrgMutation.mutateAsync();
this.toast.showInfo('ORG.TOAST.REACTIVATED', true);
} catch (error) {
this.toast.showError(error);
}
return;
}
if (newState === OrganizationState.INACTIVE) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.DEACTIVATE',
@@ -111,23 +122,21 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.deactivateOrg()
.then(() => {
this.toast.showInfo('ORG.TOAST.DEACTIVATED', true);
this.org!.state = OrgState.ORG_STATE_INACTIVE;
})
.catch((error) => {
this.toast.showError(error);
});
}
});
const resp = await lastValueFrom(dialogRef.afterClosed());
if (!resp) {
return;
}
try {
await this.deactivateOrgMutation.mutateAsync();
this.toast.showInfo('ORG.TOAST.DEACTIVATED', true);
} catch (error) {
this.toast.showError(error);
}
}
}
public deleteOrg(): void {
public async deleteOrg(org: Organization) {
const mgmtUserData = {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
@@ -136,66 +145,25 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
hintKey: 'ORG.DIALOG.DELETE.TYPENAME',
hintParam: 'ORG.DIALOG.DELETE.DESCRIPTION',
confirmationKey: 'ORG.DIALOG.DELETE.ORGNAME',
confirmation: this.org?.name,
confirmation: org.name,
};
if (this.org) {
let dialogRef;
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: mgmtUserData,
width: '400px',
});
dialogRef = this.dialog.open(WarnDialogComponent, {
data: mgmtUserData,
width: '400px',
});
// Before we remove the org we get the current default org
// we have to query before the current org is removed
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.adminService
.getDefaultOrg()
.then((response) => {
const org = response?.org;
if (org) {
// We now remove the org
this.mgmtService
.removeOrg()
.then(() => {
setTimeout(() => {
// We change active org to default org as
// current org was deleted to avoid Organization doesn't exist
this.auth.setActiveOrg(org);
// Now we visit orgs
this.router.navigate(['/orgs']);
}, 1000);
this.toast.showInfo('ORG.TOAST.DELETED', true);
})
.catch((error) => {
this.toast.showError(error);
});
} else {
this.toast.showError('ORG.TOAST.DEFAULTORGNOTFOUND', false, true);
}
})
.catch((error) => {
this.toast.showError(error);
});
}
});
if (!(await lastValueFrom(dialogRef.afterClosed()))) {
return;
}
}
private async getData(): Promise<void> {
this.mgmtService
.getMyOrg()
.then((resp) => {
if (resp.org) {
this.org = resp.org;
}
})
.catch((error) => {
this.toast.showError(error);
});
this.loadMembers();
try {
await this.deleteOrgMutation.mutateAsync();
this.toast.showInfo('ORG.TOAST.DELETED', true);
await this.router.navigate(['/orgs']);
} catch (error) {
this.toast.showError(error);
}
}
public openAddMember(): void {
@@ -234,8 +202,8 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
});
}
public showDetail(): void {
this.router.navigate(['org/members']);
public showDetail() {
return this.router.navigate(['org/members']);
}
public loadMembers(): void {
@@ -296,10 +264,10 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
});
}
public renameOrg(): void {
public async renameOrg(org: Organization): Promise<void> {
const dialogRef = this.dialog.open(NameDialogComponent, {
data: {
name: this.org?.name,
name: org.name,
titleKey: 'ORG.PAGES.RENAME.TITLE',
descKey: 'ORG.PAGES.RENAME.DESCRIPTION',
labelKey: 'ORG.PAGES.NAME',
@@ -307,37 +275,20 @@ export class OrgDetailComponent implements OnInit, OnDestroy {
width: '400px',
});
dialogRef.afterClosed().subscribe((name) => {
if (name) {
this.updateOrg(name);
}
});
}
const name = await lastValueFrom(dialogRef.afterClosed());
if (org.name === name) {
return;
}
public updateOrg(name: string): void {
if (this.org) {
this.mgmtService
.updateOrg(name)
.then(() => {
this.toast.showInfo('ORG.TOAST.UPDATED', true);
if (this.org) {
this.org.name = name;
}
this.mgmtService
.getMyOrg()
.then((resp) => {
if (resp.org) {
this.org = resp.org;
this.auth.setActiveOrg(resp.org);
}
})
.catch((error) => {
this.toast.showError(error);
});
})
.catch((error) => {
this.toast.showError(error);
});
try {
await this.renameOrgMutation.mutateAsync(name);
this.toast.showInfo('ORG.TOAST.UPDATED', true);
const resp = await this.mgmtService.getMyOrg();
if (resp.org) {
await this.newOrganizationService.setOrgId(resp.org.id);
}
} catch (error) {
this.toast.showError(error);
}
}
}

View File

@@ -19,7 +19,6 @@ import { InputModule } from 'src/app/modules/input/input.module';
import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module';
import { MetadataModule } from 'src/app/modules/metadata/metadata.module';
import { NameDialogModule } from 'src/app/modules/name-dialog/name-dialog.module';
import { SettingsGridModule } from 'src/app/modules/settings-grid/settings-grid.module';
import { TopViewModule } from 'src/app/modules/top-view/top-view.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
@@ -55,7 +54,6 @@ import { OrgRoutingModule } from './org-routing.module';
MatProgressSpinnerModule,
MetadataModule,
TranslateModule,
SettingsGridModule,
ContributorsModule,
CopyToClipboardModule,
],

View File

@@ -220,13 +220,13 @@ export class ProjectGridComponent implements OnInit, OnDestroy {
}
private async getPrefixedItem(key: string): Promise<string | null> {
const org = this.storage.getItem<Org.AsObject>(StorageKey.organization, StorageLocation.session) as Org.AsObject;
return localStorage.getItem(`${org?.id}:${key}`);
const org = this.storage.getItem(StorageKey.organizationId, StorageLocation.session);
return localStorage.getItem(`${org}:${key}`);
}
private async setPrefixedItem(key: string, value: any): Promise<void> {
const org = this.storage.getItem<Org.AsObject>(StorageKey.organization, StorageLocation.session) as Org.AsObject;
return localStorage.setItem(`${org.id}:${key}`, value);
const org = this.storage.getItem(StorageKey.organizationId, StorageLocation.session);
return localStorage.setItem(`${org}:${key}`, value);
}
public navigateToProject(type: ProjectType, item: Project.AsObject | GrantedProject.AsObject, event: any): void {

View File

@@ -1,4 +1,5 @@
<cnsl-create-layout
*ngIf="activateOrganizationQuery.data() as org"
title="{{ 'GRANTS.CREATE.TITLE' | translate }}"
[createSteps]="createSteps"
[currentCreateStep]="currentCreateStep"

View File

@@ -1,17 +1,16 @@
import { Location } from '@angular/common';
import { Component, OnDestroy } from '@angular/core';
import { Component, effect, OnDestroy } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
import { ProjectType } from 'src/app/modules/project-members/project-members-datasource';
import { UserTarget } from 'src/app/modules/search-user-autocomplete/search-user-autocomplete.component';
import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { GrantedProject, Project } from 'src/app/proto/generated/zitadel/project_pb';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { StorageKey, StorageLocation, StorageService } from 'src/app/services/storage.service';
import { ToastService } from 'src/app/services/toast.service';
import { NewOrganizationService } from '../../services/new-organization.service';
@Component({
selector: 'cnsl-user-grant-create',
@@ -21,7 +20,7 @@ import { ToastService } from 'src/app/services/toast.service';
export class UserGrantCreateComponent implements OnDestroy {
public context!: UserGrantContext;
public org?: Org.AsObject;
public activateOrganizationQuery = this.newOrganizationService.activeOrganizationQuery();
public userIds: string[] = [];
public project?: Project.AsObject;
@@ -37,16 +36,15 @@ export class UserGrantCreateComponent implements OnDestroy {
public user?: User.AsObject;
public UserTarget: any = UserTarget;
public editState: boolean = false;
private destroy$: Subject<void> = new Subject();
constructor(
private userService: ManagementService,
private toast: ToastService,
private _location: Location,
private route: ActivatedRoute,
private mgmtService: ManagementService,
private storage: StorageService,
private readonly userService: ManagementService,
private readonly toast: ToastService,
private readonly _location: Location,
private readonly route: ActivatedRoute,
private readonly mgmtService: ManagementService,
private readonly newOrganizationService: NewOrganizationService,
breadcrumbService: BreadcrumbService,
) {
breadcrumbService.setBreadcrumb([
@@ -101,10 +99,11 @@ export class UserGrantCreateComponent implements OnDestroy {
}
});
const temporg = this.storage.getItem<Org.AsObject>(StorageKey.organization, StorageLocation.session);
if (temporg) {
this.org = temporg;
}
effect(() => {
if (this.activateOrganizationQuery.isError()) {
this.toast.showError(this.activateOrganizationQuery.error());
}
});
}
public close(): void {

View File

@@ -33,6 +33,7 @@ import { PasswordComplexityValidatorFactoryService } from 'src/app/services/pass
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { NewOrganizationService } from '../../../../services/new-organization.service';
type PwdForm = ReturnType<UserCreateV2Component['buildPwdForm']>;
type AuthenticationFactor =
@@ -54,6 +55,7 @@ export class UserCreateV2Component implements OnInit {
private readonly passwordComplexityPolicy$: Observable<PasswordComplexityPolicy>;
protected readonly authenticationFactor$: Observable<AuthenticationFactor>;
private readonly useLoginV2$: Observable<LoginV2FeatureFlag | undefined>;
private orgId = this.organizationService.getOrgId();
constructor(
private readonly router: Router,
@@ -67,6 +69,7 @@ export class UserCreateV2Component implements OnInit {
private readonly route: ActivatedRoute,
protected readonly location: Location,
private readonly authService: GrpcAuthService,
private readonly organizationService: NewOrganizationService,
) {
this.userForm = this.buildUserForm();
@@ -182,12 +185,11 @@ export class UserCreateV2Component implements OnInit {
private async createUserV2Try(authenticationFactor: AuthenticationFactor) {
this.loading.set(true);
const org = await this.authService.getActiveOrg();
this.organizationService.getOrgId();
const userValues = this.userForm.getRawValue();
const humanReq: MessageInitShape<typeof AddHumanUserRequestSchema> = {
organization: { org: { case: 'orgId', value: org.id } },
organization: { org: { case: 'orgId', value: this.orgId() } },
username: userValues.username,
profile: {
givenName: userValues.givenName,

View File

@@ -1,34 +1,34 @@
<ng-container *ngIf="user$ | async as userQuery">
<ng-container>
<cnsl-top-view
title="{{ userName$ | async }}"
sub="{{ user(userQuery)?.preferredLoginName }}"
[isActive]="user(userQuery)?.state === UserState.ACTIVE"
[isInactive]="user(userQuery)?.state === UserState.INACTIVE"
stateTooltip="{{ 'USER.STATE.' + user(userQuery)?.state | translate }}"
[title]="userName()"
sub="{{ user.data()?.preferredLoginName }}"
[isActive]="user.data()?.state === UserState.ACTIVE"
[isInactive]="user.data()?.state === UserState.INACTIVE"
stateTooltip="{{ 'USER.STATE.' + user.data()?.state | translate }}"
[hasBackButton]="['org.read'] | hasRole | async"
>
<cnsl-info-row
topContent
*ngIf="user(userQuery) as user"
*ngIf="user.data() as user"
[user]="user"
[loginPolicy]="(loginPolicy$ | async) ?? undefined"
></cnsl-info-row>
/>
</cnsl-top-view>
<div *ngIf="(user$ | async)?.state === 'loading'" class="max-width-container">
<div *ngIf="user.isLoading()" class="max-width-container">
<div class="user-spinner-wrapper">
<mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
</div>
<div class="max-width-container">
<cnsl-meta-layout *ngIf="user(userQuery) as user">
<cnsl-meta-layout *ngIf="user.data() as user">
<cnsl-sidenav
[setting]="currentSetting$()"
(settingChange)="currentSetting$.set($event)"
[settingsList]="settingsList"
>
<ng-container *ngIf="currentSetting$().id === 'general' && humanUser(userQuery) as humanUser">
<ng-container *ngIf="currentSetting$().id === 'general' && humanUser(user) as humanUser">
<cnsl-card
*ngIf="humanUser.type.value.profile as profile"
class="app-card"
@@ -45,7 +45,7 @@
(changedLanguage)="changedLanguage($event)"
(changeUsernameClicked)="changeUsername(user)"
(submitData)="saveProfile(user, $event)"
(avatarChanged)="refreshChanges$.emit()"
(avatarChanged)="invalidateUser()"
>
</cnsl-detail-form>
</cnsl-card>
@@ -58,7 +58,7 @@
class="icon-button"
card-actions
mat-icon-button
(click)="refreshChanges$.emit()"
(click)="invalidateUser()"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
>
<mat-icon class="icon">refresh</mat-icon>
@@ -94,7 +94,7 @@
</ng-container>
<ng-container *ngIf="currentSetting$().id === 'security'">
<cnsl-card *ngIf="humanUser(userQuery) as humanUser" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<cnsl-card *ngIf="humanUser(user) as humanUser" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
<div class="left">
@@ -121,7 +121,7 @@
<cnsl-auth-passwordless #mfaComponent></cnsl-auth-passwordless>
<cnsl-auth-user-mfa
[phoneVerified]="humanUser(userQuery)?.type?.value?.phone?.isVerified ?? false"
[phoneVerified]="humanUser(user)?.type?.value?.phone?.isVerified ?? false"
></cnsl-auth-user-mfa>
</ng-container>
@@ -170,7 +170,7 @@
</cnsl-sidenav>
<div metainfo>
<cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.MYUSER"> </cnsl-changes>
<cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.MYUSER" />
</div>
</cnsl-meta-layout>
</div>

View File

@@ -1,11 +1,10 @@
import { MediaMatcher } from '@angular/cdk/layout';
import { Component, DestroyRef, EventEmitter, OnInit, signal } from '@angular/core';
import { Component, computed, DestroyRef, effect, OnInit, signal } from '@angular/core';
import { Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Buffer } from 'buffer';
import { defer, EMPTY, mergeWith, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs';
import { defer, EMPTY, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs';
import { ChangeType } from 'src/app/modules/changes/changes.component';
import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component';
@@ -34,8 +33,7 @@ import { Metadata } from '@zitadel/proto/zitadel/metadata_pb';
import { UserService } from 'src/app/services/user.service';
import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { query } from '@angular/animations';
type UserQuery = { state: 'success'; value: User } | { state: 'error'; error: any } | { state: 'loading'; value?: User };
import { QueryClient } from '@tanstack/angular-query-experimental';
type MetadataQuery =
| { state: 'success'; value: Metadata[] }
@@ -57,7 +55,6 @@ export class AuthUserDetailComponent implements OnInit {
protected readonly UserState = UserState;
protected USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER;
protected readonly refreshChanges$: EventEmitter<void> = new EventEmitter();
protected readonly refreshMetadata$ = new Subject<true>();
protected readonly settingsList: SidenavSetting[] = [
@@ -72,12 +69,25 @@ export class AuthUserDetailComponent implements OnInit {
requiredRoles: { [PolicyComponentServiceType.MGMT]: ['user.read'] },
},
];
protected readonly user$: Observable<UserQuery>;
protected readonly metadata$: Observable<MetadataQuery>;
private readonly savedLanguage$: Observable<string>;
protected readonly currentSetting$ = signal<SidenavSetting>(this.settingsList[0]);
protected readonly loginPolicy$: Observable<LoginPolicy>;
protected readonly userName$: Observable<string>;
protected readonly user = this.userService.userQuery();
protected readonly refreshChanges$ = new Subject<void>();
protected readonly userName = computed(() => {
const user = this.user.data();
if (!user) {
return '';
}
if (user.type.case === 'human') {
return user.type.value.profile?.displayName ?? '';
}
if (user.type.case === 'machine') {
return user.type.value.name;
}
return '';
});
constructor(
private translate: TranslateService,
@@ -92,11 +102,8 @@ export class AuthUserDetailComponent implements OnInit {
private readonly newMgmtService: NewMgmtService,
private readonly userService: UserService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly queryClient: QueryClient,
) {
this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.userName$ = this.getUserName(this.user$);
this.savedLanguage$ = this.getSavedLanguage$(this.user$);
this.metadata$ = this.getMetadata$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
@@ -104,61 +111,40 @@ export class AuthUserDetailComponent implements OnInit {
map(({ policy }) => policy),
filter(Boolean),
);
}
getUserName(user$: Observable<UserQuery>) {
return user$.pipe(
map((query) => {
const user = this.user(query);
if (!user) {
return '';
effect(
() => {
const user = this.user.data();
if (!user || user.type.case !== 'human') {
return;
}
if (user.type.case === 'human') {
return user.type.value.profile?.displayName ?? '';
}
if (user.type.case === 'machine') {
return user.type.value.name;
}
return '';
}),
);
}
getSavedLanguage$(user$: Observable<UserQuery>) {
return user$.pipe(
switchMap((query) => {
if (query.state !== 'success' || query.value.type.case !== 'human') {
return EMPTY;
}
return query.value.type.value.profile?.preferredLanguage ?? EMPTY;
}),
startWith(this.translate.defaultLang),
);
}
ngOnInit(): void {
this.user$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if ((query.state === 'loading' || query.state === 'success') && query.value?.type.case === 'human') {
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: query.value.type.value.profile?.displayName,
name: user.type.value.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
},
{ allowSignalWrites: true },
);
effect(() => {
const error = this.user.error();
if (error) {
this.toast.showError(error);
}
});
}
this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
ngOnInit(): void {
this.metadata$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if (query.state == 'error') {
this.toast.showError(query.error);
}
});
this.savedLanguage$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((savedLanguage) => this.translate.use(savedLanguage));
const param = this.route.snapshot.queryParamMap.get('id');
if (!param) {
return;
@@ -170,28 +156,6 @@ export class AuthUserDetailComponent implements OnInit {
this.currentSetting$.set(setting);
}
private getUser$(): Observable<UserQuery> {
return this.refreshChanges$.pipe(
startWith(true),
switchMap(() => this.getMyUser()),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { state: 'loading', value: prev.value } as const;
}
return curr;
}),
);
}
private getMyUser(): Observable<UserQuery> {
return this.userService.user$.pipe(
map((user) => ({ state: 'success' as const, value: user })),
catchError((error) => of({ state: 'error', error } as const)),
startWith({ state: 'loading' } as const),
);
}
getMetadata$(): Observable<MetadataQuery> {
return this.refreshMetadata$.pipe(
startWith(true),
@@ -214,7 +178,14 @@ export class AuthUserDetailComponent implements OnInit {
);
}
public changeUsername(user: User): void {
protected invalidateUser() {
this.refreshChanges$.next();
return this.queryClient.invalidateQueries({
queryKey: this.userService.userQueryOptions().queryKey,
});
}
protected changeUsername(user: User): void {
const data = {
confirmKey: 'ACTIONS.CHANGE' as const,
cancelKey: 'ACTIONS.CANCEL' as const,
@@ -239,7 +210,7 @@ export class AuthUserDetailComponent implements OnInit {
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
},
error: (error) => {
this.toast.showError(error);
@@ -262,7 +233,7 @@ export class AuthUserDetailComponent implements OnInit {
})
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
})
.catch((error) => {
this.toast.showError(error);
@@ -274,7 +245,7 @@ export class AuthUserDetailComponent implements OnInit {
.verifyMyPhone(code)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
this.promptSetupforSMSOTP();
})
.catch((error) => {
@@ -315,7 +286,7 @@ export class AuthUserDetailComponent implements OnInit {
.resendHumanEmailVerification(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
})
.catch((error) => {
this.toast.showError(error);
@@ -327,7 +298,7 @@ export class AuthUserDetailComponent implements OnInit {
.resendHumanPhoneVerification(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
})
.catch((error) => {
this.toast.showError(error);
@@ -339,7 +310,7 @@ export class AuthUserDetailComponent implements OnInit {
.removePhone(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
})
.catch((error) => {
this.toast.showError(error);
@@ -388,7 +359,7 @@ export class AuthUserDetailComponent implements OnInit {
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
},
error: (error) => this.toast.showError(error),
});
@@ -420,7 +391,7 @@ export class AuthUserDetailComponent implements OnInit {
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshChanges$.emit();
this.invalidateUser().then();
},
error: (error) => {
this.toast.showError(error);
@@ -482,24 +453,7 @@ export class AuthUserDetailComponent implements OnInit {
protected readonly query = query;
protected user(user: UserQuery): User | undefined {
if (user.state === 'success' || user.state === 'loading') {
return user.value;
}
return;
}
public async goToSetting(setting: string) {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: { id: setting },
queryParamsHandling: 'merge',
skipLocationChange: true,
});
}
public humanUser(userQuery: UserQuery): UserWithHumanType | undefined {
const user = this.user(userQuery);
public humanUser(user: User | undefined): UserWithHumanType | undefined {
if (user?.type.case === 'human') {
return { ...user, type: user.type };
}

View File

@@ -1,4 +1,4 @@
import { Component, DestroyRef, OnInit } from '@angular/core';
import { Component, computed, DestroyRef, OnInit } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import {
@@ -17,7 +17,7 @@ import { passwordConfirmValidator, requiredValidator } from 'src/app/modules/for
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ToastService } from 'src/app/services/toast.service';
import { catchError, filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { UserService } from 'src/app/services/user.service';
import { User } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { NewAuthService } from 'src/app/services/new-auth.service';
@@ -62,12 +62,16 @@ export class PasswordComponent implements OnInit {
}
private getUser() {
return this.userService.user$.pipe(
catchError((err) => {
this.toast.showError(err);
return EMPTY;
}),
);
const userQuery = this.userService.userQuery();
const userSignal = computed(() => {
if (userQuery.isError()) {
this.toast.showError(userQuery.error());
}
return userQuery.data();
});
return toObservable(userSignal).pipe(filter(Boolean));
}
private getUsername(user$: Observable<User>) {

View File

@@ -37,7 +37,6 @@ import {
combineLatestWith,
defer,
EMPTY,
identity,
mergeWith,
Observable,
ObservedValueOf,

View File

@@ -1,244 +1,247 @@
<cnsl-refresh-table
*ngIf="type$ | async as type"
[loading]="loading()"
(refreshed)="this.refresh$.next(true)"
[hideRefresh]="true"
[timestamp]="(users$ | async)?.details?.timestamp"
[selection]="selection"
[showBorder]="true"
>
<div leftActions class="user-toggle-group">
<cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.HUMANS.TITLE' | translate }}"
(clicked)="setType(Type.HUMAN)"
[active]="type === Type.HUMAN"
data-e2e="list-humans"
></cnsl-nav-toggle>
<cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.MACHINES.TITLE' | translate }}"
(clicked)="setType(Type.MACHINE)"
[active]="type === Type.MACHINE"
data-e2e="list-machines"
></cnsl-nav-toggle>
</div>
<p class="user-sub cnsl-secondary-text">
{{
(type === Type.HUMAN ? 'DESCRIPTIONS.USERS.HUMANS.DESCRIPTION' : 'DESCRIPTIONS.USERS.MACHINES.DESCRIPTION') | translate
}}
</p>
<ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button
(click)="deactivateSelectedUsers()"
class="bg-state inactive"
mat-raised-button
*ngIf="selection.hasValue() && multipleDeactivatePossible"
[disabled]="(canWrite$ | async) === false"
color="primary"
>
<div class="cnsl-action-button">
<span class="">{{ 'USER.TABLE.DEACTIVATE' | translate }}</span>
<cnsl-action-keys (actionTriggered)="deactivateSelectedUsers()" [type]="ActionKeysType.DEACTIVATE">
</cnsl-action-keys>
</div>
</button>
<button
(click)="reactivateSelectedUsers()"
class="bg-state active margin-left"
mat-raised-button
*ngIf="selection.hasValue() && multipleActivatePossible"
[disabled]="(canWrite$ | async) === false"
color="primary"
>
<div class="cnsl-action-button">
<span class="">{{ 'USER.TABLE.ACTIVATE' | translate }}</span>
<cnsl-action-keys (actionTriggered)="reactivateSelectedUsers()" [type]="ActionKeysType.REACTIVATE">
</cnsl-action-keys>
</div>
</button>
</ng-template>
<cnsl-filter-user
actions
*ngIf="!selection.hasValue()"
(filterChanged)="this.searchQueries$.next($any($event))"
(filterOpen)="filterOpen = $event"
></cnsl-filter-user>
<ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button
(click)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
color="primary"
mat-raised-button
[disabled]="(canWrite$ | async) === false"
<ng-container *ngIf="userQuery.data() as authUser">
<cnsl-refresh-table
*ngIf="type$ | async as type"
[loading]="loading()"
(refreshed)="this.refresh$.next(true)"
[hideRefresh]="true"
[timestamp]="(users$ | async)?.details?.timestamp"
[selection]="selection"
[showBorder]="true"
>
<div leftActions class="user-toggle-group">
<cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.HUMANS.TITLE' | translate }}"
(clicked)="setType(Type.HUMAN)"
[active]="type === Type.HUMAN"
data-e2e="list-humans"
></cnsl-nav-toggle>
<cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.MACHINES.TITLE' | translate }}"
(clicked)="setType(Type.MACHINE)"
[active]="type === Type.MACHINE"
data-e2e="list-machines"
></cnsl-nav-toggle>
</div>
<p class="user-sub cnsl-secondary-text">
{{
(type === Type.HUMAN ? 'DESCRIPTIONS.USERS.HUMANS.DESCRIPTION' : 'DESCRIPTIONS.USERS.MACHINES.DESCRIPTION')
| translate
}}
</p>
<ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button
(click)="deactivateSelectedUsers()"
class="bg-state inactive"
mat-raised-button
*ngIf="selection.hasValue() && multipleDeactivatePossible"
[disabled]="(canWrite$ | async) === false"
color="primary"
>
<div class="cnsl-action-button">
<span class="">{{ 'USER.TABLE.DEACTIVATE' | translate }}</span>
<cnsl-action-keys (actionTriggered)="deactivateSelectedUsers()" [type]="ActionKeysType.DEACTIVATE">
</cnsl-action-keys>
</div>
</button>
<button
(click)="reactivateSelectedUsers()"
class="bg-state active margin-left"
mat-raised-button
*ngIf="selection.hasValue() && multipleActivatePossible"
[disabled]="(canWrite$ | async) === false"
color="primary"
>
<div class="cnsl-action-button">
<span class="">{{ 'USER.TABLE.ACTIVATE' | translate }}</span>
<cnsl-action-keys (actionTriggered)="reactivateSelectedUsers()" [type]="ActionKeysType.REACTIVATE">
</cnsl-action-keys>
</div>
</button>
</ng-template>
<cnsl-filter-user
actions
*ngIf="!selection.hasValue()"
data-e2e="create-user-button"
>
<div class="cnsl-action-button">
<mat-icon class="icon">add</mat-icon>
<span>{{ 'ACTIONS.NEW' | translate }}</span>
<cnsl-action-keys
*ngIf="!filterOpen"
(actionTriggered)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
>
</cnsl-action-keys>
</div>
</button>
</ng-template>
(filterChanged)="this.searchQueries$.next($any($event))"
(filterOpen)="filterOpen = $event"
></cnsl-filter-user>
<ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button
(click)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
color="primary"
mat-raised-button
[disabled]="(canWrite$ | async) === false"
*ngIf="!selection.hasValue()"
data-e2e="create-user-button"
>
<div class="cnsl-action-button">
<mat-icon class="icon">add</mat-icon>
<span>{{ 'ACTIONS.NEW' | translate }}</span>
<cnsl-action-keys
*ngIf="!filterOpen"
(actionTriggered)="router.navigate(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
>
</cnsl-action-keys>
</div>
</button>
</ng-template>
<div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<div class="selection">
<mat-checkbox
class="checkbox"
[disabled]="(canWrite$ | async) === false"
color="primary"
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
<cnsl-avatar class="hidden" [isMachine]="true">
<i class="las la-robot"></i>
</cnsl-avatar>
</mat-checkbox>
</div>
</th>
<td mat-cell *matCellDef="let user">
<div class="selection">
<mat-checkbox
class="checkbox"
[disabled]="(canWrite$ | async) === false"
color="primary"
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(user) : null"
[checked]="selection.isSelected(user)"
>
<cnsl-avatar
*ngIf="user.type.case === 'human' && user.type.value.profile; else cog"
class="avatar"
[name]="user.type.value.profile.displayName"
[avatarUrl]="user.type.value.profile.avatarUrl || ''"
[forColor]="user.preferredLoginName"
[size]="32"
<div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<div class="selection">
<mat-checkbox
class="checkbox"
[disabled]="(canWrite$ | async) === false"
color="primary"
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</cnsl-avatar>
<ng-template #cog>
<cnsl-avatar [forColor]="user?.preferredLoginName" [isMachine]="true">
<cnsl-avatar class="hidden" [isMachine]="true">
<i class="las la-robot"></i>
</cnsl-avatar>
</ng-template>
</mat-checkbox>
</div>
</td>
</ng-container>
</mat-checkbox>
</div>
</th>
<td mat-cell *matCellDef="let user">
<div class="selection">
<mat-checkbox
class="checkbox"
[disabled]="(canWrite$ | async) === false"
color="primary"
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(user) : null"
[checked]="selection.isSelected(user)"
>
<cnsl-avatar
*ngIf="user.type.case === 'human' && user.type.value.profile; else cog"
class="avatar"
[name]="user.type.value.profile.displayName"
[avatarUrl]="user.type.value.profile.avatarUrl || ''"
[forColor]="user.preferredLoginName"
[size]="32"
>
</cnsl-avatar>
<ng-template #cog>
<cnsl-avatar [forColor]="user?.preferredLoginName" [isMachine]="true">
<i class="las la-robot"></i>
</cnsl-avatar>
</ng-template>
</mat-checkbox>
</div>
</td>
</ng-container>
<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.DISPLAYNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type.case === 'human'">{{ user.type.value?.profile?.displayName }}</span>
<span *ngIf="user.type.case === 'machine'">{{ user.type.value.name }}</span>
</td>
</ng-container>
<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.DISPLAYNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type.case === 'human'">{{ user.type.value?.profile?.displayName }}</span>
<span *ngIf="user.type.case === 'machine'">{{ user.type.value.name }}</span>
</td>
</ng-container>
<ng-container matColumnDef="preferredLoginName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type.case === 'human'">{{ user.preferredLoginName }}</span>
</td>
</ng-container>
<ng-container matColumnDef="preferredLoginName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type.case === 'human'">{{ user.preferredLoginName }}</span>
</td>
</ng-container>
<ng-container matColumnDef="username">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.USERNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
{{ user.username }}
</td>
</ng-container>
<ng-container matColumnDef="username">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.PROFILE.USERNAME' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
{{ user.username }}
</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.EMAIL' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type?.value?.email?.email">{{ user.type.value.email.email }}</span>
</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'USER.EMAIL' | translate }}
</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.type?.value?.email?.email">{{ user.type.value.email.email }}</span>
</td>
</ng-container>
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.DATA.STATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span
class="state"
[ngClass]="{
active: user.state === UserState.ACTIVE,
inactive: user.state === UserState.INACTIVE,
}"
>
{{ 'USER.STATEV2.' + user.state | translate }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.TABLE.CREATIONDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="changeDate">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.TABLE.CHANGEDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef class="user-tr-actions"></th>
<td mat-cell *matCellDef="let user" class="user-tr-actions">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="deleteUser(user)"
[disabled]="(canWrite$ | async) === false || (canDelete$ | async) === false"
[attr.data-e2e]="
(canWrite$ | async) === false || (canDelete$ | async) === false
? 'disabled-delete-button'
: 'enabled-delete-button'
"
mat-icon-button
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.DATA.STATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span
class="state"
[ngClass]="{
active: user.state === UserState.ACTIVE,
inactive: user.state === UserState.INACTIVE,
}"
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
{{ 'USER.STATEV2.' + user.state | translate }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"></tr>
<tr
class="highlight pointer"
mat-row
*matRowDef="let user; columns: type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"
></tr>
</table>
</div>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.TABLE.CREATIONDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<div *ngIf="!loading() && !dataSource?.data?.length" class="no-content-row">
<i class="las la-exclamation"></i>
<span>{{ 'USER.TABLE.EMPTY' | translate }}</span>
</div>
<cnsl-paginator
class="paginator"
[length]="dataSize()"
[pageSize]="INITIAL_PAGE_SIZE"
[timestamp]="(users$ | async)?.details?.timestamp"
[pageSizeOptions]="[10, 20, 50, 100]"
></cnsl-paginator>
<!-- (page)="changePage($event)"-->
</cnsl-refresh-table>
<ng-container matColumnDef="changeDate">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.TABLE.CHANGEDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef class="user-tr-actions"></th>
<td mat-cell *matCellDef="let user" class="user-tr-actions">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="deleteUser(user, authUser)"
[disabled]="(canWrite$ | async) === false || (canDelete$ | async) === false"
[attr.data-e2e]="
(canWrite$ | async) === false || (canDelete$ | async) === false
? 'disabled-delete-button'
: 'enabled-delete-button'
"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"></tr>
<tr
class="highlight pointer"
mat-row
*matRowDef="let user; columns: type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"
></tr>
</table>
</div>
<div *ngIf="!loading() && !dataSource?.data?.length" class="no-content-row">
<i class="las la-exclamation"></i>
<span>{{ 'USER.TABLE.EMPTY' | translate }}</span>
</div>
<cnsl-paginator
class="paginator"
[length]="dataSize()"
[pageSize]="INITIAL_PAGE_SIZE"
[timestamp]="(users$ | async)?.details?.timestamp"
[pageSizeOptions]="[10, 20, 50, 100]"
></cnsl-paginator>
<!-- (page)="changePage($event)"-->
</cnsl-refresh-table>
</ng-container>

Some files were not shown because too many files have changed in this diff Show More