diff --git a/console/src/app/modules/password-complexity-view/password-complexity-view.component.html b/console/src/app/modules/password-complexity-view/password-complexity-view.component.html index 0a5649dabb..aab69c5b5a 100644 --- a/console/src/app/modules/password-complexity-view/password-complexity-view.component.html +++ b/console/src/app/modules/password-complexity-view/password-complexity-view.component.html @@ -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" > diff --git a/console/src/app/modules/password-complexity-view/password-complexity-view.component.ts b/console/src/app/modules/password-complexity-view/password-complexity-view.component.ts index db6ecab89a..f560f39024 100644 --- a/console/src/app/modules/password-complexity-view/password-complexity-view.component.ts +++ b/console/src/app/modules/password-complexity-view/password-complexity-view.component.ts @@ -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); + } } diff --git a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.html b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.html new file mode 100644 index 0000000000..ebaf7d12f0 --- /dev/null +++ b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.html @@ -0,0 +1,92 @@ + + +
+ +
+ + Mark Email as Verified + +
+ + {{ 'USER.PROFILE.FIRSTNAME' | translate }} + + + + {{ 'USER.PROFILE.LASTNAME' | translate }} + + + + {{ 'USER.PROFILE.NICKNAME' | translate }} + + + + {{ 'USER.PROFILE.USERNAME' | translate }} + + + + +
+
+ Would you like to set up authentication methods now? + You don't have to do this now, you can do it later + +
+ + + Invitation + + + Initial password + + + + + + + {{ 'USER.PASSWORD.NEWINITIAL' | translate }} + + + + {{ 'USER.PASSWORD.CONFIRMINITIAL' | translate }} + + + +
+ + + + +
diff --git a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.scss b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.scss new file mode 100644 index 0000000000..d3184d0841 --- /dev/null +++ b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.scss @@ -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; +} diff --git a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts new file mode 100644 index 0000000000..737d468f1e --- /dev/null +++ b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts @@ -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; +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; + private readonly passwordComplexityPolicy$: Observable; + protected readonly authenticationFactor$: Observable; + + 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, + ): Observable { + 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('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 = { + 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); + } + } +} diff --git a/console/src/app/pages/users/user-create/user-create.module.ts b/console/src/app/pages/users/user-create/user-create.module.ts index a5483f1802..5bab56f861 100644 --- a/console/src/app/pages/users/user-create/user-create.module.ts +++ b/console/src/app/pages/users/user-create/user-create.module.ts @@ -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 {} diff --git a/console/src/app/services/new-mgmt.service.ts b/console/src/app/services/new-mgmt.service.ts index 21019f8c73..c1eeabf3e9 100644 --- a/console/src/app/services/new-mgmt.service.ts +++ b/console/src/app/services/new-mgmt.service.ts @@ -5,6 +5,7 @@ import { GenerateMachineSecretResponse, GetLoginPolicyRequestSchema, GetLoginPolicyResponse, + GetPasswordComplexityPolicyResponse, ListUserMetadataRequestSchema, ListUserMetadataResponse, RemoveMachineSecretRequestSchema, @@ -89,4 +90,8 @@ export class NewMgmtService { ): Promise { return this.grpcService.mgmtNew.removeUserMetadata(create(RemoveUserMetadataRequestSchema, req)); } + + public getPasswordComplexityPolicy(): Promise { + return this.grpcService.mgmtNew.getPasswordComplexityPolicy({}); + } }