feat(console): rehauled navigation for instances including breadcrumb (#10283)

This PR significantly improves user navigation by introducing a new
instance-level navigation bar. This new bar, positioned above the
existing organization navigation, provides quick access to key sections:
Home, Organizations, Actions, and Settings.

Additionally, the breadcrumb component has been refined for more
consistent behavior, reintroducing intuitive breadcrumb buttons to
easily navigate up the hierarchy. These changes also include design
improvements for a cleaner and more streamlined appearance across the
interface.

<img width="423" height="138" alt="Screenshot 2025-07-17 at 14 55 46"
src="https://github.com/user-attachments/assets/ba9e40a1-1077-4cb6-8735-ac7ab637abe7"
/>
<img width="562" height="132" alt="Screenshot 2025-07-17 at 14 56 41"
src="https://github.com/user-attachments/assets/d85dc673-0df8-4677-9d2b-dc031dde42c3"
/>
<img width="545" height="254" alt="Screenshot 2025-07-17 at 14 56 10"
src="https://github.com/user-attachments/assets/eaf10117-079e-4181-8dbb-60c89b24556a"
/>
<img width="689" height="261" alt="Screenshot 2025-07-17 at 14 56 20"
src="https://github.com/user-attachments/assets/510ad550-1d9a-4c6a-8af1-66cb0b23619c"
/>

---------

Co-authored-by: conblem <[email protected]>
Co-authored-by: Stefan Benz <[email protected]>
Co-authored-by: Florian Forster <[email protected]>
This commit is contained in:
Max Peintner
2025-08-28 11:23:14 +02:00
committed by GitHub
co-authored by conblem Stefan Benz Florian Forster
parent cccba3f8f3
commit f963ea9f86
117 changed files with 3286 additions and 2360 deletions
-35
View File
@@ -171,8 +171,6 @@ import {
ListLoginPolicySecondFactorsResponse,
ListMilestonesRequest,
ListMilestonesResponse,
ListOrgsRequest,
ListOrgsResponse,
ListProvidersRequest,
ListProvidersResponse,
ListSecretGeneratorsRequest,
@@ -307,7 +305,6 @@ import {
UpdateSMTPConfigRequest,
UpdateSMTPConfigResponse,
} from '../proto/generated/zitadel/admin_pb';
import { Event } from '../proto/generated/zitadel/event_pb';
import {
ResetCustomDomainClaimedMessageTextToDefaultRequest,
ResetCustomDomainClaimedMessageTextToDefaultResponse,
@@ -340,9 +337,6 @@ import {
MilestoneQuery,
MilestoneType,
} from '../proto/generated/zitadel/milestone/v1/milestone_pb';
import { OrgFieldName, OrgQuery } from '../proto/generated/zitadel/org_pb';
import { SortDirection } from '@angular/material/sort';
import { SMTPConfig } from '../proto/generated/zitadel/settings_pb';
export interface OnboardingActions {
order: number;
@@ -1404,33 +1398,4 @@ export class AdminService {
public listMilestones(req: ListMilestonesRequest): Promise<ListMilestonesResponse.AsObject> {
return this.grpcService.admin.listMilestones(req, null).then((resp) => resp.toObject());
}
public listOrgs(
limit: number,
offset: number,
queriesList?: OrgQuery[],
sortingColumn?: OrgFieldName,
sortingDirection?: SortDirection,
): Promise<ListOrgsResponse.AsObject> {
const req = new ListOrgsRequest();
const query = new ListQuery();
if (limit) {
query.setLimit(limit);
}
if (offset) {
query.setOffset(offset);
}
if (sortingDirection) {
query.setAsc(sortingDirection === 'asc');
}
req.setQuery(query);
if (sortingColumn) {
req.setSortingColumn(sortingColumn);
}
if (queriesList) {
req.setQueriesList(queriesList);
}
return this.grpcService.admin.listOrgs(req, null).then((resp) => resp.toObject());
}
}
@@ -10,8 +10,8 @@ import { ToastService } from './toast.service';
})
export class AuthenticationService {
private authConfig!: AuthConfig;
private _authenticated: boolean = false;
private readonly _authenticationChanged: BehaviorSubject<boolean> = new BehaviorSubject(this.authenticated);
private _authenticated = false;
private readonly _authenticationChanged = new BehaviorSubject(this.authenticated);
constructor(
private oauthService: OAuthService,
@@ -45,9 +45,11 @@ export class AuthenticationService {
}
this.oauthService.configure(this.authConfig);
this.oauthService.strictDiscoveryDocumentValidation = false;
await this.oauthService.loadDiscoveryDocumentAndTryLogin().catch((error) => {
try {
await this.oauthService.loadDiscoveryDocumentAndTryLogin();
} catch (error) {
this.toast.showError(error, false, false);
});
}
this._authenticated = this.oauthService.hasValidAccessToken();
if (!this.oauthService.hasValidIdToken() || !this.authenticated || partialConfig || force) {
+12 -109
View File
@@ -1,18 +1,7 @@
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,
Subject,
} from 'rxjs';
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 {
@@ -96,21 +85,18 @@ 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 { Org, OrgFieldName, OrgIDQuery, OrgQuery } from '../proto/generated/zitadel/org_pb';
import { OrgFieldName, 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 { StorageKey, StorageLocation, StorageService } from './storage.service';
const ORG_LIMIT = 10;
import { NewOrganizationService } from './new-organization.service';
import { toObservable } from '@angular/core/rxjs-interop';
@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>;
@@ -121,26 +107,27 @@ 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,
private storage: StorageService,
newOrganizationService: NewOrganizationService,
) {
this.labelpolicy$ = this.activeOrgChanged.pipe(
const activeOrg = toObservable(newOrganizationService.orgId);
this.labelpolicy$ = activeOrg.pipe(
tap(() => this.labelPolicyLoading$.next(true)),
switchMap((org) => this.getMyLabelPolicy(org ? org.id : '')),
switchMap((org) => this.getMyLabelPolicy(org ?? '')),
tap(() => this.labelPolicyLoading$.next(false)),
finalize(() => this.labelPolicyLoading$.next(false)),
filter((policy) => !!policy),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.privacypolicy$ = this.activeOrgChanged.pipe(
switchMap((org) => this.getMyPrivacyPolicy(org ? org.id : '')),
this.privacypolicy$ = activeOrg.pipe(
switchMap((org) => this.getMyPrivacyPolicy(org ?? '')),
filter((policy) => !!policy),
catchError((err) => {
console.error(err);
@@ -161,7 +148,7 @@ export class GrpcAuthService {
);
this.zitadelPermissions = this.user.pipe(
combineLatestWith(this.activeOrgChanged),
combineLatestWith(activeOrg),
// ignore errors from observables
catchError(() => of(true)),
// make sure observable never completes
@@ -197,85 +184,6 @@ 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 = this.cachedOrgs.getValue();
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.organization, org, 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
@@ -345,11 +253,6 @@ 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,
+14 -3
View File
@@ -16,8 +16,13 @@ import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.intercep
import { I18nInterceptor } from './interceptors/i18n.interceptor';
import { NewConnectWebOrgInterceptor, OrgInterceptor, OrgInterceptorProvider } from './interceptors/org.interceptor';
import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb';
import { createFeatureServiceClient, createUserServiceClient, createSessionServiceClient } from '@zitadel/client/v2';
import { createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1';
import {
createFeatureServiceClient,
createUserServiceClient,
createSessionServiceClient,
createOrganizationServiceClient,
} from '@zitadel/client/v2';
import { createAdminServiceClient, createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { createClientFor } from '@zitadel/client';
@@ -42,6 +47,10 @@ export class GrpcService {
public featureNew!: ReturnType<typeof createFeatureServiceClient>;
public actionNew!: ReturnType<typeof createActionServiceClient>;
public webKey!: ReturnType<typeof createWebKeyServiceClient>;
public organizationNew!: ReturnType<typeof createOrganizationServiceClient>;
public adminNew!: ReturnType<typeof createAdminServiceClient>;
public assets!: void;
constructor(
private readonly envService: EnvironmentService,
@@ -93,10 +102,12 @@ export class GrpcService {
this.userNew = createUserServiceClient(transport);
this.session = createSessionServiceClient(transport);
this.mgmtNew = createManagementServiceClient(transportOldAPIs);
this.authNew = createAuthServiceClient(transport);
this.authNew = createAuthServiceClient(transportOldAPIs);
this.featureNew = createFeatureServiceClient(transport);
this.actionNew = createActionServiceClient(transport);
this.webKey = createWebKeyServiceClient(transport);
this.organizationNew = createOrganizationServiceClient(transport);
this.adminNew = createAdminServiceClient(transportOldAPIs);
const authConfig: AuthConfig = {
scope: 'openid profile email',
@@ -1,12 +1,8 @@
import { Injectable } from '@angular/core';
import { Request, RpcError, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { StorageKey, StorageLocation, StorageService } from '../storage.service';
import { ConnectError, Interceptor } from '@connectrpc/connect';
import { firstValueFrom, identity, Observable, Subject } from 'rxjs';
import { debounceTime, filter, map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const ORG_HEADER_KEY = 'x-zitadel-orgid';
@Injectable({ providedIn: 'root' })
@@ -43,8 +39,7 @@ export class OrgInterceptorProvider {
constructor(private storageService: StorageService) {}
getOrgId() {
const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session);
return org?.id;
return this.storageService.getItem(StorageKey.organizationId, StorageLocation.session);
}
handleError = (error: any): never => {
@@ -57,7 +52,7 @@ export class OrgInterceptorProvider {
error.code === StatusCode.PERMISSION_DENIED &&
error.message.startsWith("Organisation doesn't exist")
) {
this.storageService.removeItem(StorageKey.organization, StorageLocation.session);
this.storageService.removeItem(StorageKey.organizationId, StorageLocation.session);
}
throw error;
@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import { MessageInitShape } from '@bufbuild/protobuf';
import {
GetDefaultOrgResponse,
GetMyInstanceResponse,
SetUpOrgRequestSchema,
SetUpOrgResponse,
} from '@zitadel/proto/zitadel/admin_pb';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { NewAuthService } from './new-auth.service';
import { UserService } from './user.service';
@Injectable({
providedIn: 'root',
})
export class NewAdminService {
constructor(
private readonly grpcService: GrpcService,
private readonly authService: NewAuthService,
private readonly userService: UserService,
) {}
public setupOrg(req: MessageInitShape<typeof SetUpOrgRequestSchema>): Promise<SetUpOrgResponse> {
return this.grpcService.adminNew.setUpOrg(req);
}
public getDefaultOrg(): Promise<GetDefaultOrgResponse> {
return this.grpcService.adminNew.getDefaultOrg({});
}
private getMyInstance(signal?: AbortSignal): Promise<GetMyInstanceResponse> {
return this.grpcService.adminNew.getMyInstance({}, { signal });
}
public getMyInstanceQuery() {
const listMyZitadelPermissionsQuery = this.authService.listMyZitadelPermissionsQuery();
return injectQuery(() => ({
queryKey: [this.userService.userId(), 'admin', 'getMyInstance'],
queryFn: async () => this.getMyInstance(),
enabled: (listMyZitadelPermissionsQuery.data() ?? []).includes('iam.write'),
}));
}
}
+18 -6
View File
@@ -6,7 +6,6 @@ import {
GetMyLoginPolicyResponse,
GetMyLoginPolicyRequestSchema,
GetMyPasswordComplexityPolicyResponse,
GetMyUserResponse,
ListMyAuthFactorsRequestSchema,
ListMyAuthFactorsResponse,
RemoveMyAuthFactorOTPEmailRequestSchema,
@@ -19,17 +18,19 @@ import {
RemoveMyAuthFactorOTPSMSResponse,
ListMyMetadataResponse,
VerifyMyPhoneResponse,
ListMyZitadelPermissionsResponse,
} from '@zitadel/proto/zitadel/auth_pb';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { UserService } from './user.service';
@Injectable({
providedIn: 'root',
})
export class NewAuthService {
constructor(private readonly grpcService: GrpcService) {}
public getMyUser(): Promise<GetMyUserResponse> {
return this.grpcService.authNew.getMyUser({});
}
constructor(
private readonly grpcService: GrpcService,
private readonly userService: UserService,
) {}
public verifyMyPhone(code: string): Promise<VerifyMyPhoneResponse> {
return this.grpcService.authNew.verifyMyPhone({ code });
@@ -70,4 +71,15 @@ export class NewAuthService {
public getMyPasswordComplexityPolicy(): Promise<GetMyPasswordComplexityPolicyResponse> {
return this.grpcService.authNew.getMyPasswordComplexityPolicy({});
}
public listMyZitadelPermissions(): Promise<ListMyZitadelPermissionsResponse> {
return this.grpcService.authNew.listMyZitadelPermissions({});
}
public listMyZitadelPermissionsQuery() {
return injectQuery(() => ({
queryKey: [this.userService.userId(), 'auth', 'listMyZitadelPermissions'],
queryFn: () => this.listMyZitadelPermissions().then(({ result }) => result),
}));
}
}
@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddOrgResponse,
DeactivateOrgResponse,
GenerateMachineSecretRequestSchema,
GenerateMachineSecretResponse,
GetDefaultPasswordComplexityPolicyResponse,
@@ -9,8 +11,10 @@ import {
GetPasswordComplexityPolicyResponse,
ListUserMetadataRequestSchema,
ListUserMetadataResponse,
ReactivateOrgResponse,
RemoveMachineSecretRequestSchema,
RemoveMachineSecretResponse,
RemoveOrgResponse,
RemoveUserMetadataRequestSchema,
RemoveUserMetadataResponse,
ResendHumanEmailVerificationRequestSchema,
@@ -26,6 +30,8 @@ import {
SetUserMetadataResponse,
UpdateMachineRequestSchema,
UpdateMachineResponse,
UpdateOrgRequestSchema,
UpdateOrgResponse,
} from '@zitadel/proto/zitadel/management_pb';
import { MessageInitShape, create } from '@bufbuild/protobuf';
@@ -98,4 +104,24 @@ export class NewMgmtService {
public getDefaultPasswordComplexityPolicy(): Promise<GetDefaultPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({});
}
public updateOrg(req: MessageInitShape<typeof UpdateOrgRequestSchema>): Promise<UpdateOrgResponse> {
return this.grpcService.mgmtNew.updateOrg(req);
}
public removeOrg(): Promise<RemoveOrgResponse> {
return this.grpcService.mgmtNew.removeOrg({});
}
public reactivateOrg(): Promise<ReactivateOrgResponse> {
return this.grpcService.mgmtNew.reactivateOrg({});
}
public deactivateOrg(): Promise<DeactivateOrgResponse> {
return this.grpcService.mgmtNew.deactivateOrg({});
}
public addOrg(name: string): Promise<AddOrgResponse> {
return this.grpcService.mgmtNew.addOrg({ name });
}
}
@@ -0,0 +1,212 @@
import { computed, Injectable, signal } 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 { 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';
@Injectable({
providedIn: 'root',
})
export class NewOrganizationService {
constructor(
private readonly grpcService: GrpcService,
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: {
limit: 1,
},
queries: [
{
query: {
case: 'idQuery' as const,
value: {
id: organizationId?.toString(),
},
},
},
],
};
return queryOptions({
queryKey: [this.userService.userId(), 'organization', 'listOrganizations', req],
queryFn: organizationId
? () => this.listOrganizations(req).then((resp) => resp.result.find(Boolean) ?? null)
: skipToken,
});
}
public activeOrganizationQuery() {
return injectQuery(() => this.organizationByIdQueryOptions(this.orgId()));
}
public listOrganizationsQueryOptions(req?: MessageInitShape<typeof ListOrganizationsRequestSchema>) {
return queryOptions({
queryKey: this.listOrganizationsQueryKey(req),
queryFn: () => this.listOrganizations(req ?? {}),
});
}
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 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: {},
},
},
],
});
return resp.result.find(Boolean) ?? null;
}
private invalidateAllOrganizationQueries() {
return this.queryClient.invalidateQueries({
queryKey: this.listOrganizationsQueryOptions().queryKey,
});
}
public renameOrgMutationOptions = () =>
mutationOptions({
mutationKey: ['renameOrg'],
mutationFn: (name: string) => this.newMgtmService.updateOrg({ name }),
onSettled: () => this.invalidateAllOrganizationQueries(),
});
public deleteOrgMutationOptions = () =>
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);
return resp;
},
onSettled: async () => {
const orgId = this.orgInterceptorProvider.getOrgId();
if (orgId) {
this.queryClient.removeQueries({
queryKey: this.organizationByIdQueryOptions(orgId).queryKey,
});
}
await this.invalidateAllOrganizationQueries();
},
});
public reactivateOrgMutationOptions = () =>
mutationOptions({
mutationKey: ['reactivateOrg'],
mutationFn: () => this.newMgtmService.reactivateOrg(),
onSettled: () => this.invalidateAllOrganizationQueries(),
});
public deactivateOrgMutationOptions = () =>
mutationOptions({
mutationKey: ['deactivateOrg'],
mutationFn: () => this.newMgtmService.deactivateOrg(),
onSettled: () => this.invalidateAllOrganizationQueries(),
});
public setupOrgMutationOptions = () =>
mutationOptions({
mutationKey: ['setupOrg'],
mutationFn: (req: MessageInitShape<typeof SetUpOrgRequestSchema>) => this.newAdminService.setupOrg(req),
onSettled: async () => {
await this.invalidateAllOrganizationQueries();
},
});
public addOrgMutationOptions = () =>
mutationOptions({
mutationKey: ['addOrg'],
mutationFn: (name: string) => this.newMgtmService.addOrg(name),
onSettled: async () => {
await this.invalidateAllOrganizationQueries();
},
});
}
+1 -1
View File
@@ -43,7 +43,7 @@ export class StorageConfig {
}
export enum StorageKey {
organization = 'organization',
organizationId = 'organizationId',
}
export enum StorageLocation {
+66 -76
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { computed, Injectable, Signal } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddHumanUserRequestSchema,
@@ -18,9 +18,6 @@ import {
ListPasskeysResponse,
ListUsersRequestSchema,
ListUsersResponse,
LockUserRequestSchema,
LockUserResponse,
PasswordResetRequestSchema,
ReactivateUserRequestSchema,
ReactivateUserResponse,
RemoveOTPEmailRequestSchema,
@@ -49,84 +46,85 @@ import {
UpdateHumanUserResponse,
} from '@zitadel/proto/zitadel/user/v2/user_service_pb';
import type { MessageInitShape } from '@bufbuild/protobuf';
import {
AccessTokenType,
Gender,
HumanProfile,
HumanProfileSchema,
HumanUser,
HumanUserSchema,
MachineUser,
MachineUserSchema,
User as UserV2,
UserSchema,
UserState,
} from '@zitadel/proto/zitadel/user/v2/user_pb';
import { create } from '@bufbuild/protobuf';
import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt';
import { Details, DetailsSchema } from '@zitadel/proto/zitadel/object/v2/object_pb';
import { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb';
import { ObjectDetails } from '../proto/generated/zitadel/object_pb';
import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb';
import { HumanPhone, HumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_pb';
import { OAuthService } from 'angular-oauth2-oidc';
import { debounceTime, EMPTY, Observable, of, ReplaySubject, shareReplay, switchAll, switchMap } from 'rxjs';
import { catchError, filter, map, startWith } from 'rxjs/operators';
import { filter, map } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectQuery, queryOptions, skipToken } from '@tanstack/angular-query-experimental';
@Injectable({
providedIn: 'root',
})
export class UserService {
private user$$ = new ReplaySubject<Observable<UserV2>>(1);
public user$ = this.user$$.pipe(
startWith(this.getUser()),
// makes sure if many subscribers reset the observable only one wins
debounceTime(10),
switchAll(),
catchError((err) => {
// reset user observable on error
this.user$$.next(this.getUser());
throw err;
}),
);
private readonly payload: Signal<unknown | undefined>;
public readonly userId: Signal<string | undefined>;
public readonly isExpired: Signal<boolean>;
public userQuery() {
return injectQuery(() => this.userQueryOptions());
}
public userQueryOptions() {
const userId = this.userId();
return queryOptions({
queryKey: [userId, 'user'],
queryFn: userId ? () => this.getUserById(userId).then((resp) => resp.user) : skipToken,
});
}
constructor(
private readonly grpcService: GrpcService,
private readonly oauthService: OAuthService,
) {}
private getUserId() {
return this.oauthService.events.pipe(
filter((event) => event.type === 'token_received'),
map(() => this.oauthService.getIdToken()),
startWith(this.oauthService.getIdToken()),
filter(Boolean),
switchMap((token) => {
// we do this in a try catch so the observable will retry this logic if it fails
try {
// split jwt and get base64 encoded payload
const unparsedPayload = atob(token.split('.')[1]);
// parse payload
const payload: unknown = JSON.parse(unparsedPayload);
// check if sub is in payload and is a string
if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') {
return of(payload.sub);
}
return EMPTY;
} catch {
return EMPTY;
}
}),
);
) {
this.payload = this.getPayload();
this.userId = this.getUserId(this.payload);
this.isExpired = this.getIsExpired(this.payload);
}
private getUser() {
return this.getUserId().pipe(
switchMap((id) => this.getUserById(id)),
map((resp) => resp.user),
filter(Boolean),
shareReplay({ refCount: true, bufferSize: 1 }),
private getPayload() {
const idToken$ = this.oauthService.events.pipe(
filter((event) => event.type === 'token_received'),
// can actually return null
// https://github.com/manfredsteyer/angular-oauth2-oidc/blob/c724ad73eadbb28338b084e3afa5ed49a0ea058c/projects/lib/src/oauth-service.ts#L2365
map(() => this.oauthService.getIdToken() as string | null),
);
const idToken = toSignal(idToken$, { initialValue: this.oauthService.getIdToken() as string | null });
return computed(() => {
try {
// split jwt and get base64 encoded payload
const unparsedPayload = atob((idToken() ?? '').split('.')[1]);
// parse payload
return JSON.parse(unparsedPayload) as unknown;
} catch {
return undefined;
}
});
}
private getUserId(payloadSignal: Signal<unknown | undefined>) {
return computed(() => {
const payload = payloadSignal();
if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') {
return payload.sub;
}
return undefined;
});
}
private getIsExpired(payloadSignal: Signal<unknown | undefined>) {
const expSignal = computed(() => {
const payload = payloadSignal();
if (payload && typeof payload === 'object' && 'exp' in payload && typeof payload.exp === 'number') {
return new Date(payload.exp * 1000);
}
return undefined;
});
return computed(() => {
const exp = expSignal();
return exp ? exp <= new Date() : true;
});
}
public addHumanUser(req: MessageInitShape<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> {
@@ -157,10 +155,6 @@ export class UserService {
return this.grpcService.userNew.updateHumanUser(create(UpdateHumanUserRequestSchema, req));
}
public lockUser(userId: string): Promise<LockUserResponse> {
return this.grpcService.userNew.lockUser(create(LockUserRequestSchema, { userId }));
}
public unlockUser(userId: string): Promise<UnlockUserResponse> {
return this.grpcService.userNew.unlockUser(create(UnlockUserRequestSchema, { userId }));
}
@@ -221,10 +215,6 @@ export class UserService {
return this.grpcService.userNew.createInviteCode(create(CreateInviteCodeRequestSchema, req));
}
public passwordReset(req: MessageInitShape<typeof PasswordResetRequestSchema>) {
return this.grpcService.userNew.passwordReset(create(PasswordResetRequestSchema, req));
}
public setPassword(req: MessageInitShape<typeof SetPasswordRequestSchema>): Promise<SetPasswordResponse> {
return this.grpcService.userNew.setPassword(create(SetPasswordRequestSchema, req));
}