fix(console): remove rehauled navigation #10863 (#10912)

# Which Problems Are Solved
The new breadcrumbs were causing problems with some more advanced
Zitadel setups. This pr removes the new breadcrumbs to revert to the old
navigation behaviour.

# How the Problems Are Solved
Most of the changes could be kept but those specific to the navigation
where mostly reverted.

# Additional Changes
Updated some dependencies.

# Additional Context
- Closes #10863
This commit is contained in:
Ramon
2026-01-16 08:30:09 +01:00
committed by GitHub
parent 13be5dc46d
commit e401da3cfa
49 changed files with 667 additions and 1452 deletions
+1 -1
View File
@@ -82,7 +82,7 @@
"@zitadel/client": "workspace:*",
"@zitadel/proto": "workspace:*",
"eslint": "^8.57.1",
"jasmine-core": "~5.6.0",
"jasmine-core": "~5.12.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
-8
View File
@@ -75,14 +75,6 @@ const routes: Routes = [
},
{
path: 'actions',
loadChildren: () => import('./pages/actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['iam.read', 'iam.read'],
},
},
{
path: 'actions-v1',
loadChildren: () => import('./pages/org-actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
+20 -45
View File
@@ -1,27 +1,16 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay';
import { ViewportScroller } from '@angular/common';
import {
Component,
DestroyRef,
effect,
HostBinding,
HostListener,
Inject,
OnDestroy,
ViewChild,
DOCUMENT,
} from '@angular/core';
import { Component, DestroyRef, HostBinding, HostListener, Inject, ViewChild, DOCUMENT } 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 } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { filter, map, startWith, switchMap } from 'rxjs/operators';
import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations';
import { Org } from './proto/generated/zitadel/org_pb';
import { PrivacyPolicy } from './proto/generated/zitadel/policy_pb';
import { AuthenticationService } from './services/authentication.service';
import { GrpcAuthService } from './services/grpc-auth.service';
@@ -55,15 +44,12 @@ export class AppComponent {
@HostListener('window:scroll', ['$event']) onScroll(event: Event): void {
this.yoffset = this.viewPortScroller.getScrollPosition()[1];
}
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;
constructor(
@@ -216,9 +202,8 @@ export class AppComponent {
this.getProjectCount();
effect(() => {
const orgId = this.newOrganizationService.orgId();
if (orgId) {
this.authService.activeOrgChanged.pipe(takeUntilDestroyed()).subscribe((org) => {
if (org?.id) {
this.getProjectCount();
}
});
@@ -227,30 +212,22 @@ export class AppComponent {
.pipe(
map((params) => params.get('org')),
filter(Boolean),
takeUntilDestroyed(this.destroyRef),
takeUntilDestroyed(),
)
.subscribe((orgId) => this.newOrganizationService.setOrgId(orgId));
.subscribe((orgId) => this.authService.getActiveOrg(orgId));
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.authenticationService.authenticationChanged
.pipe(
filter(Boolean),
switchMap(() => this.authService.getActiveOrg()),
takeUntilDestroyed(),
)
.subscribe({
error: async (err) => {
console.error(err);
return this.router.navigate(['/users/me']);
},
});
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => {
@@ -264,8 +241,6 @@ export class AppComponent {
});
}
// TODO implement Console storage
// private startIntroWorkflow(): void {
// setTimeout(() => {
// const cb = () => {
@@ -317,8 +292,8 @@ export class AppComponent {
private getProjectCount(): void {
this.authService.isAllowed(['project.read']).subscribe((allowed) => {
if (allowed) {
this.mgmtService.listProjects(0, 0);
this.mgmtService.listGrantedProjects(0, 0);
this.mgmtService.listProjects(0, 0).then();
this.mgmtService.listGrantedProjects(0, 0).then();
}
});
}
+1 -6
View File
@@ -75,7 +75,6 @@ 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 } from '@tanstack/angular-query-experimental';
import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
@@ -181,7 +180,6 @@ const authConfig: AuthConfig = {
MatDialogModule,
KeyboardShortcutsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }),
NewHeaderComponent,
CdkOverlayOrigin,
],
providers: [
@@ -253,10 +251,7 @@ const authConfig: AuthConfig = {
LanguagesService,
PosthogService,
{ provide: 'windowObject', useValue: window },
provideTanStackQuery(
new QueryClient(),
withDevtools(() => ({ loadDevtools: 'auto' })),
),
provideTanStackQuery(new QueryClient(), withDevtools()),
provideNgIconsConfig({
size: '1rem',
}),
@@ -4,11 +4,7 @@
<mat-spinner [diameter]="20"></mat-spinner>
</div>
<ng-template #logo>
<a
class="title custom"
[routerLink]="(['iam.read', 'iam.policy.read'] | hasRole | async) ? ['/'] : ['/org']"
*ngIf="authService.labelpolicy$ | async as lP; else defaultHome"
>
<a class="title custom" [routerLink]="['/']" *ngIf="authService.labelpolicy$ | async as lP; else defaultHome">
<img
class="logo"
alt="home logo"
@@ -41,7 +37,141 @@
</a>
</ng-template>
<cnsl-new-header></cnsl-new-header>
<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() as 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="showOrgContext && org() as org"
(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>
<span class="fill-space"></span>
@@ -55,6 +185,33 @@
</a>
</ng-container>
<div class="system-rel" *ngIf="!isOnMe">
<a
id="systembutton"
*ngIf="!isOnInstance && (['iam.read$', 'iam.write$'] | hasRole | async)"
[routerLink]="['/instance']"
class="iam-settings"
mat-stroked-button
>
<div class="cnsl-action-button">
<span class="iam-label">{{ 'MENU.INSTANCE' | translate }}</span>
<i class="las la-cog"></i>
</div>
</a>
<a
id="orgbutton"
*ngIf="isOnInstance && (['org.read'] | hasRole | async)"
[routerLink]="['/org']"
class="org-settings"
mat-stroked-button
>
<div class="cnsl-action-button">
<span class="iam-label">{{ 'MENU.ORGANIZATION' | translate }}</span>
<i class="las la-cog"></i>
</div>
</a>
</div>
<ng-container *ngIf="user && user.id">
<div class="account-card-wrapper">
<button
@@ -1,13 +1,13 @@
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, input, Input, Output } from '@angular/core';
import { Router } from '@angular/router';
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';
import { Org } from '@zitadel/proto/zitadel/org_pb';
@Component({
selector: 'cnsl-header',
@@ -20,11 +20,12 @@ export class HeaderComponent {
@Input({ required: true }) public user!: User.AsObject;
public showOrgContext: boolean = false;
@Input() public org?: Organization | null;
public org = input<Organization | Org | null>();
@Output() public changedActiveOrg = new EventEmitter<void>();
public showAccount: boolean = false;
protected readonly BreadcrumbType = BreadcrumbType;
protected readonly ActionKeysType = ActionKeysType;
public BreadcrumbType = BreadcrumbType;
public ActionKeysType = ActionKeysType;
public positions: ConnectedPosition[] = [
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
@@ -40,11 +41,10 @@ export class HeaderComponent {
public mgmtService: ManagementService,
public breadcrumbService: BreadcrumbService,
public router: Router,
private readonly newOrganizationService: NewOrganizationService,
) {}
public async setActiveOrg(orgId: string): Promise<void> {
await this.newOrganizationService.setOrgId(orgId);
await this.authService.getActiveOrg(orgId);
this.changedActiveOrg.emit();
}
@@ -17,7 +17,6 @@ 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],
@@ -39,7 +38,6 @@ import { NewHeaderComponent } from '../new-header/new-header.component';
AvatarModule,
AccountsCardModule,
HasRolePipeModule,
NewHeaderComponent,
],
exports: [HeaderComponent],
})
@@ -10,6 +10,7 @@ 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';
import { Org as OrgV1 } from '@zitadel/proto/zitadel/org_pb';
@Component({
selector: 'cnsl-info-row',
@@ -19,7 +20,7 @@ import { Organization as OrgV2 } from '@zitadel/proto/zitadel/org/v2/org_pb';
})
export class InfoRowComponent {
@Input() public user?: User.AsObject | UserV2 | UserV1;
@Input() public org!: Org.AsObject | OrgV2;
@Input() public org!: Org.AsObject | OrgV2 | OrgV1;
@Input() public instance!: InstanceDetail.AsObject;
@Input() public app!: App.AsObject;
@Input() public idp!: IDP.AsObject;
@@ -15,8 +15,6 @@ 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',
@@ -50,7 +48,6 @@ export class MembershipsTableComponent implements OnInit, OnDestroy {
private toast: ToastService,
private router: Router,
private workflowService: OverlayWorkflowService,
private readonly newOrganizationService: NewOrganizationService,
) {
this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => {
this.changedSelection.emit(this.selection.selected);
@@ -118,32 +115,32 @@ export class MembershipsTableComponent implements OnInit, OnDestroy {
}
public async goto(membership: Membership.AsObject) {
const orgId = this.newOrganizationService.orgId();
const org = await this.authService.getActiveOrg();
if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
// only shown on auth user, or if currentOrg === resourceOwner
try {
const membershipOrg = await this.newOrganizationService.setOrgId(membership.orgId);
const membershipOrg = await this.authService.getActiveOrg(membership.orgId);
await this.router.navigate(['/org/members']);
this.startOrgContextWorkflow(membershipOrg, orgId);
this.startOrgContextWorkflow(membershipOrg.id, org.id);
} catch (error) {
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
}
} else if (membership.projectGrantId && membership.details?.resourceOwner) {
// only shown on auth user
try {
const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner);
const membershipOrg = await this.authService.getActiveOrg(membership.details?.resourceOwner);
await this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]);
this.startOrgContextWorkflow(membershipOrg, orgId);
this.startOrgContextWorkflow(membershipOrg.id, org.id);
} 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
try {
const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner);
const membershipOrg = await this.authService.getActiveOrg(membership.details?.resourceOwner);
await this.router.navigate(['/projects', membership.projectId, 'members']);
this.startOrgContextWorkflow(membershipOrg, orgId);
this.startOrgContextWorkflow(membershipOrg.id, org.id);
} catch (error) {
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
}
@@ -153,8 +150,8 @@ export class MembershipsTableComponent implements OnInit, OnDestroy {
}
}
private startOrgContextWorkflow(membershipOrg: Organization, currentOrgId?: string | null): void {
if (!currentOrgId || (membershipOrg.id && currentOrgId && currentOrgId !== membershipOrg.id)) {
private startOrgContextWorkflow(membershipOrgId: string, currentOrgId?: string | null): void {
if (!currentOrgId || (membershipOrgId && currentOrgId && currentOrgId !== membershipOrgId)) {
setTimeout(() => {
this.workflowService.startWorkflow(OrgContextChangedWorkflowOverlays, null);
}, 1000);
+14 -48
View File
@@ -1,16 +1,19 @@
<ng-container *ngIf="['iam.read$', 'iam.write$'] | hasRole as iamuser$">
<div class="nav-col" [ngClass]="{ 'is-admin': (iamuser$ | async) }">
<ng-container *ngIf="breadcrumbService.breadcrumbsExtended$ | async as breadc">
<ng-container
*ngIf="breadcrumbService.breadcrumbsExtended$ && (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.AUTHUSER &&
breadc[breadc.length - 1].type !== BreadcrumbType.INSTANCE
"
[ngSwitch]="breadc[0].type"
>
<div class="nav-row" @navrow>
<ng-container *ngSwitchCase="BreadcrumbType.INSTANCE">
<ng-container *ngSwitchCase="BreadcrumbType.ORG">
<div class="nav-row-abs" @navrowproject>
<a
class="nav-item"
@@ -21,48 +24,6 @@
<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
@@ -122,7 +83,7 @@
<a
class="nav-item"
[routerLinkActive]="['active']"
[routerLink]="['/actions-v1']"
[routerLink]="['/actions']"
[routerLinkActiveOptions]="{ exact: true }"
>
<span class="label">{{ 'MENU.ACTIONS' | translate }}</span>
@@ -135,7 +96,12 @@
[routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: false }"
[routerLink]="['/org-settings']"
*ngIf="['policy.read'] | hasRole | async"
*ngIf="
(['policy.read'] | hasRole | async) &&
((['iam.read$', 'iam.write$'] | hasRole | async) === false ||
(((authService.cachedOrgs | async)?.length ?? 1) > 1 &&
(['iam.read$', 'iam.write$'] | hasRole | async)))
"
>
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
</a>
@@ -154,6 +120,7 @@
<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
@@ -193,7 +160,6 @@
</ng-container>
</ng-template>
<span class="fill-space"></span>
<div
(click)="openHelp()"
class="nav-shortcut-action-key"
+16 -40
View File
@@ -1,3 +1,5 @@
@use '@angular/material' as mat;
@mixin nav-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
@@ -17,14 +19,13 @@
height: auto;
position: relative;
align-items: flex-start;
transform: transale3d(0, 0, 0);
transform: translate3d(0, 0, 0);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
.nav-row {
position: relative;
width: 100%;
height: 40px; // Increased height to accommodate the line
overflow: visible; // Allow the indicator line to show below
height: 36px;
.nav-row-abs {
padding: 0 2rem;
@@ -33,7 +34,7 @@
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden; // Allow the indicator line to show
overflow-y: hidden;
align-self: stretch;
position: absolute;
top: 0;
@@ -52,45 +53,19 @@
.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;
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);
}
height: 27px;
.c_label {
display: flex;
@@ -124,12 +99,13 @@
}
}
&.active {
opacity: 1;
&:hover {
background: if($is-dark-theme, #ffffff40, #00000010);
}
&::after {
width: 100%;
}
&.active {
background-color: $primary-color;
color: map-get($primary, default-contrast);
.c_label {
.count {
+2 -1
View File
@@ -14,6 +14,7 @@ import { KeyboardShortcutsService } from 'src/app/services/keyboard-shortcuts/ke
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';
import { Org } from '@zitadel/proto/zitadel/org_pb';
@Component({
selector: 'cnsl-nav',
@@ -83,7 +84,7 @@ export class NavComponent implements OnDestroy {
}),
);
@Input() public org?: Organization | null;
@Input() public org?: Organization | Org | null;
public filterControl: UntypedFormControl = new UntypedFormControl('');
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
public showAccount: boolean = false;
@@ -1,4 +0,0 @@
<button class="header-button" matRipple [matRippleUnbounded]="false">
<ng-icon size="1.3rem" name="heroChevronUpDown"></ng-icon>
<span class="sr-only">{{ ariaLabel }}</span>
</button>
@@ -1,45 +0,0 @@
: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);
}
}
}
@@ -1,16 +0,0 @@
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'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIconComponent, MatRippleModule],
providers: [provideIcons({ heroChevronUpDown })],
})
export class HeaderButtonComponent {
@Input() ariaLabel: string = '';
}
@@ -1,15 +0,0 @@
<!-- 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>
@@ -1,46 +0,0 @@
@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;
}
}
}
@@ -1,104 +0,0 @@
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'],
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();
}
});
});
}
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
},
]),
);
}
}
@@ -1,26 +0,0 @@
<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>
@@ -1,40 +0,0 @@
@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;
}
}
}
}
@@ -1,35 +0,0 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, Input } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
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'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslateModule, MatButtonModule, 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(['/']);
}
}
@@ -1,82 +0,0 @@
<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>
@@ -1,35 +0,0 @@
@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);
}
}
}
@@ -1,113 +0,0 @@
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'],
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;
});
}
}
@@ -1,48 +0,0 @@
<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>
@@ -1,83 +0,0 @@
: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;
}
}
@@ -1,286 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
EventEmitter,
Input,
Output,
signal,
Signal,
ViewChild,
} from '@angular/core';
import { injectInfiniteQuery, injectMutation, keepPreviousData, QueryClient } 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'],
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,
private readonly queryClient: QueryClient,
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;
}
// no orgId set so we set it to the first org
if (!orgId) {
newOrganizationService.setOrgId(orgs[0].id).then();
return;
}
// user has a selected org and it was found
if (orgs.some((org) => org.id === orgId)) {
return;
}
// maybe the org is not yet loaded in the org selector so we try to fetch it
// if the user has permission to the org this will succeed and we do nothing
this.queryClient
.fetchQuery(this.newOrganizationService.organizationByIdQueryOptions(orgId))
.then((org) => {
if (org) {
return;
}
throw new Error('org not found');
})
.catch((_) => {
// user has no org selected or no permission for said org so we default to first org
return newOrganizationService.setOrgId(orgs[0].id);
});
});
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;
}
@@ -8,6 +8,7 @@ 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';
import { Org as OrgV1 } from '@zitadel/proto/zitadel/org_pb';
const ORG_QUERY_LIMIT = 100;
@@ -46,10 +47,10 @@ export class OrgContextComponent implements OnInit {
);
public filterControl: UntypedFormControl = new UntypedFormControl('');
@Input({ required: true }) public org!: Organization;
@Input({ required: true }) public org!: Organization | OrgV1;
@ViewChild('input', { static: false }) input!: ElementRef;
@Output() public closedCard: EventEmitter<void> = new EventEmitter();
@Output() public setOrg: EventEmitter<Org.AsObject> = new EventEmitter();
@Output() public setOrg: EventEmitter<string> = new EventEmitter();
constructor(
public authService: AuthenticationService,
@@ -67,8 +68,8 @@ export class OrgContextComponent implements OnInit {
this.init();
}
public setActiveOrg(org: Org.AsObject) {
this.setOrg.emit(org);
protected setActiveOrg(org: Org.AsObject) {
this.setOrg.emit(org.id);
this.closedCard.emit();
}
@@ -162,7 +162,7 @@ export class OrgTableComponent {
public async setAndNavigateToOrg(org: Organization): Promise<void> {
if (org.state !== OrganizationState.REMOVED) {
await this.newOrganizationService.setOrgId(org.id);
await this.authService.getActiveOrg(org.id);
await this.router.navigate(['/org']);
} else {
this.translate.get('ORG.TOAST.ORG_WAS_DELETED').subscribe((data) => {
@@ -1,6 +1,5 @@
import { Component, Injector, Input, OnDestroy, OnInit, Type } from '@angular/core';
import { Component, DestroyRef, Injector, Input, OnInit, Type } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Subscription } from 'rxjs';
import {
AddCustomDomainPolicyRequest,
GetCustomOrgIAMPolicyResponse,
@@ -10,12 +9,12 @@ import { GetOrgIAMPolicyResponse } from 'src/app/proto/generated/zitadel/managem
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 { 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';
import { GrpcAuthService } from '../../../services/grpc-auth.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-domain-policy',
@@ -23,15 +22,13 @@ import { NewOrganizationService } from '../../../services/new-organization.servi
styleUrls: ['./domain-policy.component.scss'],
standalone: false,
})
export class DomainPolicyComponent implements OnInit, OnDestroy {
export class DomainPolicyComponent implements OnInit {
private managementService!: ManagementService;
@Input() public serviceType!: PolicyComponentServiceType;
public domainData!: DomainPolicy.AsObject;
public loading: boolean = false;
private sub: Subscription = new Subscription();
private orgId = this.newOrganizationService.getOrgId();
public PolicyComponentServiceType: any = PolicyComponentServiceType;
@@ -40,33 +37,29 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
private toast: ToastService,
private injector: Injector,
private adminService: AdminService,
private readonly newOrganizationService: NewOrganizationService,
private readonly authService: GrpcAuthService,
private readonly destroyRef: DestroyRef,
) {}
ngOnInit(): void {
if (this.serviceType === PolicyComponentServiceType.MGMT) {
this.managementService = this.injector.get(ManagementService as Type<ManagementService>);
}
this.fetchData();
this.fetchData().then();
}
public ngOnDestroy(): void {
this.sub.unsubscribe();
}
public fetchData(): void {
public async fetchData(): Promise<void> {
this.loading = true;
this.getData()
.then((resp) => {
this.loading = false;
if (resp?.policy) {
this.domainData = resp.policy;
}
})
.catch((error) => {
this.loading = false;
this.toast.showError(error);
});
try {
const resp = await this.getData();
if (resp?.policy) {
this.domainData = resp.policy;
}
} catch (error) {
this.toast.showError(error);
} finally {
this.loading = false;
}
}
private async getData(): Promise<GetCustomOrgIAMPolicyResponse.AsObject | GetOrgIAMPolicyResponse.AsObject | any> {
@@ -80,12 +73,13 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
}
}
public savePolicy(): void {
public async savePolicy(): Promise<void> {
const org = await this.authService.getActiveOrg();
switch (this.serviceType) {
case PolicyComponentServiceType.MGMT:
if ((this.domainData as OrgIAMPolicy.AsObject).isDefault) {
const req = new AddCustomDomainPolicyRequest();
req.setOrgId(this.orgId());
req.setOrgId(org.id);
req.setUserLoginMustBeDomain(this.domainData.userLoginMustBeDomain);
req.setValidateOrgDomains(this.domainData.validateOrgDomains);
req.setSmtpSenderAddressMatchesInstanceDomain(this.domainData.smtpSenderAddressMatchesInstanceDomain);
@@ -101,7 +95,7 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
break;
} else {
const req = new AddCustomDomainPolicyRequest();
req.setOrgId(this.orgId());
req.setOrgId(org.id);
req.setUserLoginMustBeDomain(this.domainData.userLoginMustBeDomain);
req.setValidateOrgDomains(this.domainData.validateOrgDomains);
req.setSmtpSenderAddressMatchesInstanceDomain(this.domainData.smtpSenderAddressMatchesInstanceDomain);
@@ -146,21 +140,24 @@ export class DomainPolicyComponent implements OnInit, OnDestroy {
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.adminService
.resetCustomDomainPolicyToDefault(this.orgId())
.then(() => {
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
setTimeout(() => {
this.fetchData();
}, 1000);
})
.catch((error) => {
this.toast.showError(error);
});
}
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(async (resp) => {
if (!resp) {
return;
}
try {
const org = await this.authService.getActiveOrg();
await this.adminService.resetCustomDomainPolicyToDefault(org.id);
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
await new Promise((res) => setTimeout(res, 1000));
await this.fetchData();
} catch (error) {
this.toast.showError(error);
}
});
}
}
@@ -0,0 +1,60 @@
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];
@@ -68,6 +68,12 @@
<ng-container *ngIf="setting()?.id === 'failedevents' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-iam-failed-events></cnsl-iam-failed-events>
</ng-container>
<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>
@@ -213,3 +213,21 @@ 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'],
},
};
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'],
},
};
@@ -1,72 +1,13 @@
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { Component, effect, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { Component, OnDestroy } from '@angular/core';
import { merge, Subject, takeUntil } from 'rxjs';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
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 { 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];
import { SETTINGLINKS } from '../settings-grid/settinglinks';
export interface ShortcutItem {
id: string;
@@ -140,7 +81,7 @@ const CREATE_USER: ShortcutItem = {
standalone: false,
})
export class ShortcutsComponent implements OnDestroy {
public orgId!: string;
public org!: Org.AsObject;
public main: ShortcutItem[] = [];
public secondary: ShortcutItem[] = [];
@@ -152,19 +93,26 @@ 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,
) {
effect(() => {
const orgId = this.newOrganizationService.orgId();
if (orgId) {
this.orgId = orgId;
this.loadProjectShortcuts();
}
});
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();
}
});
}
public loadProjectShortcuts(): void {
@@ -204,14 +152,14 @@ export class ShortcutsComponent implements OnDestroy {
});
this.ALL_SHORTCUTS = [...routesShortcuts, ...settingsShortcuts, ...mapped];
this.loadShortcuts(this.orgId);
this.loadShortcuts(this.org);
}
});
}
public loadShortcuts(orgId: string): void {
public loadShortcuts(org: Org.AsObject): void {
['main', 'secondary', 'third'].map((listName) => {
const joinedShortcuts = this.storageService.getItem(`shortcuts:${listName}:${orgId}`, StorageLocation.local);
const joinedShortcuts = this.storageService.getItem(`shortcuts:${listName}:${org.id}`, StorageLocation.local);
if (joinedShortcuts) {
const parsedIds: string[] = joinedShortcuts.split(',');
if (parsedIds && parsedIds.length) {
@@ -297,26 +245,26 @@ export class ShortcutsComponent implements OnDestroy {
}
public saveStateToStorage(): void {
const orgId = this.newOrganizationService.orgId();
if (orgId) {
this.storageService.setItem(`shortcuts:main:${orgId}`, this.main.map((p) => p.id).join(','), StorageLocation.local);
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);
this.storageService.setItem(
`shortcuts:secondary:${orgId}`,
`shortcuts:secondary:${org.id}`,
this.secondary.map((p) => p.id).join(','),
StorageLocation.local,
);
this.storageService.setItem(`shortcuts:third:${orgId}`, this.third.map((p) => p.id).join(','), StorageLocation.local);
this.storageService.setItem(`shortcuts:third:${org.id}`, this.third.map((p) => p.id).join(','), StorageLocation.local);
}
}
public reset(): void {
const orgId = this.newOrganizationService.orgId();
if (orgId) {
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
if (org && org.id) {
['main', 'secondary', 'third'].map((listName) => {
this.storageService.removeItem(`shortcuts:${listName}:${orgId}`, StorageLocation.local);
this.storageService.removeItem(`shortcuts:${listName}:${org.id}`, StorageLocation.local);
});
this.loadShortcuts(orgId);
this.loadShortcuts(org);
}
}
@@ -313,7 +313,7 @@ export class UserGrantsComponent implements OnInit, AfterViewInit {
this.newOrganizationService.organizationByIdQueryOptions(grant.grantedOrgId),
);
if (org) {
this.newOrganizationService.setOrgId(grant.grantedOrgId);
await this.authService.getActiveOrg(grant.grantedOrgId);
await this.router.navigate(['/users', grant.userId]);
} else {
this.toast.showInfo('GRANTS.TOAST.CANTSHOWINFO', true);
@@ -5,9 +5,7 @@ import { OrgTableModule } from 'src/app/modules/org-table/org-table.module';
import { ActionsRoutingModule } from './actions-routing.module';
import { ActionsComponent } from './actions.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';
+3 -16
View File
@@ -1,4 +1,4 @@
import { Component, effect } from '@angular/core';
import { Component } 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';
@@ -27,8 +27,6 @@ export class HomeComponent {
protected readonly PolicyComponentServiceType = PolicyComponentServiceType;
private readonly permissions = this.newAuthService.listMyZitadelPermissionsQuery();
constructor(
public authService: GrpcAuthService,
private readonly newAuthService: NewAuthService,
@@ -37,24 +35,13 @@ export class HomeComponent {
private readonly router: Router,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.INSTANCE,
routerLink: ['/'],
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
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();
});
}
}
@@ -1,8 +1,8 @@
import { Component, DestroyRef } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { BehaviorSubject, defer, from, Observable, of, shareReplay, TimeoutError } from 'rxjs';
import { catchError, finalize, map, timeout } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, from, 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 { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
import { InstanceDetail, State } from 'src/app/proto/generated/zitadel/instance_pb';
@@ -34,10 +34,11 @@ import {
FAILEDEVENTS,
EVENTS,
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';
import { EnvironmentService } from 'src/app/services/environment.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-instance',
@@ -77,6 +78,9 @@ export class InstanceComponent {
VIEWS,
EVENTS,
FAILEDEVENTS,
//actions
ACTIONS,
ACTIONS_TARGETS,
// others
PRIVACYPOLICY,
LANGUAGES,
@@ -3,18 +3,26 @@
*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"
[isActive]="$any(org.state) === OrganizationState.ACTIVE"
[isInactive]="$any(org.state) === OrganizationState.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 === OrganizationState.ACTIVE" (click)="changeState(OrganizationState.INACTIVE)">
<button
mat-menu-item
*ngIf="$any(org.state) === OrganizationState.ACTIVE"
(click)="changeState(OrganizationState.INACTIVE)"
>
{{ 'ORG.PAGES.DEACTIVATE' | translate }}
</button>
<button mat-menu-item *ngIf="org.state === OrganizationState.INACTIVE" (click)="changeState(OrganizationState.ACTIVE)">
<button
mat-menu-item
*ngIf="$any(org.state) === OrganizationState.INACTIVE"
(click)="changeState(OrganizationState.ACTIVE)"
>
{{ 'ORG.PAGES.REACTIVATE' | translate }}
</button>
@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, effect, OnInit, signal } from '@angular/core';
import { ChangeDetectorRef, Component, OnInit, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { BehaviorSubject, from, lastValueFrom, Observable, of } from 'rxjs';
@@ -19,7 +19,9 @@ 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';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GrpcAuthService } from '../../../services/grpc-auth.service';
import { Org } from '@zitadel/proto/zitadel/org_pb';
@Component({
selector: 'cnsl-org-detail',
@@ -53,6 +55,7 @@ export class OrgDetailComponent implements OnInit {
protected reloadChanges = signal(true);
constructor(
private readonly authService: GrpcAuthService,
private readonly dialog: MatDialog,
private readonly mgmtService: ManagementService,
private readonly toast: ToastService,
@@ -67,17 +70,14 @@ export class OrgDetailComponent implements OnInit {
};
breadcrumbService.setBreadcrumb([bread]);
effect(() => {
const orgId = this.newOrganizationService.orgId();
if (!orgId) {
authService.activeOrgChanged.pipe(takeUntilDestroyed()).subscribe((org) => {
if (!org) {
return;
}
this.loadMembers();
this.loadMetadata();
});
// force rerender changes because it is not reactive to orgId changes
toObservable(this.newOrganizationService.orgId).subscribe(() => {
// force rerender changes because it is not reactive to orgId changes
this.reloadChanges.set(false);
cdr.detectChanges();
this.reloadChanges.set(true);
@@ -137,7 +137,7 @@ export class OrgDetailComponent implements OnInit {
}
}
public async deleteOrg(org: Organization) {
public async deleteOrg(org: Organization | Org) {
const mgmtUserData = {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
@@ -265,7 +265,7 @@ export class OrgDetailComponent implements OnInit {
});
}
public async renameOrg(org: Organization): Promise<void> {
public async renameOrg(org: Organization | Org): Promise<void> {
const dialogRef = this.dialog.open(NameDialogComponent, {
data: {
name: org.name,
@@ -286,7 +286,7 @@ export class OrgDetailComponent implements OnInit {
this.toast.showInfo('ORG.TOAST.UPDATED', true);
const resp = await this.mgmtService.getMyOrg();
if (resp.org) {
await this.newOrganizationService.setOrgId(resp.org.id);
await this.authService.getActiveOrg(resp.org.id);
}
} catch (error) {
this.toast.showError(error);
@@ -7,7 +7,6 @@ import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, Observable, Subject, takeUntil } from 'rxjs';
import { ProjectType } from 'src/app/modules/project-members/project-members-datasource';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { GrantedProject, Project, ProjectState } from 'src/app/proto/generated/zitadel/project_pb';
import { ManagementService } from 'src/app/services/mgmt.service';
import { StorageKey, StorageLocation, StorageService } from 'src/app/services/storage.service';
@@ -56,7 +56,6 @@ 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,
@@ -70,7 +69,6 @@ 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();
@@ -186,11 +184,11 @@ export class UserCreateV2Component implements OnInit {
private async createUserV2Try(authenticationFactor: AuthenticationFactor) {
this.loading.set(true);
this.organizationService.getOrgId();
const activeOrg = await this.authService.getActiveOrg();
const userValues = this.userForm.getRawValue();
const humanReq: MessageInitShape<typeof AddHumanUserRequestSchema> = {
organization: { org: { case: 'orgId', value: this.orgId() } },
organization: { org: { case: 'orgId', value: activeOrg.id } },
username: userValues.username,
profile: {
givenName: userValues.givenName,
@@ -22,6 +22,7 @@ import {
delay,
distinctUntilChanged,
EMPTY,
from,
Observable,
of,
ReplaySubject,
@@ -35,7 +36,7 @@ import { PaginatorComponent } from 'src/app/modules/paginator/paginator.componen
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { ToastService } from 'src/app/services/toast.service';
import { UserService } from 'src/app/services/user.service';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { SearchQuery as UserSearchQuery } from 'src/app/proto/generated/zitadel/user_pb';
import { Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb';
import { UserState, User } from '@zitadel/proto/zitadel/user/v2/user_pb';
@@ -43,6 +44,8 @@ import { MessageInitShape } from '@bufbuild/protobuf';
import { ListUsersRequestSchema, ListUsersResponse } from '@zitadel/proto/zitadel/user/v2/user_service_pb';
import { UserState as UserStateV1 } from 'src/app/proto/generated/zitadel/user_pb';
import { NewOrganizationService } from 'src/app/services/new-organization.service';
import { AuthenticationService } from 'src/app/services/authentication.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
type ListUsersRequest = MessageInitShape<typeof ListUsersRequestSchema>;
type QueriesArray = NonNullable<ListUsersRequest['queries']>;
@@ -124,6 +127,8 @@ export class UserTableComponent implements OnInit {
private readonly route: ActivatedRoute,
private readonly destroyRef: DestroyRef,
private readonly newOrganizationService: NewOrganizationService,
private readonly authenticationService: AuthenticationService,
private readonly authService: GrpcAuthService,
) {
this.type$ = this.getType$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.users$ = this.getUsers(this.type$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
@@ -229,7 +234,7 @@ export class UserTableComponent implements OnInit {
}
private getQueries(type$: Observable<Type>): Observable<Query[]> {
const orgId$ = toObservable(this.newOrganizationService.orgId).pipe(filter(Boolean));
const orgId$ = this.getActiveOrgId().pipe(filter(Boolean));
return this.searchQueries$.pipe(
startWith([]),
combineLatestWith(type$, orgId$),
@@ -474,4 +479,20 @@ export class UserTableComponent implements OnInit {
const selected = this.selection.selected;
return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false;
}
private getActiveOrgId() {
return this.authenticationService.authenticationChanged.pipe(
startWith(true),
filter(Boolean),
switchMap(() =>
from(this.authService.getActiveOrg()).pipe(
catchError((err) => {
this.toast.showError(err);
return of(undefined);
}),
),
),
map((org) => org?.id),
);
}
}
+112 -13
View File
@@ -1,8 +1,19 @@
import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { OAuthService } from 'angular-oauth2-oidc';
import { BehaviorSubject, combineLatestWith, EMPTY, identity, mergeWith, NEVER, Observable, of, shareReplay } from 'rxjs';
import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators';
import {
BehaviorSubject,
combineLatestWith,
EMPTY,
identity,
mergeWith,
NEVER,
Observable,
of,
shareReplay,
Subject,
} from 'rxjs';
import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap } from 'rxjs/operators';
import {
AddMyAuthFactorOTPEmailRequest,
@@ -85,18 +96,21 @@ import {
import { ChangeQuery } from '../proto/generated/zitadel/change_pb';
import { MetadataQuery } from '../proto/generated/zitadel/metadata_pb';
import { ListQuery } from '../proto/generated/zitadel/object_pb';
import { OrgFieldName, OrgQuery } from '../proto/generated/zitadel/org_pb';
import { Org, OrgFieldName, OrgIDQuery, OrgQuery } from '../proto/generated/zitadel/org_pb';
import { LabelPolicy, PrivacyPolicy } from '../proto/generated/zitadel/policy_pb';
import { Gender, MembershipQuery, User, WebAuthNVerification } from '../proto/generated/zitadel/user_pb';
import { GrpcService } from './grpc.service';
import { NewOrganizationService } from './new-organization.service';
import { toObservable } from '@angular/core/rxjs-interop';
import { StorageKey, StorageLocation, StorageService } from './storage.service';
const ORG_LIMIT = 10;
@Injectable({
providedIn: 'root',
})
export class GrpcAuthService {
private _activeOrgChanged: Subject<Org.AsObject | undefined> = new Subject();
public user: Observable<User.AsObject | undefined>;
private triggerPermissionsRefresh: Subject<void> = new Subject();
public zitadelPermissions: Observable<string[]>;
public labelpolicy$!: Observable<LabelPolicy.AsObject>;
@@ -107,27 +121,26 @@ export class GrpcAuthService {
PrivacyPolicy.AsObject | undefined
>(undefined);
public cachedOrgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {};
private cachedPrivacyPolicies: { [orgId: string]: PrivacyPolicy.AsObject } = {};
constructor(
private readonly grpcService: GrpcService,
private oauthService: OAuthService,
newOrganizationService: NewOrganizationService,
private storage: StorageService,
) {
const activeOrg = toObservable(newOrganizationService.orgId);
this.labelpolicy$ = activeOrg.pipe(
this.labelpolicy$ = this.activeOrgChanged.pipe(
tap(() => this.labelPolicyLoading$.next(true)),
switchMap((org) => this.getMyLabelPolicy(org ?? '')),
switchMap((org) => this.getMyLabelPolicy(org ? org.id : '')),
tap(() => this.labelPolicyLoading$.next(false)),
finalize(() => this.labelPolicyLoading$.next(false)),
filter((policy) => !!policy),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.privacypolicy$ = activeOrg.pipe(
switchMap((org) => this.getMyPrivacyPolicy(org ?? '')),
this.privacypolicy$ = this.activeOrgChanged.pipe(
switchMap((org) => this.getMyPrivacyPolicy(org ? org.id : '')),
filter((policy) => !!policy),
catchError((err) => {
console.error(err);
@@ -148,7 +161,7 @@ export class GrpcAuthService {
);
this.zitadelPermissions = this.user.pipe(
combineLatestWith(activeOrg),
combineLatestWith(this.activeOrgChanged),
// ignore errors from observables
catchError(() => of(true)),
// make sure observable never completes
@@ -184,6 +197,87 @@ export class GrpcAuthService {
return this.grpcService.auth.listMyMetadata(req, null).then((resp) => resp.toObject());
}
public async getActiveOrg(id?: string): Promise<Org.AsObject> {
if (id) {
const find = this.cachedOrgs.getValue().find((tmp) => tmp.id === id);
if (find) {
this.setActiveOrg(find);
return Promise.resolve(find);
} else {
const orgQuery = new OrgQuery();
const orgIdQuery = new OrgIDQuery();
orgIdQuery.setId(id);
orgQuery.setIdQuery(orgIdQuery);
const orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0, [orgQuery])).resultList;
if (orgs.length === 1) {
this.setActiveOrg(orgs[0]);
return Promise.resolve(orgs[0]);
} else {
// throw error if the org was specifically requested but not found
return Promise.reject(new Error('requested organization not found'));
}
}
} else {
let orgs: Org.AsObject[];
const org = this.storage.getItem<Org.AsObject>(StorageKey.organization, StorageLocation.local);
if (org) {
orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList;
this.cachedOrgs.next(orgs);
const find = this.cachedOrgs.getValue().find((tmp) => tmp.id === id);
if (find) {
this.setActiveOrg(find);
return Promise.resolve(find);
} else {
const orgQuery = new OrgQuery();
const orgIdQuery = new OrgIDQuery();
orgIdQuery.setId(org.id);
orgQuery.setIdQuery(orgIdQuery);
const specificOrg = (await this.listMyProjectOrgs(ORG_LIMIT, 0, [orgQuery])).resultList;
if (specificOrg.length === 1) {
this.setActiveOrg(specificOrg[0]);
return Promise.resolve(specificOrg[0]);
}
}
} else {
orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList;
this.cachedOrgs.next(orgs);
}
if (orgs.length === 0) {
this._activeOrgChanged.next(undefined);
return Promise.reject(new Error('No organizations found!'));
}
const orgToSet = orgs.find((element) => element.id !== '0' && element.name !== '');
if (orgToSet) {
this.setActiveOrg(orgToSet);
return Promise.resolve(orgToSet);
}
return Promise.resolve(orgs[0]);
}
}
public get activeOrgChanged(): Observable<Org.AsObject | undefined> {
return this._activeOrgChanged.asObservable();
}
public setActiveOrg(org: Org.AsObject): void {
// Set organization in localstorage to get the last used organization in a new tab
this.storage.setItem(StorageKey.organization, org, StorageLocation.local);
this.storage.setItem(StorageKey.organizationId, org.id, StorageLocation.local);
this.storage.setItem(StorageKey.organization, org, StorageLocation.session);
this.storage.setItem(StorageKey.organizationId, org.id, StorageLocation.session);
this._activeOrgChanged.next(org);
}
private loadPermissions(): void {
this.triggerPermissionsRefresh.next();
}
/**
* returns true if user has one of the provided roles
* @param roles roles of the user
@@ -253,6 +347,11 @@ export class GrpcAuthService {
return this.grpcService.auth.getMyUser(new GetMyUserRequest(), null).then((resp) => resp.toObject());
}
public async revalidateOrgs() {
const orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList;
this.cachedOrgs.next(orgs);
}
public listMyProjectOrgs(
limit?: number,
offset?: number,
@@ -1,17 +1,23 @@
import { computed, Injectable, signal } from '@angular/core';
import { computed, Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import { injectQuery, mutationOptions, QueryClient, queryOptions, skipToken } from '@tanstack/angular-query-experimental';
import { MessageInitShape } from '@bufbuild/protobuf';
import { ListOrganizationsRequestSchema, ListOrganizationsResponse } from '@zitadel/proto/zitadel/org/v2/org_service_pb';
import { create, DescMessage, MessageInitShape, toBinary } from '@bufbuild/protobuf';
import {
ListOrganizationsRequestSchema,
ListOrganizationsResponse,
OrganizationService,
} from '@zitadel/proto/zitadel/org/v2/org_service_pb';
import { NewMgmtService } from './new-mgmt.service';
import { OrgInterceptorProvider } from './interceptors/org.interceptor';
import { NewAdminService } from './new-admin.service';
import { SetUpOrgRequestSchema } from '@zitadel/proto/zitadel/admin_pb';
import { TranslateService } from '@ngx-translate/core';
import { lastValueFrom } from 'rxjs';
import { first } from 'rxjs/operators';
import { StorageKey, StorageLocation, StorageService } from './storage.service';
import { UserService } from './user.service';
import { GrpcAuthService } from './grpc-auth.service';
import { concatWith, defer, map } from 'rxjs';
import { filter } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
import { Buffer } from 'buffer';
import { AuthService, ListMyProjectOrgsRequestSchema } from '@zitadel/proto/zitadel/auth_pb';
@Injectable({
providedIn: 'root',
@@ -19,44 +25,14 @@ import { UserService } from './user.service';
export class NewOrganizationService {
constructor(
private readonly grpcService: GrpcService,
private readonly authService: GrpcAuthService,
private readonly newMgtmService: NewMgmtService,
private readonly newAdminService: NewAdminService,
private readonly orgInterceptorProvider: OrgInterceptorProvider,
private readonly queryClient: QueryClient,
private readonly translate: TranslateService,
private readonly storage: StorageService,
private readonly userService: UserService,
) {}
private readonly orgIdSignal = signal<string | undefined>(
this.storage.getItem(StorageKey.organizationId, StorageLocation.session) ??
this.storage.getItem(StorageKey.organizationId, StorageLocation.local) ??
undefined,
);
public readonly orgId = this.orgIdSignal.asReadonly();
public getOrgId() {
return computed(() => {
const orgId = this.orgIdSignal();
if (orgId === undefined) {
throw new Error('No organization ID set');
}
return orgId;
});
}
public async setOrgId(orgId?: string) {
const organization = await this.queryClient.fetchQuery(this.organizationByIdQueryOptions(orgId ?? this.getOrgId()()));
if (organization) {
this.storage.setItem(StorageKey.organizationId, orgId, StorageLocation.session);
this.storage.setItem(StorageKey.organizationId, orgId, StorageLocation.local);
this.orgIdSignal.set(orgId);
} else {
throw new Error('request organization not found');
}
return organization;
}
public organizationByIdQueryOptions(organizationId?: string) {
const req = {
query: {
@@ -74,62 +50,82 @@ export class NewOrganizationService {
],
};
const { queryFn, ...listOrganizationsQueryOptions } = this.listOrganizationsQueryOptions(req);
return queryOptions({
queryKey: [this.userService.userId(), 'organization', 'listOrganizations', req],
queryFn: organizationId
? () => this.listOrganizations(req).then((resp) => resp.result.find(Boolean) ?? null)
: skipToken,
...listOrganizationsQueryOptions,
queryFn: organizationId ? queryFn : skipToken,
select: (data) => data.result.find(Boolean) ?? null,
});
}
public activeOrganizationQuery() {
return injectQuery(() => this.organizationByIdQueryOptions(this.orgId()));
}
const activeOrg$ = defer(() => this.authService.getActiveOrg()).pipe(
concatWith(this.authService.activeOrgChanged),
filter(Boolean),
map((org) => org.id),
);
public listOrganizationsQueryOptions(req?: MessageInitShape<typeof ListOrganizationsRequestSchema>) {
return queryOptions({
queryKey: this.listOrganizationsQueryKey(req),
queryFn: () => this.listOrganizations(req ?? {}),
const activeOrg = toSignal(activeOrg$);
const req = computed(
() =>
({
query: {
limit: 1,
},
queries: [
{
query: {
case: 'idQuery' as const,
value: {
id: activeOrg(),
},
},
},
],
}) satisfies MessageInitShape<typeof ListMyProjectOrgsRequestSchema>,
);
return injectQuery(() => {
const { queryFn, ...listMyProjectOrgsQueryOptions } = this.listMyProjectOrgsQueryOptions(req());
return queryOptions({
...listMyProjectOrgsQueryOptions,
queryFn: activeOrg() ? queryFn : skipToken,
select: (data) => data.result.find(Boolean) ?? null,
});
});
}
public listOrganizationsQueryKey(req?: MessageInitShape<typeof ListOrganizationsRequestSchema>) {
if (!req) {
return [this.userService.userId(), 'organization', 'listOrganizations'];
}
// needed because angular query isn't able to serialize a bigint key
const query = req.query ? { ...req.query, offset: req.query.offset ? Number(req.query.offset) : undefined } : undefined;
const queryKey = {
...req,
...(query ? { query } : {}),
};
return [this.userService.userId(), 'organization', 'listOrganizations', queryKey];
public listOrganizationsQueryOptions(req?: MessageInitShape<typeof ListOrganizationsRequestSchema>) {
const queryKeyHashFn = tanstackQueryKeyHashFn(ListOrganizationsRequestSchema);
return queryOptions({
queryKey: [
this.userService.userId(),
OrganizationService.name,
OrganizationService.method.listOrganizations.name,
req,
] as const,
queryKeyHashFn: (key) => queryKeyHashFn(...key),
queryFn: ({ signal }) => this.listOrganizations(req ?? {}, signal),
});
}
public listOrganizations(
private listOrganizations(
req: MessageInitShape<typeof ListOrganizationsRequestSchema>,
signal?: AbortSignal,
): Promise<ListOrganizationsResponse> {
return this.grpcService.organizationNew.listOrganizations(req, { signal });
}
private async getDefaultOrganization() {
let resp = await this.listOrganizations({
query: {
limit: 1,
},
queries: [
{
query: {
case: 'defaultQuery',
value: {},
},
},
],
private listMyProjectOrgsQueryOptions(req?: MessageInitShape<typeof ListMyProjectOrgsRequestSchema>) {
const queryKeyHashFn = tanstackQueryKeyHashFn(ListMyProjectOrgsRequestSchema);
return queryOptions({
queryKey: [this.userService.userId(), AuthService.name, AuthService.method.listMyProjectOrgs.name, req] as const,
queryKeyHashFn: (key) => queryKeyHashFn(...key),
queryFn: ({ signal }) => this.grpcService.authNew.listMyProjectOrgs(req ?? {}, { signal }),
});
return resp.result.find(Boolean) ?? null;
}
private invalidateAllOrganizationQueries() {
@@ -149,20 +145,11 @@ export class NewOrganizationService {
mutationOptions({
mutationKey: ['deleteOrg'],
mutationFn: async () => {
// Before we remove the org we get the current default org
// we have to query before the current org is removed
const defaultOrg = await this.getDefaultOrganization();
if (!defaultOrg) {
const error$ = this.translate.get('ORG.TOAST.DEFAULTORGOTFOUND').pipe(first());
throw { message: await lastValueFrom(error$) };
}
const resp = await this.newMgtmService.removeOrg();
await new Promise((resolve) => setTimeout(resolve, 1000));
// We change active org to default org as
// current org was deleted to avoid Organization doesn't exist
await this.setOrgId(defaultOrg.id);
await this.authService.getActiveOrg();
return resp;
},
@@ -210,3 +197,16 @@ export class NewOrganizationService {
},
});
}
function tanstackQueryKeyHashFn<T extends DescMessage>(schema: T) {
return (userId: string | undefined, serviceName: string, methodName: string, req?: MessageInitShape<T>) => {
if (!req) {
return JSON.stringify([userId, serviceName, methodName]);
}
const serializedReq = toBinary(schema, create(schema, req));
const serializedReqAsString = Buffer.from(serializedReq).toString('base64');
return JSON.stringify([userId, serviceName, methodName, serializedReqAsString]);
};
}
@@ -43,6 +43,7 @@ export class StorageConfig {
}
export enum StorageKey {
organization = 'organization',
organizationId = 'organizationId',
}
-10
View File
@@ -78,11 +78,6 @@
@import './styles/codemirror.scss';
@import 'src/app/components/copy-row/copy-row.component.scss';
@import 'src/app/modules/providers/provider-next/provider-next.component.scss';
@import 'src/app/modules/new-header/organization-selector/organization-selector.component.scss';
@import 'src/app/modules/new-header/instance-selector/instance-selector.component.scss';
@import 'src/app/modules/new-header/header-dropdown/header-dropdown.component.scss';
@import 'src/app/modules/new-header/new-header.component.scss';
@import 'src/app/modules/new-header/header-button/header-button.component.scss';
@mixin component-themes($theme) {
@include cnsl-color-theme($theme);
@@ -164,9 +159,4 @@
@include copy-row-theme($theme);
@include provider-next-theme($theme);
@include smtp-settings-theme($theme);
@include organization-selector-theme($theme);
@include instance-selector-theme($theme);
@include header-dropdown-theme($theme);
@include new-header-theme($theme);
@include header-button-theme($theme);
}
+8 -8
View File
@@ -425,8 +425,8 @@ importers:
specifier: ^8.57.1
version: 8.57.1
jasmine-core:
specifier: ~5.6.0
version: 5.6.0
specifier: ~5.12.0
version: 5.12.1
jasmine-spec-reporter:
specifier: ~7.0.0
version: 7.0.0
@@ -447,7 +447,7 @@ importers:
version: 5.1.0(karma@6.4.4)
karma-jasmine-html-reporter:
specifier: ^2.1.0
version: 2.1.0(jasmine-core@5.6.0)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4)
version: 2.1.0(jasmine-core@5.12.1)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4)
prettier:
specifier: ^3.7.4
version: 3.7.4
@@ -9519,8 +9519,8 @@ packages:
jasmine-core@4.6.1:
resolution: {integrity: sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==}
jasmine-core@5.6.0:
resolution: {integrity: sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==}
jasmine-core@5.12.1:
resolution: {integrity: sha512-P/UbRZ0LKwXe7wEpwDheuhunPwITn4oPALhrJEQJo6756EwNGnsK/TSQrWojBB4cQDQ+VaxWYws9tFNDuiMh2Q==}
jasmine-spec-reporter@7.0.0:
resolution: {integrity: sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==}
@@ -24969,7 +24969,7 @@ snapshots:
jasmine-core@4.6.1: {}
jasmine-core@5.6.0: {}
jasmine-core@5.12.1: {}
jasmine-spec-reporter@7.0.0:
dependencies:
@@ -25201,9 +25201,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
karma-jasmine-html-reporter@2.1.0(jasmine-core@5.6.0)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4):
karma-jasmine-html-reporter@2.1.0(jasmine-core@5.12.1)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4):
dependencies:
jasmine-core: 5.6.0
jasmine-core: 5.12.1
karma: 6.4.4
karma-jasmine: 5.1.0(karma@6.4.4)
@@ -12,7 +12,7 @@ describe('instance secret generators', () => {
cy.contains('Email verification');
cy.contains('Phone verification');
cy.contains('Password Reset');
cy.contains('Passwordless Initialization');
cy.contains('Passkey Initialization');
cy.contains('App Secret');
cy.contains('One Time Password (OTP) - SMS');
cy.contains('One Time Password (OTP) - Email');