This commit is contained in:
conblem 2025-02-27 13:39:16 +01:00
parent 83614562a2
commit 73f5b52ab8
7 changed files with 342 additions and 3 deletions

View File

@ -15,7 +15,7 @@
diameter="20"
[color]="currentError ? 'warn' : 'valid'"
mode="determinate"
[value]="(password?.value?.length / policy.minLength) * 100"
[value]="(password?.value?.length / minLength) * 100"
>
</mat-progress-spinner>
</div>

View File

@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { PasswordComplexityPolicy as BufPasswordComplexityPolicy } from '@zitadel/proto/zitadel/policy_pb';
@Component({
selector: 'cnsl-password-complexity-view',
@ -9,5 +10,9 @@ import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy
})
export class PasswordComplexityViewComponent {
@Input() public password: AbstractControl | null = null;
@Input() public policy!: PasswordComplexityPolicy.AsObject;
@Input() public policy!: PasswordComplexityPolicy.AsObject | BufPasswordComplexityPolicy;
protected get minLength() {
return Number(this.policy.minLength);
}
}

View File

@ -0,0 +1,92 @@
<cnsl-create-layout
title="{{ 'USER.CREATE.TITLE' | translate }}"
[createSteps]="1"
[currentCreateStep]="1"
(closed)="location.back()"
>
<mat-progress-bar *ngIf="loading()" color="primary" mode="indeterminate"></mat-progress-bar>
<form *ngIf="authenticationFactor$ | async as authenticationFactor" class="form-grid" [formGroup]="userForm" (ngSubmit)="createUserV2(authenticationFactor)">
<cnsl-form-field class="email">
<cnsl-label>{{ 'USER.PROFILE.EMAIL' | translate }}</cnsl-label>
<input cnslInput matRipple formControlName="email" required />
</cnsl-form-field>
<div class="emailVerified">
<mat-checkbox *ngIf="authenticationFactor.factor !== 'invitation'" formControlName="emailVerified">
Mark Email as Verified
</mat-checkbox>
</div>
<cnsl-form-field class="givenName">
<cnsl-label>{{ 'USER.PROFILE.FIRSTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="givenName" required />
</cnsl-form-field>
<cnsl-form-field class="familyName">
<cnsl-label>{{ 'USER.PROFILE.LASTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="familyName" required />
</cnsl-form-field>
<cnsl-form-field class="nickName">
<cnsl-label>{{ 'USER.PROFILE.NICKNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="nickName" />
</cnsl-form-field>
<cnsl-form-field class="username">
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input
cnslInput
formControlName="username"
required
/>
</cnsl-form-field>
<div class="authenticationFactor">
<div [class.authenticationFactorIntroductionDone]="authenticationFactor.factor !== 'none'">
Would you like to set up authentication methods now?
<span>You don't have to do this now, you can do it later</span>
<button mat-raised-button color="primary" (click)="$event.preventDefault(); userForm.controls.authenticationFactor.setValue('invitation')">
Setup
</button>
</div>
<mat-radio-group *ngIf="authenticationFactor.factor !== 'none'" aria-label="Select an option" formControlName="authenticationFactor">
<mat-radio-button [value]="'invitation'">
Invitation
</mat-radio-button>
<mat-radio-button [value]="'initialPassword'">
Initial password
</mat-radio-button>
</mat-radio-group>
<form *ngIf="authenticationFactor.factor === 'initialPassword'" [formGroup]="authenticationFactor.form">
<cnsl-password-complexity-view
class="complexity-view"
[policy]="authenticationFactor.policy"
[password]="authenticationFactor.form.controls.password"
>
</cnsl-password-complexity-view>
<cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.NEWINITIAL' | translate }}</cnsl-label>
<input cnslInput autocomplete="off" name="firstpassword" formControlName="password" type="password" />
</cnsl-form-field>
<cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.CONFIRMINITIAL' | translate }}</cnsl-label>
<input
cnslInput
autocomplete="off"
name="confirmPassword"
formControlName="confirmPassword"
type="password"
/>
</cnsl-form-field>
</form>
</div>
<button
data-e2e="create-button"
color="primary"
[disabled]="userForm.invalid || (authenticationFactor.factor === 'initialPassword' && authenticationFactor.form.invalid)"
type="submit"
class="create-button"
mat-raised-button
>
{{ 'ACTIONS.CREATE' | translate }}
</button>
</form>
</cnsl-create-layout>

View File

@ -0,0 +1,44 @@
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
grid-template-areas:
"email email"
"emailVerified emailVerified"
"givenName familyName"
"nickName username"
"authenticationFactor authenticationFactor";
;
}
.email {
grid-area: email;
}
.emailVerified {
grid-area: emailVerified;
}
.givenName {
grid-area: givenName;
}
.familyName {
grid-area: familyName;
}
.nickName {
grid-area: nickName;
}
.username {
grid-area: username;
}
.authenticationFactor {
grid-area: authenticationFactor;
}
.authenticationFactorIntroductionDone {
background: red;
}

View File

@ -0,0 +1,190 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ToastService } from 'src/app/services/toast.service';
import { FormBuilder, FormControl, ValidatorFn } from '@angular/forms';
import { UserService } from 'src/app/services/user.service';
import { LanguagesService } from 'src/app/services/languages.service';
import { FeatureService } from 'src/app/services/feature.service';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { Location } from '@angular/common';
import {
containsLowerCaseValidator,
containsNumberValidator,
containsSymbolValidator,
containsUpperCaseValidator,
emailValidator,
minLengthValidator,
passwordConfirmValidator,
requiredValidator,
} from 'src/app/modules/form-field/validators/validators';
import { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { defaultIfEmpty, defer, EMPTY, Observable, shareReplay } from 'rxjs';
import { catchError, filter, map, startWith } from 'rxjs/operators';
import { PasswordComplexityPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { MessageInitShape } from '@bufbuild/protobuf';
import { AddHumanUserRequestSchema } from '@zitadel/proto/zitadel/user/v2/user_service_pb';
import { withLatestFromSynchronousFix } from '../../../../utils/withLatestFromSynchronousFix';
type PwdForm = ReturnType<UserCreateV2Component['buildPwdForm']>;
type AuthenticationFactor =
| { factor: 'none' }
| { factor: 'initialPassword'; form: PwdForm; policy: PasswordComplexityPolicy }
| { factor: 'invitation' };
@Component({
selector: 'cnsl-user-create-v2',
templateUrl: './user-create-v2.component.html',
styleUrls: ['./user-create-v2.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCreateV2Component implements OnInit {
protected readonly userForm: ReturnType<typeof this.buildUserForm>;
private readonly passwordComplexityPolicy$: Observable<PasswordComplexityPolicy>;
protected readonly authenticationFactor$: Observable<AuthenticationFactor>;
protected loading = signal(false);
constructor(
private router: Router,
private readonly toast: ToastService,
private readonly fb: FormBuilder,
private readonly userService: UserService,
public readonly langSvc: LanguagesService,
private readonly featureService: FeatureService,
private readonly destroyRef: DestroyRef,
private readonly breadcrumbService: BreadcrumbService,
private readonly newMgmtService: NewMgmtService,
protected readonly location: Location,
) {
this.userForm = this.buildUserForm();
this.passwordComplexityPolicy$ = this.getPasswordComplexityPolicy().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.authenticationFactor$ = this.getAuthenticationFactor(this.userForm, this.passwordComplexityPolicy$);
}
ngOnInit(): void {
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.ORG,
routerLink: ['/org'],
}),
]);
}
private getAuthenticationFactor(
userForm: typeof this.userForm,
passwordComplexityPolicy$: Observable<PasswordComplexityPolicy>,
): Observable<AuthenticationFactor> {
const pwdForm$ = passwordComplexityPolicy$.pipe(
defaultIfEmpty(undefined),
map((policy) => this.buildPwdForm(policy)),
);
return userForm.controls.authenticationFactor.valueChanges.pipe(
startWith(userForm.controls.authenticationFactor.value),
withLatestFromSynchronousFix(pwdForm$, passwordComplexityPolicy$),
map(([factor, form, policy]) => {
if (factor === 'initialPassword') {
return { factor, form, policy };
}
return { factor };
}),
);
}
public buildUserForm() {
return this.fb.group({
email: new FormControl('', { nonNullable: true, validators: [requiredValidator, emailValidator] }),
username: new FormControl('', { nonNullable: true, validators: [requiredValidator, minLengthValidator(2)] }),
givenName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
familyName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
nickName: new FormControl('', { nonNullable: true }),
emailVerified: new FormControl(false, { nonNullable: true }),
authenticationFactor: new FormControl<AuthenticationFactor['factor']>('none', { nonNullable: true }),
});
}
private buildPwdForm(policy: PasswordComplexityPolicy | undefined) {
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 this.fb.group({
password: new FormControl('', { nonNullable: true, validators }),
confirmPassword: new FormControl('', {
nonNullable: true,
validators: [requiredValidator, passwordConfirmValidator()],
}),
});
}
private getPasswordComplexityPolicy() {
return defer(() => this.newMgmtService.getPasswordComplexityPolicy()).pipe(
map(({ policy }) => policy),
filter(Boolean),
catchError((error) => {
this.toast.showError(error);
return EMPTY;
}),
);
}
protected async createUserV2(authenticationFactor: AuthenticationFactor) {
this.loading.set(true);
const userValues = this.userForm.getRawValue();
const humanReq: MessageInitShape<typeof AddHumanUserRequestSchema> = {
username: userValues.username,
profile: {
givenName: userValues.givenName,
familyName: userValues.familyName,
nickName: userValues.nickName,
},
email: {
email: userValues.email,
verification: {
case: 'isVerified',
value: userValues.emailVerified,
},
},
};
if (authenticationFactor.factor === 'initialPassword') {
const { password } = authenticationFactor.form.getRawValue();
humanReq.passwordType = {
case: 'password',
value: {
password,
},
};
}
try {
const resp = await this.userService.addHumanUser(humanReq);
if (authenticationFactor.factor === 'invitation') {
await this.userService.createInviteCode({
userId: resp.userId,
verification: {
case: 'sendCode',
value: {},
},
});
}
this.toast.showInfo('USER.TOAST.CREATED', true);
await this.router.navigate(['users', resp.userId], { queryParams: { new: true } });
} catch (error) {
this.toast.showError(error);
} finally {
this.loading.set(false);
}
}
}

View File

@ -19,9 +19,11 @@ import { PasswordComplexityViewModule } from 'src/app/modules/password-complexit
import { CountryCallingCodesService } from 'src/app/services/country-calling-codes.service';
import { UserCreateRoutingModule } from './user-create-routing.module';
import { UserCreateComponent } from './user-create.component';
import { UserCreateV2Component } from './user-create-v2/user-create-v2.component';
import { MatRadioModule } from '@angular/material/radio';
@NgModule({
declarations: [UserCreateComponent],
declarations: [UserCreateComponent, UserCreateV2Component],
providers: [CountryCallingCodesService],
imports: [
UserCreateRoutingModule,
@ -42,6 +44,7 @@ import { UserCreateComponent } from './user-create.component';
DetailLayoutModule,
InputModule,
MatRippleModule,
MatRadioModule,
],
})
export default class UserCreateModule {}

View File

@ -5,6 +5,7 @@ import {
GenerateMachineSecretResponse,
GetLoginPolicyRequestSchema,
GetLoginPolicyResponse,
GetPasswordComplexityPolicyResponse,
ListUserMetadataRequestSchema,
ListUserMetadataResponse,
RemoveMachineSecretRequestSchema,
@ -89,4 +90,8 @@ export class NewMgmtService {
): Promise<RemoveUserMetadataResponse> {
return this.grpcService.mgmtNew.removeUserMetadata(create(RemoveUserMetadataRequestSchema, req));
}
public getPasswordComplexityPolicy(): Promise<GetPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getPasswordComplexityPolicy({});
}
}