mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-09 07:35:13 +00:00
feat(console): rehauled navigation for instances including breadcrumb (#10283)
This PR significantly improves user navigation by introducing a new instance-level navigation bar. This new bar, positioned above the existing organization navigation, provides quick access to key sections: Home, Organizations, Actions, and Settings. Additionally, the breadcrumb component has been refined for more consistent behavior, reintroducing intuitive breadcrumb buttons to easily navigate up the hierarchy. These changes also include design improvements for a cleaner and more streamlined appearance across the interface. <img width="423" height="138" alt="Screenshot 2025-07-17 at 14 55 46" src="https://github.com/user-attachments/assets/ba9e40a1-1077-4cb6-8735-ac7ab637abe7" /> <img width="562" height="132" alt="Screenshot 2025-07-17 at 14 56 41" src="https://github.com/user-attachments/assets/d85dc673-0df8-4677-9d2b-dc031dde42c3" /> <img width="545" height="254" alt="Screenshot 2025-07-17 at 14 56 10" src="https://github.com/user-attachments/assets/eaf10117-079e-4181-8dbb-60c89b24556a" /> <img width="689" height="261" alt="Screenshot 2025-07-17 at 14 56 20" src="https://github.com/user-attachments/assets/510ad550-1d9a-4c6a-8af1-66cb0b23619c" /> --------- Co-authored-by: conblem <[email protected]> Co-authored-by: Stefan Benz <[email protected]> Co-authored-by: Florian Forster <[email protected]>
This commit is contained in:
co-authored by
conblem
Stefan Benz
Florian Forster
parent
cccba3f8f3
commit
f963ea9f86
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export class StorageConfig {
|
||||
}
|
||||
|
||||
export enum StorageKey {
|
||||
organization = 'organization',
|
||||
organizationId = 'organizationId',
|
||||
}
|
||||
|
||||
export enum StorageLocation {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user