feat: create human user v2 page (#9506)

# Which Problems Are Solved
Allows users to be created using the V2 User API

# How the Problems Are Solved
I added a seperate V2 create user page with the new code using the new
apis.

# Additional Changes
I did some refactorings arround our interceptors as they used an
obselete syntax.
The password complexity form takes the Buf definitions.

# Additional Context
- Closes #9430

---------

Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Ramon
2025-03-19 13:27:59 +01:00
committed by GitHub
co-authored by Max Peintner
parent 11c9be3b8d
commit b418ea75bb
53 changed files with 863 additions and 375 deletions
+3 -1
View File
@@ -18,7 +18,7 @@ import { NewConnectWebOrgInterceptor, OrgInterceptor, OrgInterceptorProvider } f
import { StorageService } from './storage.service';
import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb';
//@ts-ignore
import { createUserServiceClient } from '@zitadel/client/v2';
import { createFeatureServiceClient, createUserServiceClient } from '@zitadel/client/v2';
//@ts-ignore
import { createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
@@ -36,6 +36,7 @@ export class GrpcService {
public userNew!: ReturnType<typeof createUserServiceClient>;
public mgmtNew!: ReturnType<typeof createManagementServiceClient>;
public authNew!: ReturnType<typeof createAuthServiceClient>;
public featureNew!: ReturnType<typeof createFeatureServiceClient>;
constructor(
private readonly envService: EnvironmentService,
@@ -114,6 +115,7 @@ export class GrpcService {
this.userNew = createUserServiceClient(transport);
this.mgmtNew = createManagementServiceClient(transportOldAPIs);
this.authNew = createAuthServiceClient(transport);
this.featureNew = createFeatureServiceClient(transport);
const authConfig: AuthConfig = {
scope: 'openid profile email',
@@ -17,15 +17,15 @@ const accessTokenStorageKey = 'access_token';
@Injectable({ providedIn: 'root' })
export class AuthInterceptorProvider {
public triggerDialog: Subject<boolean> = new Subject();
private readonly triggerDialog: Subject<boolean> = new Subject();
constructor(
private authenticationService: AuthenticationService,
private storageService: StorageService,
private dialog: MatDialog,
private destroyRef: DestroyRef,
private readonly authenticationService: AuthenticationService,
private readonly storageService: StorageService,
private readonly dialog: MatDialog,
destroyRef: DestroyRef,
) {
this.triggerDialog.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => this.openDialog());
this.triggerDialog.pipe(debounceTime(1000), takeUntilDestroyed(destroyRef)).subscribe(() => this.openDialog());
}
getToken(): Observable<string> {
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddMyAuthFactorOTPSMSResponse,
GetMyPasswordComplexityPolicyResponse,
GetMyUserResponse,
ListMyMetadataResponse,
VerifyMyPhoneResponse,
@@ -28,4 +29,8 @@ export class NewAuthService {
public listMyMetadata(): Promise<ListMyMetadataResponse> {
return this.grpcService.authNew.listMyMetadata({});
}
public getMyPasswordComplexityPolicy(): Promise<GetMyPasswordComplexityPolicyResponse> {
return this.grpcService.authNew.getMyPasswordComplexityPolicy({});
}
}
@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import { GetInstanceFeaturesResponse } from '@zitadel/proto/zitadel/feature/v2/instance_pb';
@Injectable({
providedIn: 'root',
})
export class NewFeatureService {
constructor(private readonly grpcService: GrpcService) {}
public getInstanceFeatures(): Promise<GetInstanceFeaturesResponse> {
return this.grpcService.featureNew.getInstanceFeatures({});
}
}
@@ -3,8 +3,10 @@ import { GrpcService } from './grpc.service';
import {
GenerateMachineSecretRequestSchema,
GenerateMachineSecretResponse,
GetDefaultPasswordComplexityPolicyResponse,
GetLoginPolicyRequestSchema,
GetLoginPolicyResponse,
GetPasswordComplexityPolicyResponse,
ListUserMetadataRequestSchema,
ListUserMetadataResponse,
RemoveMachineSecretRequestSchema,
@@ -89,4 +91,12 @@ export class NewMgmtService {
): Promise<RemoveUserMetadataResponse> {
return this.grpcService.mgmtNew.removeUserMetadata(create(RemoveUserMetadataRequestSchema, req));
}
public getPasswordComplexityPolicy(): Promise<GetPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getPasswordComplexityPolicy({});
}
public getDefaultPasswordComplexityPolicy(): Promise<GetDefaultPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({});
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { ValidatorFn } from '@angular/forms';
import {
containsLowerCaseValidator,
containsNumberValidator,
containsSymbolValidator,
containsUpperCaseValidator,
minLengthValidator,
requiredValidator,
} from '../modules/form-field/validators/validators';
import { PasswordComplexityPolicy } from '@zitadel/proto/zitadel/policy_pb';
@Injectable({
providedIn: 'root',
})
export class PasswordComplexityValidatorFactoryService {
constructor() {}
buildValidators(policy?: PasswordComplexityPolicy) {
const validators: ValidatorFn[] = [requiredValidator];
if (policy?.minLength) {
validators.push(minLengthValidator(Number(policy.minLength)));
}
if (policy?.hasLowercase) {
validators.push(containsLowerCaseValidator);
}
if (policy?.hasUppercase) {
validators.push(containsUpperCaseValidator);
}
if (policy?.hasNumber) {
validators.push(containsNumberValidator);
}
if (policy?.hasSymbol) {
validators.push(containsSymbolValidator);
}
return validators;
}
}
+40 -46
View File
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddHumanUserRequestSchema,
@@ -70,57 +70,65 @@ 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 { firstValueFrom, Observable, shareReplay } from 'rxjs';
import { filter, map, startWith, tap, timeout } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime, EMPTY, Observable, of, ReplaySubject, shareReplay, switchAll, switchMap } from 'rxjs';
import { catchError, filter, map, startWith } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class UserService {
private readonly userId$: Observable<string>;
private user: UserV2 | undefined;
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;
}),
);
constructor(
private readonly grpcService: GrpcService,
private readonly oauthService: OAuthService,
destroyRef: DestroyRef,
) {
this.userId$ = this.getUserId().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
// this preloads the userId and deletes the cache everytime the userId changes
this.userId$.pipe(takeUntilDestroyed(destroyRef)).subscribe(async () => {
this.user = undefined;
try {
await this.getMyUser();
} catch (error) {
console.warn(error);
}
});
}
) {}
private getUserId() {
return this.oauthService.events.pipe(
filter((event) => event.type === 'token_received'),
startWith(this.oauthService.getIdToken),
map(() => this.oauthService.getIdToken()),
startWith(this.oauthService.getIdToken()),
filter(Boolean),
// split jwt and get base64 encoded payload
map((token) => token.split('.')[1]),
// decode payload
map(atob),
// parse payload
map((payload) => JSON.parse(payload)),
map((payload: unknown) => {
// check if sub is in payload and is a string
if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') {
return payload.sub;
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;
}
throw new Error('Invalid payload');
}),
);
}
private getUser() {
return this.getUserId().pipe(
switchMap((id) => this.getUserById(id)),
map((resp) => resp.user),
filter(Boolean),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
public addHumanUser(req: MessageInitShape<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> {
return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req));
}
@@ -129,20 +137,6 @@ export class UserService {
return this.grpcService.userNew.listUsers(req);
}
public async getMyUser(): Promise<UserV2> {
const userId = await firstValueFrom(this.userId$.pipe(timeout(2000)));
if (this.user) {
return this.user;
}
const resp = await this.getUserById(userId);
if (!resp.user) {
throw new Error("Couldn't find user");
}
this.user = resp.user;
return resp.user;
}
public getUserById(userId: string): Promise<GetUserByIDResponse> {
return this.grpcService.userNew.getUserByID({ userId });
}