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
+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',
}