feat: Use V2 API's in Console (#9312)

# Which Problems Are Solved
Solves #8976

# Additional Changes
I have done some intensive refactorings and we are using the new
@zitadel/client package for GRPC access.

# Additional Context
- Closes #8976

---------

Co-authored-by: Max Peintner <peintnerm@gmail.com>
This commit is contained in:
Ramon
2025-02-17 19:25:46 +01:00
committed by GitHub
parent ad225836d5
commit 3042bbb993
90 changed files with 3679 additions and 2315 deletions

View File

@@ -63,7 +63,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "8mb", "maximumWarning": "8mb",
"maximumError": "9mb" "maximumError": "10mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -24,6 +24,8 @@
"@angular/platform-browser-dynamic": "^16.2.5", "@angular/platform-browser-dynamic": "^16.2.5",
"@angular/router": "^16.2.5", "@angular/router": "^16.2.5",
"@angular/service-worker": "^16.2.5", "@angular/service-worker": "^16.2.5",
"@connectrpc/connect": "^2.0.0",
"@connectrpc/connect-web": "^2.0.0",
"@ctrl/ngx-codemirror": "^6.1.0", "@ctrl/ngx-codemirror": "^6.1.0",
"@fortawesome/angular-fontawesome": "^0.13.0", "@fortawesome/angular-fontawesome": "^0.13.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/fontawesome-svg-core": "^6.4.2",
@@ -31,6 +33,8 @@
"@grpc/grpc-js": "^1.11.2", "@grpc/grpc-js": "^1.11.2",
"@netlify/framework-info": "^9.8.13", "@netlify/framework-info": "^9.8.13",
"@ngx-translate/core": "^15.0.0", "@ngx-translate/core": "^15.0.0",
"@zitadel/client": "^1.0.6",
"@zitadel/proto": "^1.0.3",
"angular-oauth2-oidc": "^15.0.1", "angular-oauth2-oidc": "^15.0.1",
"angularx-qrcode": "^16.0.0", "angularx-qrcode": "^16.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",

View File

@@ -1,5 +1,5 @@
<div class="main-container"> <div class="main-container">
<ng-container *ngIf="(authService.userSubject | async) || {} as user"> <ng-container *ngIf="(authService.user | async) || {} as user">
<cnsl-header <cnsl-header
*ngIf="user && user !== {}" *ngIf="user && user !== {}"
[org]="org" [org]="org"

View File

@@ -8,7 +8,7 @@ import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core'; import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
import { Observable, of, Subject } from 'rxjs'; import { Observable, of, Subject } from 'rxjs';
import { filter, map, takeUntil } from 'rxjs/operators'; import { filter, map, startWith, takeUntil } from 'rxjs/operators';
import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations'; import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations';
import { Org } from './proto/generated/zitadel/org_pb'; import { Org } from './proto/generated/zitadel/org_pb';
@@ -275,7 +275,7 @@ export class AppComponent implements OnDestroy {
const currentUrl = this.router.url; const currentUrl = this.router.url;
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
// We use navigateByUrl as our urls may have queryParams // We use navigateByUrl as our urls may have queryParams
this.router.navigateByUrl(currentUrl); this.router.navigateByUrl(currentUrl).then();
}); });
} }
@@ -283,18 +283,16 @@ export class AppComponent implements OnDestroy {
this.translate.addLangs(supportedLanguages); this.translate.addLangs(supportedLanguages);
this.translate.setDefaultLang(fallbackLanguage); this.translate.setDefaultLang(fallbackLanguage);
this.authService.userSubject.pipe(takeUntil(this.destroy$)).subscribe((userprofile) => { this.authService.user.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((userprofile) => {
if (userprofile) { const cropped = navigator.language.split('-')[0] ?? fallbackLanguage;
const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp) const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp)
? userprofile.human.profile?.preferredLanguage ? userprofile.human.profile?.preferredLanguage
: fallbackLang; : fallbackLang;
this.translate.use(lang); this.translate.use(lang);
this.language = lang; this.language = lang;
this.document.documentElement.lang = lang; this.document.documentElement.lang = lang;
}
}); });
} }
@@ -308,7 +306,7 @@ export class AppComponent implements OnDestroy {
} }
private setFavicon(theme: string): void { private setFavicon(theme: string): void {
this.authService.labelpolicy.pipe(takeUntil(this.destroy$)).subscribe((lP) => { this.authService.labelpolicy$.pipe(startWith(undefined), takeUntil(this.destroy$)).subscribe((lP) => {
if (theme === 'dark-theme' && lP?.iconUrlDark) { if (theme === 'dark-theme' && lP?.iconUrlDark) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied // Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrlDark).then((response) => { fetch(lP.iconUrlDark).then((response) => {

View File

@@ -403,6 +403,34 @@
'SETTING.FEATURES.OIDCSINGLEV1SESSIONTERMINATION_DESCRIPTION' | translate 'SETTING.FEATURES.OIDCSINGLEV1SESSIONTERMINATION_DESCRIPTION' | translate
}}</cnsl-info-section> }}</cnsl-info-section>
</div> </div>
<div class="feature-row" *ngIf="toggleStates.consoleUseV2UserApi">
<span>{{ 'SETTING.FEATURES.CONSOLEUSEV2USERAPI' | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="toggleStates.consoleUseV2UserApi.state"
(change)="validateAndSave()"
name="displayview"
aria-label="Display View"
>
<mat-button-toggle [value]="ToggleState.DISABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<div class="toggle-row">
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
</div>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<cnsl-info-section class="feature-info">{{
'SETTING.FEATURES.CONSOLEUSEV2USERAPI_DESCRIPTION' | translate
}}</cnsl-info-section>
</div>
</div> </div>
</cnsl-card> </cnsl-card>
</div> </div>

View File

@@ -16,13 +16,14 @@ import { InfoSectionModule } from 'src/app/modules/info-section/info-section.mod
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { Event } from 'src/app/proto/generated/zitadel/event_pb'; import { Event } from 'src/app/proto/generated/zitadel/event_pb';
import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb'; import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb';
import {
GetInstanceFeaturesResponse,
SetInstanceFeaturesRequest,
} from 'src/app/proto/generated/zitadel/feature/v2beta/instance_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { FeatureService } from 'src/app/services/feature.service'; import { FeatureService } from 'src/app/services/feature.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import {
GetInstanceFeaturesResponse,
SetInstanceFeaturesRequest,
} from '../../proto/generated/zitadel/feature/v2/instance_pb';
import { withIdentifier } from 'codelyzer/util/astQuery';
enum ToggleState { enum ToggleState {
ENABLED = 'ENABLED', ENABLED = 'ENABLED',
@@ -39,6 +40,7 @@ type ToggleStates = {
oidcTokenExchange?: FeatureState; oidcTokenExchange?: FeatureState;
actions?: FeatureState; actions?: FeatureState;
oidcSingleV1SessionTermination?: FeatureState; oidcSingleV1SessionTermination?: FeatureState;
consoleUseV2UserApi?: FeatureState;
}; };
@Component({ @Component({
@@ -142,6 +144,7 @@ export class FeaturesComponent implements OnDestroy {
); );
changed = true; changed = true;
} }
req.setConsoleUseV2UserApi(this.toggleStates?.consoleUseV2UserApi?.state === ToggleState.ENABLED);
if (changed) { if (changed) {
this.featureService this.featureService
@@ -232,6 +235,10 @@ export class FeaturesComponent implements OnDestroy {
? ToggleState.ENABLED ? ToggleState.ENABLED
: ToggleState.DISABLED, : ToggleState.DISABLED,
}, },
consoleUseV2UserApi: {
source: this.featureData.consoleUseV2UserApi?.source || Source.SOURCE_INSTANCE,
state: this.featureData.consoleUseV2UserApi?.enabled ? ToggleState.ENABLED : ToggleState.DISABLED,
},
}; };
}); });
} }

View File

@@ -1,18 +1,17 @@
import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core'; import { DestroyRef, Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Directive({ @Directive({
selector: '[cnslHasRole]', selector: '[cnslHasRole]',
}) })
export class HasRoleDirective implements OnDestroy { export class HasRoleDirective {
private destroy$: Subject<void> = new Subject();
private hasView: boolean = false; private hasView: boolean = false;
@Input() public set hasRole(roles: string[] | RegExp[] | undefined) { @Input() public set hasRole(roles: string[] | RegExp[] | undefined) {
if (roles && roles.length > 0) { if (roles && roles.length > 0) {
this.authService this.authService
.isAllowed(roles) .isAllowed(roles)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isAllowed) => { .subscribe((isAllowed) => {
if (isAllowed && !this.hasView) { if (isAllowed && !this.hasView) {
if (this.viewContainerRef.length !== 0) { if (this.viewContainerRef.length !== 0) {
@@ -38,10 +37,6 @@ export class HasRoleDirective implements OnDestroy {
private authService: GrpcAuthService, private authService: GrpcAuthService,
protected templateRef: TemplateRef<any>, protected templateRef: TemplateRef<any>,
protected viewContainerRef: ViewContainerRef, protected viewContainerRef: ViewContainerRef,
private readonly destroyRef: DestroyRef,
) {} ) {}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@@ -4,6 +4,7 @@ import { AuthConfig } from 'angular-oauth2-oidc';
import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { AuthenticationService } from 'src/app/services/authentication.service'; import { AuthenticationService } from 'src/app/services/authentication.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'cnsl-accounts-card', selector: 'cnsl-accounts-card',
@@ -18,6 +19,8 @@ export class AccountsCardComponent implements OnInit {
public sessions: Session.AsObject[] = []; public sessions: Session.AsObject[] = [];
public loadingUsers: boolean = false; public loadingUsers: boolean = false;
public UserState: any = UserState; public UserState: any = UserState;
private labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined });
constructor( constructor(
public authService: AuthenticationService, public authService: AuthenticationService,
private router: Router, private router: Router,
@@ -68,7 +71,7 @@ export class AccountsCardComponent implements OnInit {
} }
public logout(): void { public logout(): void {
const lP = JSON.stringify(this.userService.labelpolicy.getValue()); const lP = JSON.stringify(this.labelpolicy());
localStorage.setItem('labelPolicyOnSignout', lP); localStorage.setItem('labelPolicyOnSignout', lP);
this.authService.signout(); this.authService.signout();

View File

@@ -1,6 +1,6 @@
<div class="footer-wrapper"> <div class="footer-wrapper">
<div class="footer-row"> <div class="footer-row">
<div class="footer-links" *ngIf="authService.privacypolicy | async as pP"> <div class="footer-links" *ngIf="authService.privacypolicy$ | async as pP">
<a target="_blank" *ngIf="pP?.tosLink" rel="noreferrer" [href]="pP?.tosLink" external> <a target="_blank" *ngIf="pP?.tosLink" rel="noreferrer" [href]="pP?.tosLink" external>
<span>{{ 'FOOTER.LINKS.TOS' | translate }}</span> <span>{{ 'FOOTER.LINKS.TOS' | translate }}</span>
<i class="las la-external-link-alt"></i> <i class="las la-external-link-alt"></i>
@@ -11,7 +11,7 @@
</a> </a>
</div> </div>
<div class="footer-socials" *ngIf="authService.labelpolicy | async as lP"> <div class="footer-socials" *ngIf="authService.labelpolicy$ | async as lP">
<ng-container *ngIf="lP?.disableWatermark === false"> <ng-container *ngIf="lP?.disableWatermark === false">
<a target="_blank" rel="noreferrer" href="https://github.com/zitadel"> <a target="_blank" rel="noreferrer" href="https://github.com/zitadel">
<i class="text-3xl lab la-github"></i> <i class="text-3xl lab la-github"></i>

View File

@@ -4,7 +4,7 @@
<mat-spinner [diameter]="20"></mat-spinner> <mat-spinner [diameter]="20"></mat-spinner>
</div> </div>
<ng-template #logo> <ng-template #logo>
<a class="title custom" [routerLink]="['/']" *ngIf="authService.labelpolicy | async as lP; else defaultHome"> <a class="title custom" [routerLink]="['/']" *ngIf="authService.labelpolicy$ | async as lP; else defaultHome">
<img <img
class="logo" class="logo"
alt="home logo" alt="home logo"
@@ -24,7 +24,7 @@
</ng-template> </ng-template>
<ng-template #defaultHome> <ng-template #defaultHome>
<a class="title" [routerLink]="authService.zitadelPermissions.getValue().length === 0 ? ['/users', 'me'] : ['/']"> <a class="title" [routerLink]="(authService.zitadelPermissions | async)?.length ? ['/'] : ['/users', 'me']">
<img <img
class="logo" class="logo"
alt="zitadel logo" alt="zitadel logo"
@@ -168,7 +168,7 @@
<span class="fill-space"></span> <span class="fill-space"></span>
<ng-container *ngIf="authService.privacypolicy | async as pP"> <ng-container *ngIf="authService.privacypolicy$ | async as pP">
<a class="custom-link" *ngIf="pP.customLink" href="{{ pP.customLink }}" mat-stroked-button target="_blank"> <a class="custom-link" *ngIf="pP.customLink" href="{{ pP.customLink }}" mat-stroked-button target="_blank">
{{ pP.customLinkText }} {{ pP.customLinkText }}
</a> </a>

View File

@@ -9,26 +9,26 @@
inactive: user.state === UserState.USER_STATE_INACTIVE, inactive: user.state === UserState.USER_STATE_INACTIVE,
}" }"
> >
{{ 'USER.DATA.STATE' + user.state | translate }} {{ (isV2(user) ? 'USER.STATEV2.' : 'USER.STATE.') + user.state | translate }}
</p> </p>
</div> </div>
<div class="info-wrapper"> <div class="info-wrapper">
<p class="info-row-title">{{ 'USER.ID' | translate }}</p> <p class="info-row-title">{{ 'USER.ID' | translate }}</p>
<p *ngIf="user && user.id" class="info-row-desc">{{ user.id }}</p> <p *ngIf="userId" class="info-row-desc">{{ userId }}</p>
</div> </div>
<div class="info-wrapper"> <div class="info-wrapper">
<p class="info-row-title">{{ 'USER.DETAILS.DATECREATED' | translate }}</p> <p class="info-row-title">{{ 'USER.DETAILS.DATECREATED' | translate }}</p>
<p *ngIf="user && user.details && user.details.creationDate" class="info-row-desc"> <p *ngIf="creationDate" class="info-row-desc">
{{ user.details.creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }} {{ creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
</p> </p>
</div> </div>
<div class="info-wrapper"> <div class="info-wrapper">
<p class="info-row-title">{{ 'USER.DETAILS.DATECHANGED' | translate }}</p> <p class="info-row-title">{{ 'USER.DETAILS.DATECHANGED' | translate }}</p>
<p *ngIf="user && user.details && user.details.changeDate" class="info-row-desc"> <p *ngIf="changeDate" class="info-row-desc">
{{ user.details.changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }} {{ changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
</p> </p>
</div> </div>

View File

@@ -6,6 +6,9 @@ import { Org, OrgState } from 'src/app/proto/generated/zitadel/org_pb';
import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb'; import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { GrantedProject, Project, ProjectGrantState, ProjectState } from 'src/app/proto/generated/zitadel/project_pb'; import { GrantedProject, Project, ProjectGrantState, ProjectState } from 'src/app/proto/generated/zitadel/project_pb';
import { User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; import { User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { User as UserV1 } from '@zitadel/proto/zitadel/user_pb';
import { User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { LoginPolicy as LoginPolicyV2 } from '@zitadel/proto/zitadel/policy_pb';
@Component({ @Component({
selector: 'cnsl-info-row', selector: 'cnsl-info-row',
@@ -13,14 +16,14 @@ import { User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
styleUrls: ['./info-row.component.scss'], styleUrls: ['./info-row.component.scss'],
}) })
export class InfoRowComponent { export class InfoRowComponent {
@Input() public user!: User.AsObject; @Input() public user?: User.AsObject | UserV2 | UserV1;
@Input() public org!: Org.AsObject; @Input() public org!: Org.AsObject;
@Input() public instance!: InstanceDetail.AsObject; @Input() public instance!: InstanceDetail.AsObject;
@Input() public app!: App.AsObject; @Input() public app!: App.AsObject;
@Input() public idp!: IDP.AsObject; @Input() public idp!: IDP.AsObject;
@Input() public project!: Project.AsObject; @Input() public project!: Project.AsObject;
@Input() public grantedProject!: GrantedProject.AsObject; @Input() public grantedProject!: GrantedProject.AsObject;
@Input() public loginPolicy?: LoginPolicy.AsObject; @Input() public loginPolicy?: LoginPolicy.AsObject | LoginPolicyV2;
public UserState: any = UserState; public UserState: any = UserState;
public State: any = State; public State: any = State;
@@ -35,25 +38,77 @@ export class InfoRowComponent {
constructor() {} constructor() {}
public get loginMethods(): Set<string> { public get loginMethods(): Set<string> {
const methods = this.user?.loginNamesList; if (!this.user) {
let email: string = ''; return new Set();
let phone: string = '';
if (this.loginPolicy) {
if (
!this.loginPolicy?.disableLoginWithEmail &&
this.user.human?.email?.email &&
this.user.human.email.isEmailVerified
) {
email = this.user.human?.email?.email;
}
if (
!this.loginPolicy?.disableLoginWithPhone &&
this.user.human?.phone?.phone &&
this.user.human.phone.isPhoneVerified
) {
phone = this.user.human?.phone?.phone;
}
} }
return new Set([email, phone, ...methods].filter((method) => !!method));
const methods = '$typeName' in this.user ? this.user.loginNames : this.user.loginNamesList;
const loginPolicy = this.loginPolicy;
if (!loginPolicy) {
return new Set([...methods]);
}
let email = !loginPolicy.disableLoginWithEmail ? this.getEmail(this.user) : '';
let phone = !loginPolicy.disableLoginWithPhone ? this.getPhone(this.user) : '';
return new Set([email, phone, ...methods].filter(Boolean));
}
public get userId() {
if (!this.user) {
return undefined;
}
if ('$typeName' in this.user && this.user.$typeName === 'zitadel.user.v2.User') {
return this.user.userId;
}
return this.user.id;
}
public get changeDate() {
return this?.user?.details?.changeDate;
}
public get creationDate() {
if (!this.user) {
return undefined;
}
return '$typeName' in this.user ? undefined : this.user.details?.creationDate;
}
private getEmail(user: User.AsObject | UserV2 | UserV1) {
const human = this.human(user);
if (!human) {
return '';
}
if ('$typeName' in human && human.$typeName === 'zitadel.user.v2.HumanUser') {
return human.email?.isVerified ? human.email.email : '';
}
return human.email?.isEmailVerified ? human.email.email : '';
}
private getPhone(user: User.AsObject | UserV2 | UserV1) {
const human = this.human(user);
if (!human) {
return '';
}
if ('$typeName' in human && human.$typeName === 'zitadel.user.v2.HumanUser') {
return human.phone?.isVerified ? human.phone.phone : '';
}
return human.phone?.isPhoneVerified ? human.phone.phone : '';
}
public human(user: User.AsObject | UserV2 | UserV1) {
if (!('$typeName' in user)) {
return user.human;
}
return user.type.case === 'human' ? user.type.value : undefined;
}
public isV2(user: User.AsObject | UserV2 | UserV1) {
if ('$typeName' in user) {
return user.$typeName === 'zitadel.user.v2.User';
}
return false;
} }
} }

View File

@@ -1,8 +1,16 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { Metadata as MetadataV2 } from '@zitadel/proto/zitadel/metadata_pb';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { Buffer } from 'buffer';
export type MetadataDialogData = {
metadata: (Metadata.AsObject | MetadataV2)[];
setFcn: (key: string, value: string) => Promise<any>;
removeFcn: (key: string) => Promise<any>;
};
@Component({ @Component({
selector: 'cnsl-metadata-dialog', selector: 'cnsl-metadata-dialog',
@@ -10,72 +18,63 @@ import { ToastService } from 'src/app/services/toast.service';
styleUrls: ['./metadata-dialog.component.scss'], styleUrls: ['./metadata-dialog.component.scss'],
}) })
export class MetadataDialogComponent { export class MetadataDialogComponent {
public metadata: Partial<Metadata.AsObject>[] = []; public metadata: { key: string; value: string }[] = [];
public ts!: Timestamp.AsObject | undefined; public ts!: Timestamp.AsObject | undefined;
constructor( constructor(
private toast: ToastService, private toast: ToastService,
public dialogRef: MatDialogRef<MetadataDialogComponent>, public dialogRef: MatDialogRef<MetadataDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: MetadataDialogData,
) { ) {
this.metadata = data.metadata; this.metadata = data.metadata.map(({ key, value }) => ({
key,
value: typeof value === 'string' ? value : Buffer.from(value as unknown as string, 'base64').toString('utf8'),
}));
} }
public addEntry(): void { public addEntry(): void {
const newGroup = { this.metadata.push({
key: '', key: '',
value: '', value: '',
}; });
this.metadata.push(newGroup);
} }
public removeEntry(index: number): void { public async removeEntry(index: number) {
const key = this.metadata[index].key; const key = this.metadata[index].key;
if (key) { if (!key) {
this.removeMetadata(key).then(() => {
this.metadata.splice(index, 1);
if (this.metadata.length === 0) {
this.addEntry();
}
});
} else {
this.metadata.splice(index, 1); this.metadata.splice(index, 1);
return;
}
try {
await this.data.removeFcn(key);
} catch (error) {
this.toast.showError(error);
return;
}
this.toast.showInfo('METADATA.REMOVESUCCESS', true);
this.metadata.splice(index, 1);
if (this.metadata.length === 0) {
this.addEntry();
} }
} }
public saveElement(index: number): void { public async saveElement(index: number) {
const metadataElement = this.metadata[index]; const { key, value } = this.metadata[index];
if (metadataElement.key && metadataElement.value) { if (!key || !value) {
this.setMetadata(metadataElement.key, metadataElement.value as string); return;
} }
}
public setMetadata(key: string, value: string): void { try {
if (key && value) { await this.data.setFcn(key, value);
this.data this.toast.showInfo('METADATA.SETSUCCESS', true);
.setFcn(key, value) } catch (error) {
.then(() => { this.toast.showError(error);
this.toast.showInfo('METADATA.SETSUCCESS', true);
})
.catch((error: any) => {
this.toast.showError(error);
});
} }
} }
public removeMetadata(key: string): Promise<void> {
return this.data
.removeFcn(key)
.then((resp: any) => {
this.toast.showInfo('METADATA.REMOVESUCCESS', true);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
closeDialog(): void { closeDialog(): void {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@@ -1,7 +1,12 @@
<cnsl-card class="metadata-details" [title]="'DESCRIPTIONS.METADATA_TITLE' | translate" [description]="description"> <cnsl-card class="metadata-details" [title]="'DESCRIPTIONS.METADATA_TITLE' | translate" [description]="description">
<mat-spinner card-actions class="spinner" diameter="20" *ngIf="loading"></mat-spinner> <mat-spinner card-actions class="spinner" diameter="20" *ngIf="loading"></mat-spinner>
<cnsl-refresh-table [loading]="loading$ | async" (refreshed)="refresh.emit()" [dataSize]="dataSource.data.length"> <cnsl-refresh-table
*ngIf="dataSource$ | async as dataSource"
[loading]="loading"
(refreshed)="refresh.emit()"
[dataSize]="dataSource.data.length"
>
<button actions [disabled]="disabled" mat-raised-button color="primary" class="edit" (click)="editClicked.emit()"> <button actions [disabled]="disabled" mat-raised-button color="primary" class="edit" (click)="editClicked.emit()">
{{ 'ACTIONS.EDIT' | translate }} {{ 'ACTIONS.EDIT' | translate }}
</button> </button>
@@ -28,7 +33,7 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table> </table>
<div *ngIf="(loading$ | async) === false && !dataSource?.data?.length" class="no-content-row"> <div *ngIf="!loading && !dataSource?.data?.length" class="no-content-row">
<i class="las la-exclamation"></i> <i class="las la-exclamation"></i>
<span>{{ 'USER.MFA.EMPTY' | translate }}</span> <span>{{ 'USER.MFA.EMPTY' | translate }}</span>
</div> </div>

View File

@@ -1,16 +1,26 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTable, MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs'; import { Observable, ReplaySubject } from 'rxjs';
import { Metadata as MetadataV2 } from '@zitadel/proto/zitadel/metadata_pb';
import { map, startWith } from 'rxjs/operators';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb'; import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { Buffer } from 'buffer';
type StringMetadata = {
key: string;
value: string;
};
@Component({ @Component({
selector: 'cnsl-metadata', selector: 'cnsl-metadata',
templateUrl: './metadata.component.html', templateUrl: './metadata.component.html',
styleUrls: ['./metadata.component.scss'], styleUrls: ['./metadata.component.scss'],
}) })
export class MetadataComponent implements OnChanges { export class MetadataComponent implements OnInit {
@Input() public metadata: Metadata.AsObject[] = []; @Input({ required: true }) public set metadata(metadata: (Metadata.AsObject | MetadataV2)[]) {
this.metadata$.next(metadata);
}
@Input() public disabled: boolean = false; @Input() public disabled: boolean = false;
@Input() public loading: boolean = false; @Input() public loading: boolean = false;
@Input({ required: true }) public description!: string; @Input({ required: true }) public description!: string;
@@ -18,18 +28,23 @@ export class MetadataComponent implements OnChanges {
@Output() public refresh: EventEmitter<void> = new EventEmitter(); @Output() public refresh: EventEmitter<void> = new EventEmitter();
public displayedColumns: string[] = ['key', 'value']; public displayedColumns: string[] = ['key', 'value'];
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); public metadata$ = new ReplaySubject<(Metadata.AsObject | MetadataV2)[]>(1);
public loading$: Observable<boolean> = this.loadingSubject.asObservable(); public dataSource$?: Observable<MatTableDataSource<StringMetadata>>;
@ViewChild(MatTable) public table!: MatTable<Metadata.AsObject>;
@ViewChild(MatSort) public sort!: MatSort; @ViewChild(MatSort) public sort!: MatSort;
public dataSource: MatTableDataSource<Metadata.AsObject> = new MatTableDataSource<Metadata.AsObject>([]);
constructor() {} constructor() {}
ngOnChanges(changes: SimpleChanges): void { ngOnInit() {
if (changes['metadata']?.currentValue) { this.dataSource$ = this.metadata$.pipe(
this.dataSource = new MatTableDataSource<Metadata.AsObject>(changes['metadata'].currentValue); map((metadata) =>
} metadata.map(({ key, value }) => ({
key,
value: Buffer.from(value as any as string, 'base64').toString('utf-8'),
})),
),
startWith([] as StringMetadata[]),
map((metadata) => new MatTableDataSource(metadata)),
);
} }
} }

View File

@@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Timestamp } from 'src/app/proto/generated/google/protobuf/timestamp_pb'; import { Timestamp } from 'src/app/proto/generated/google/protobuf/timestamp_pb';
import { Timestamp as ConnectTimestamp } from '@bufbuild/protobuf/wkt';
export interface PageEvent { export interface PageEvent {
length: number; length: number;
@@ -14,7 +15,7 @@ export interface PageEvent {
styleUrls: ['./paginator.component.scss'], styleUrls: ['./paginator.component.scss'],
}) })
export class PaginatorComponent { export class PaginatorComponent {
@Input() public timestamp: Timestamp.AsObject | undefined = undefined; @Input() public timestamp: Timestamp.AsObject | ConnectTimestamp | undefined = undefined;
@Input() public length: number = 0; @Input() public length: number = 0;
@Input() public pageSize: number = 10; @Input() public pageSize: number = 10;
@Input() public pageIndex: number = 0; @Input() public pageIndex: number = 0;

View File

@@ -3,6 +3,7 @@ import { SelectionModel } from '@angular/cdk/collections';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { RefreshService } from 'src/app/services/refresh.service'; import { RefreshService } from 'src/app/services/refresh.service';
import { Timestamp as ConnectTimestamp } from '@bufbuild/protobuf/wkt';
import { ActionKeysType } from '../action-keys/action-keys.component'; import { ActionKeysType } from '../action-keys/action-keys.component';
@@ -27,7 +28,7 @@ const rotate = animation([
}) })
export class RefreshTableComponent implements OnInit { export class RefreshTableComponent implements OnInit {
@Input() public selection: SelectionModel<any> = new SelectionModel<any>(true, []); @Input() public selection: SelectionModel<any> = new SelectionModel<any>(true, []);
@Input() public timestamp: Timestamp.AsObject | undefined = undefined; @Input() public timestamp: Timestamp.AsObject | ConnectTimestamp | undefined = undefined;
@Input() public dataSize: number = 0; @Input() public dataSize: number = 0;
@Input() public emitRefreshAfterTimeoutInMs: number = 0; @Input() public emitRefreshAfterTimeoutInMs: number = 0;
@Input() public loading: boolean | null = false; @Input() public loading: boolean | null = false;

View File

@@ -51,15 +51,17 @@ export class SidenavComponent implements ControlValueAccessor {
} }
if (this.queryParam && setting) { if (this.queryParam && setting) {
this.router.navigate([], { this.router
relativeTo: this.route, .navigate([], {
queryParams: { relativeTo: this.route,
[this.queryParam]: setting, queryParams: {
}, [this.queryParam]: setting,
replaceUrl: true, },
queryParamsHandling: 'merge', replaceUrl: true,
skipLocationChange: false, queryParamsHandling: 'merge',
}); skipLocationChange: false,
})
.then();
} }
} }

View File

@@ -19,16 +19,21 @@ export class SignedoutComponent {
const lP = localStorage.getItem(LABELPOLICY_LOCALSTORAGE_KEY); const lP = localStorage.getItem(LABELPOLICY_LOCALSTORAGE_KEY);
if (lP) { if (!lP) {
const parsed = JSON.parse(lP);
localStorage.removeItem(LABELPOLICY_LOCALSTORAGE_KEY);
if (parsed) {
this.labelpolicy = parsed;
authService.labelpolicy.next(parsed);
authService.labelPolicyLoading$.next(false);
}
} else {
authService.labelPolicyLoading$.next(false); authService.labelPolicyLoading$.next(false);
return;
} }
const parsed = JSON.parse(lP);
localStorage.removeItem(LABELPOLICY_LOCALSTORAGE_KEY);
if (!parsed) {
return;
}
this.labelpolicy = parsed;
// todo: figure this one out
// authService.labelpolicy.next(parsed);
authService.labelPolicyLoading$.next(false);
} }
} }

View File

@@ -2,12 +2,17 @@
title="{{ 'USER.CREATE.TITLE' | translate }}" title="{{ 'USER.CREATE.TITLE' | translate }}"
[createSteps]="1" [createSteps]="1"
[currentCreateStep]="1" [currentCreateStep]="1"
(closed)="close()" (closed)="location.back()"
> >
<div class="user-create-main-content"> <div class="user-create-main-content">
<mat-progress-bar *ngIf="loading" color="primary" mode="indeterminate"></mat-progress-bar> <mat-progress-bar *ngIf="loading" color="primary" mode="indeterminate"></mat-progress-bar>
<form *ngIf="userForm" [formGroup]="userForm" (ngSubmit)="createUser()" class="user-create-form"> <form
*ngIf="pwdForm$ | async as pwdForm"
[formGroup]="userForm"
(ngSubmit)="createUser(pwdForm)"
class="user-create-form"
>
<div class="user-create-content"> <div class="user-create-content">
<p class="user-create-section">{{ 'USER.CREATE.NAMEANDEMAILSECTION' | translate }}</p> <p class="user-create-section">{{ 'USER.CREATE.NAMEANDEMAILSECTION' | translate }}</p>
@@ -19,12 +24,13 @@
<cnsl-form-field> <cnsl-form-field>
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input <input
*ngIf="suffixPadding$ | async as suffixPadding"
cnslInput cnslInput
formControlName="userName" formControlName="userName"
required required
[ngStyle]="{ 'padding-right': suffixPadding ? suffixPadding : '10px' }" [ngStyle]="{ 'padding-right': suffixPadding }"
/> />
<span #suffix *ngIf="envSuffix" cnslSuffix>{{ envSuffix }}</span> <span #suffix *ngIf="envSuffix$ | async as envSuffix" cnslSuffix>{{ envSuffix }}</span>
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field> <cnsl-form-field>
@@ -42,28 +48,39 @@
</div> </div>
<div class="email-is-verified"> <div class="email-is-verified">
<mat-checkbox class="block-checkbox" formControlName="isVerified"> <mat-checkbox class="block-checkbox" formControlName="emailVerified">
{{ 'USER.LOGINMETHODS.EMAIL.ISVERIFIED' | translate }} {{ 'USER.LOGINMETHODS.EMAIL.ISVERIFIED' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox
*ngIf="((useV2Api$ | async) && !usePassword) || !userForm.controls.emailVerified.value"
class="block-checkbox"
formControlName="sendEmail"
>
{{ 'USER.PROFILE.SEND_EMAIL' | translate }}
</mat-checkbox>
<mat-checkbox class="block-checkbox" [(ngModel)]="usePassword" [ngModelOptions]="{ standalone: true }"> <mat-checkbox class="block-checkbox" [(ngModel)]="usePassword" [ngModelOptions]="{ standalone: true }">
{{ 'ORG.PAGES.USEPASSWORD' | translate }} {{ 'ORG.PAGES.USEPASSWORD' | translate }}
</mat-checkbox> </mat-checkbox>
<cnsl-info-section class="full-width desc"> <cnsl-info-section *ngIf="(useV2Api$ | async) === false" class="full-width desc">
<span>{{ 'USER.CREATE.INITMAILDESCRIPTION' | translate }}</span> <span>{{ 'USER.CREATE.INITMAILDESCRIPTION' | translate }}</span>
</cnsl-info-section> </cnsl-info-section>
</div> </div>
<div class="pwd-section" *ngIf="usePassword && pwdForm"> <div class="pwd-section" *ngIf="usePassword && (passwordComplexityPolicy$ | async) as passwordComplexityPolicy">
<cnsl-password-complexity-view class="complexity-view" [policy]="this.policy" [password]="password"> <cnsl-password-complexity-view
class="complexity-view"
[policy]="passwordComplexityPolicy"
[password]="pwdForm.controls.password"
>
</cnsl-password-complexity-view> </cnsl-password-complexity-view>
<form [formGroup]="pwdForm"> <form [formGroup]="pwdForm">
<div class="user-create-grid"> <div class="user-create-grid">
<cnsl-form-field *ngIf="password"> <cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.NEWINITIAL' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PASSWORD.NEWINITIAL' | translate }}</cnsl-label>
<input cnslInput autocomplete="off" name="firstpassword" formControlName="password" type="password" /> <input cnslInput autocomplete="off" name="firstpassword" formControlName="password" type="password" />
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field *ngIf="confirmPassword"> <cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.CONFIRMINITIAL' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PASSWORD.CONFIRMINITIAL' | translate }}</cnsl-label>
<input <input
cnslInput cnslInput
@@ -128,6 +145,18 @@
<input cnslInput formControlName="phone" matTooltip="{{ 'USER.PROFILE.PHONE_HINT' | translate }}" /> <input cnslInput formControlName="phone" matTooltip="{{ 'USER.PROFILE.PHONE_HINT' | translate }}" />
</cnsl-form-field> </cnsl-form-field>
</div> </div>
<div class="phone-is-verified">
<mat-checkbox *ngIf="useV2Api$ | async" class="block-checkbox" formControlName="phoneVerified">
{{ 'USER.PROFILE.PHONE_VERIFIED' | translate }}
</mat-checkbox>
<mat-checkbox
*ngIf="(useV2Api$ | async) && !userForm.controls.phoneVerified.value"
class="block-checkbox"
formControlName="sendSms"
>
{{ 'USER.PROFILE.SEND_SMS' | translate }}
</mat-checkbox>
</div>
</div> </div>
<div class="user-create-btn-container"> <div class="user-create-btn-container">
<button <button

View File

@@ -26,6 +26,7 @@
} }
.email-is-verified, .email-is-verified,
.phone-is-verified,
.use-password-block { .use-password-block {
flex-basis: 100%; flex-basis: 100%;
margin-top: 1.5rem; margin-top: 1.5rem;

View File

@@ -1,10 +1,19 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, DestroyRef, ElementRef, OnInit, ViewChild } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; import { FormBuilder, FormControl, ValidatorFn } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Subject, debounceTime, Observable } from 'rxjs'; import {
import { AddHumanUserRequest } from 'src/app/proto/generated/zitadel/management_pb'; debounceTime,
import { Domain } from 'src/app/proto/generated/zitadel/org_pb'; defer,
of,
Observable,
shareReplay,
firstValueFrom,
forkJoin,
ObservedValueOf,
EMPTY,
ReplaySubject,
} from 'rxjs';
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb'; import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Gender } from 'src/app/proto/generated/zitadel/user_pb'; import { Gender } from 'src/app/proto/generated/zitadel/user_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
@@ -23,249 +32,354 @@ import {
passwordConfirmValidator, passwordConfirmValidator,
phoneValidator, phoneValidator,
requiredValidator, requiredValidator,
} from '../../../modules/form-field/validators/validators'; } from 'src/app/modules/form-field/validators/validators';
import { LanguagesService } from '../../../services/languages.service'; import { LanguagesService } from 'src/app/services/languages.service';
import { UserService } from 'src/app/services/user.service';
import { AddHumanUserRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { AddHumanUserRequestSchema } from '@zitadel/proto/zitadel/user/v2/user_service_pb';
import { create } from '@bufbuild/protobuf';
import { SetHumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_pb';
import { PasswordSchema } from '@zitadel/proto/zitadel/user/v2/password_pb';
import { SetHumanEmailSchema } from '@zitadel/proto/zitadel/user/v2/email_pb';
import { FeatureService } from 'src/app/services/feature.service';
import { catchError, filter, map, startWith, timeout } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'cnsl-user-create', selector: 'cnsl-user-create',
templateUrl: './user-create.component.html', templateUrl: './user-create.component.html',
styleUrls: ['./user-create.component.scss'], styleUrls: ['./user-create.component.scss'],
}) })
export class UserCreateComponent implements OnInit, OnDestroy { export class UserCreateComponent implements OnInit {
public user: AddHumanUserRequest.AsObject = new AddHumanUserRequest().toObject(); public readonly genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
public selected: CountryPhoneCode | undefined = { public selected: CountryPhoneCode | undefined = {
countryCallingCode: '1', countryCallingCode: '1',
countryCode: 'US', countryCode: 'US',
countryName: 'United States of America', countryName: 'United States of America',
}; };
public countryPhoneCodes: CountryPhoneCode[] = []; public readonly countryPhoneCodes: CountryPhoneCode[];
public userForm!: UntypedFormGroup;
public pwdForm!: UntypedFormGroup;
private destroyed$: Subject<void> = new Subject();
public userLoginMustBeDomain: boolean = false; public loading = false;
public loading: boolean = false;
private readonly suffix$ = new ReplaySubject<HTMLSpanElement>(1);
@ViewChild('suffix') public set suffix(suffix: ElementRef<HTMLSpanElement>) {
this.suffix$.next(suffix.nativeElement);
}
@ViewChild('suffix') public suffix!: any;
private primaryDomain!: Domain.AsObject;
public usePassword: boolean = false; public usePassword: boolean = false;
public policy!: PasswordComplexityPolicy.AsObject; protected readonly useV2Api$: Observable<boolean>;
protected readonly envSuffix$: Observable<string>;
protected readonly userForm: ReturnType<typeof this.buildUserForm>;
protected readonly pwdForm$: ReturnType<typeof this.buildPwdForm>;
protected readonly passwordComplexityPolicy$: Observable<PasswordComplexityPolicy.AsObject>;
protected readonly suffixPadding$: Observable<string>;
constructor( constructor(
private router: Router, private readonly router: Router,
private toast: ToastService, private readonly toast: ToastService,
private fb: UntypedFormBuilder, private readonly fb: FormBuilder,
private mgmtService: ManagementService, private readonly mgmtService: ManagementService,
private changeDetRef: ChangeDetectorRef, private readonly userService: UserService,
private _location: Location, public readonly langSvc: LanguagesService,
private countryCallingCodesService: CountryCallingCodesService, private readonly featureService: FeatureService,
public langSvc: LanguagesService, private readonly destroyRef: DestroyRef,
breadcrumbService: BreadcrumbService, private readonly breadcrumbService: BreadcrumbService,
protected readonly location: Location,
countryCallingCodesService: CountryCallingCodesService,
) { ) {
breadcrumbService.setBreadcrumb([ this.useV2Api$ = this.getUseV2Api().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.envSuffix$ = this.getEnvSuffix();
this.suffixPadding$ = this.getSuffixPadding();
this.passwordComplexityPolicy$ = this.getPasswordComplexityPolicy().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.userForm = this.buildUserForm();
this.pwdForm$ = this.buildPwdForm(this.passwordComplexityPolicy$);
this.countryPhoneCodes = countryCallingCodesService.getCountryCallingCodes();
}
ngOnInit(): void {
// already start loading if we should use v2 api
this.useV2Api$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
this.watchPhoneChanges();
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({ new Breadcrumb({
type: BreadcrumbType.ORG, type: BreadcrumbType.ORG,
routerLink: ['/org'], routerLink: ['/org'],
}), }),
]); ]);
this.loading = true; }
this.loadOrg();
this.mgmtService private getUseV2Api(): Observable<boolean> {
.getDomainPolicy() return defer(() => this.featureService.getInstanceFeatures(true)).pipe(
.then((resp) => { map((features) => !!features.getConsoleUseV2UserApi()?.getEnabled()),
if (resp.policy?.userLoginMustBeDomain) { timeout(1000),
this.userLoginMustBeDomain = resp.policy.userLoginMustBeDomain; catchError(() => of(false)),
);
}
private getEnvSuffix() {
const domainPolicy$ = defer(() => this.mgmtService.getDomainPolicy());
const orgDomains$ = defer(() => this.mgmtService.listOrgDomains());
return forkJoin([domainPolicy$, orgDomains$]).pipe(
map(([policy, domains]) => {
const userLoginMustBeDomain = policy.policy?.userLoginMustBeDomain;
const primaryDomain = domains.resultList.find((resp) => resp.isPrimary);
if (userLoginMustBeDomain && primaryDomain) {
return `@${primaryDomain.domainName}`;
} else {
return '';
} }
this.initForm(); }),
this.loading = false; catchError(() => of('')),
this.changeDetRef.detectChanges(); );
})
.catch((error) => {
console.error(error);
this.initForm();
this.loading = false;
this.changeDetRef.detectChanges();
});
} }
public close(): void { private getSuffixPadding() {
this._location.back(); return this.suffix$.pipe(
map((suffix) => `${suffix.offsetWidth + 10}px`),
startWith('10px'),
);
} }
private async loadOrg(): Promise<void> { private getPasswordComplexityPolicy() {
const domains = await this.mgmtService.listOrgDomains(); return defer(() => this.mgmtService.getPasswordComplexityPolicy()).pipe(
const found = domains.resultList.find((resp) => resp.isPrimary); map(({ policy }) => policy),
if (found) { filter(Boolean),
this.primaryDomain = found; catchError((error) => {
} this.toast.showError(error);
return EMPTY;
}),
);
} }
private initForm(): void { public buildUserForm() {
this.userForm = this.fb.group({ return this.fb.group({
email: ['', [requiredValidator, emailValidator]], email: new FormControl('', { nonNullable: true, validators: [requiredValidator, emailValidator] }),
userName: ['', [requiredValidator, minLengthValidator(2)]], userName: new FormControl('', { nonNullable: true, validators: [requiredValidator, minLengthValidator(2)] }),
firstName: ['', requiredValidator], firstName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
lastName: ['', requiredValidator], lastName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
nickName: [''], nickName: new FormControl('', { nonNullable: true }),
gender: [], gender: new FormControl(Gender.GENDER_UNSPECIFIED, { nonNullable: true, validators: [requiredValidator] }),
preferredLanguage: [''], preferredLanguage: new FormControl('', { nonNullable: true }),
phone: ['', phoneValidator], phone: new FormControl('', { nonNullable: true, validators: [phoneValidator] }),
isVerified: [false, []], emailVerified: new FormControl(false, { nonNullable: true }),
sendEmail: new FormControl(true, { nonNullable: true }),
phoneVerified: new FormControl(false, { nonNullable: true }),
sendSms: new FormControl(true, { nonNullable: true }),
}); });
}
const validators: Validators[] = [requiredValidator]; public buildPwdForm(passwordComplexityPolicy$: Observable<PasswordComplexityPolicy.AsObject>) {
return passwordComplexityPolicy$.pipe(
this.mgmtService.getPasswordComplexityPolicy().then((data) => { map((policy) => {
if (data.policy) { const validators: [ValidatorFn] = [requiredValidator];
this.policy = data.policy; if (policy.minLength) {
validators.push(minLengthValidator(policy.minLength));
if (this.policy.minLength) {
validators.push(minLengthValidator(this.policy.minLength));
} }
if (this.policy.hasLowercase) { if (policy.hasLowercase) {
validators.push(containsLowerCaseValidator); validators.push(containsLowerCaseValidator);
} }
if (this.policy.hasUppercase) { if (policy.hasUppercase) {
validators.push(containsUpperCaseValidator); validators.push(containsUpperCaseValidator);
} }
if (this.policy.hasNumber) { if (policy.hasNumber) {
validators.push(containsNumberValidator); validators.push(containsNumberValidator);
} }
if (this.policy.hasSymbol) { if (policy.hasSymbol) {
validators.push(containsSymbolValidator); validators.push(containsSymbolValidator);
} }
const pwdValidators = [...validators] as ValidatorFn[]; return this.fb.group({
const confirmPwdValidators = [requiredValidator, passwordConfirmValidator()] as ValidatorFn[]; password: new FormControl('', { nonNullable: true, validators }),
confirmPassword: new FormControl('', {
this.pwdForm = this.fb.group({ nonNullable: true,
password: ['', pwdValidators], validators: [requiredValidator, passwordConfirmValidator()],
confirmPassword: ['', confirmPwdValidators], }),
}); });
} }),
}); );
}
this.phone?.valueChanges.pipe(debounceTime(200)).subscribe((value: string) => { private watchPhoneChanges(): void {
const phone = this.userForm.controls.phone;
phone.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((value) => {
const phoneNumber = formatPhone(value); const phoneNumber = formatPhone(value);
if (phoneNumber) { if (phoneNumber) {
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country); this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
this.phone?.setValue(phoneNumber.phone); phone.setValue(phoneNumber.phone);
} }
}); });
} }
public createUser(): void { public async createUser(pwdForm: ObservedValueOf<typeof this.pwdForm$>): Promise<void> {
this.user = this.userForm.value; if (await firstValueFrom(this.useV2Api$)) {
await this.createUserV2(pwdForm);
} else {
await this.createUserV1(pwdForm);
}
}
public async createUserV1(pwdForm: ObservedValueOf<typeof this.pwdForm$>): Promise<void> {
this.loading = true; this.loading = true;
const controls = this.userForm.controls;
const profileReq = new AddHumanUserRequest.Profile(); const profileReq = new AddHumanUserRequest.Profile();
profileReq.setFirstName(this.firstName?.value); profileReq.setFirstName(controls.firstName.value);
profileReq.setLastName(this.lastName?.value); profileReq.setLastName(controls.lastName.value);
profileReq.setNickName(this.nickName?.value); profileReq.setNickName(controls.nickName.value);
profileReq.setPreferredLanguage(this.preferredLanguage?.value); profileReq.setPreferredLanguage(controls.preferredLanguage.value);
profileReq.setGender(this.gender?.value); profileReq.setGender(controls.gender.value);
const humanReq = new AddHumanUserRequest(); const humanReq = new AddHumanUserRequest();
humanReq.setUserName(this.userName?.value); humanReq.setUserName(controls.userName.value);
humanReq.setProfile(profileReq); humanReq.setProfile(profileReq);
const emailreq = new AddHumanUserRequest.Email(); const emailreq = new AddHumanUserRequest.Email();
emailreq.setEmail(this.email?.value); emailreq.setEmail(controls.email.value);
emailreq.setIsEmailVerified(this.isVerified?.value); emailreq.setIsEmailVerified(controls.emailVerified.value);
humanReq.setEmail(emailreq); humanReq.setEmail(emailreq);
if (this.usePassword && this.password?.value) { if (this.usePassword) {
humanReq.setInitialPassword(this.password.value); humanReq.setInitialPassword(pwdForm.controls.password.value);
} }
if (this.phone && this.phone.value) { if (controls.phone.value) {
// Try to parse number and format it according to country // Try to parse number and format it according to country
const phoneNumber = formatPhone(this.phone.value); const phoneNumber = formatPhone(controls.phone.value);
if (phoneNumber) { if (phoneNumber) {
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country); this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
humanReq.setPhone(new AddHumanUserRequest.Phone().setPhone(phoneNumber.phone)); humanReq.setPhone(new AddHumanUserRequest.Phone().setPhone(phoneNumber.phone));
} }
} }
this.mgmtService try {
.addHumanUser(humanReq) const data = await this.mgmtService.addHumanUser(humanReq);
.then((data) => { this.toast.showInfo('USER.TOAST.CREATED', true);
this.loading = false; this.router.navigate(['users', data.userId], { queryParams: { new: true } }).then();
this.toast.showInfo('USER.TOAST.CREATED', true); } catch (error) {
this.router.navigate(['users', data.userId], { queryParams: { new: true } }); this.toast.showError(error);
}) } finally {
.catch((error) => { this.loading = false;
this.loading = false; }
this.toast.showError(error); }
public async createUserV2(pwdForm: ObservedValueOf<typeof this.pwdForm$>): Promise<void> {
this.loading = true;
const controls = this.userForm.controls;
const humanReq = create(AddHumanUserRequestSchema, {
username: controls.userName.value,
profile: {
givenName: controls.firstName.value,
familyName: controls.lastName.value,
nickName: controls.nickName.value,
preferredLanguage: controls.preferredLanguage.value,
// the enum numbers of v1 gender are the same as v2 gender
gender: controls.gender.value as unknown as any,
},
});
if (this.usePassword) {
const password = create(PasswordSchema, { password: pwdForm.controls.password.value });
humanReq.passwordType = { case: 'password', value: password };
}
if (controls.emailVerified.value) {
humanReq.email = create(SetHumanEmailSchema, {
email: controls.email.value,
verification: {
value: true,
case: 'isVerified',
},
}); });
} else {
if (controls.sendEmail.value) {
humanReq.email = create(SetHumanEmailSchema, {
email: controls.email.value,
verification: {
case: 'sendCode',
value: {},
},
});
} else {
humanReq.email = create(SetHumanEmailSchema, {
email: controls.email.value,
verification: {
value: false,
case: 'isVerified',
},
});
}
}
const phoneNumber = formatPhone(controls.phone.value);
if (phoneNumber) {
const country = phoneNumber.country;
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === country);
if (controls.phoneVerified.value) {
humanReq.phone = create(SetHumanPhoneSchema, {
phone: phoneNumber.phone,
verification: {
case: 'isVerified',
value: true,
},
});
} else {
if (controls.sendSms.value) {
humanReq.phone = create(SetHumanPhoneSchema, {
phone: phoneNumber.phone,
verification: {
case: 'sendCode',
value: {},
},
});
} else {
humanReq.phone = create(SetHumanPhoneSchema, {
phone: phoneNumber.phone,
verification: {
case: 'isVerified',
value: false,
},
});
}
}
}
try {
const data = await this.userService.addHumanUser(humanReq);
if (this.sendEmailAfterCreation) {
await this.userService.passwordReset({
userId: data.userId,
medium: {
case: 'sendLink',
value: {},
},
});
}
this.toast.showInfo('USER.TOAST.CREATED', true);
await this.router.navigate(['users', data.userId], { queryParams: { new: true } });
} catch (error) {
this.toast.showError(error);
} finally {
this.loading = false;
}
}
public get sendEmailAfterCreation() {
const controls = this.userForm.controls;
const sendEmailAfterCreationIsAOption = controls.emailVerified.value && !this.usePassword;
return sendEmailAfterCreationIsAOption && controls.sendEmail.value;
} }
public setCountryCallingCode(): void { public setCountryCallingCode(): void {
let value = (this.phone?.value as string) || ''; let value = this.userForm.controls.phone.value;
this.countryPhoneCodes.forEach((code) => (value = value.replace(`+${code.countryCallingCode}`, ''))); this.countryPhoneCodes.forEach((code) => (value = value.replace(`+${code.countryCallingCode}`, '')));
value = value.trim(); value = value.trim();
this.phone?.setValue('+' + this.selected?.countryCallingCode + ' ' + value);
}
ngOnInit(): void { this.userForm.controls.phone.setValue('+' + this.selected?.countryCallingCode + ' ' + value);
this.countryPhoneCodes = this.countryCallingCodesService.getCountryCallingCodes();
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
public get email(): AbstractControl | null {
return this.userForm.get('email');
}
public get isVerified(): AbstractControl | null {
return this.userForm.get('isVerified');
}
public get userName(): AbstractControl | null {
return this.userForm.get('userName');
}
public get firstName(): AbstractControl | null {
return this.userForm.get('firstName');
}
public get lastName(): AbstractControl | null {
return this.userForm.get('lastName');
}
public get nickName(): AbstractControl | null {
return this.userForm.get('nickName');
}
public get gender(): AbstractControl | null {
return this.userForm.get('gender');
}
public get preferredLanguage(): AbstractControl | null {
return this.userForm.get('preferredLanguage');
}
public get phone(): AbstractControl | null {
return this.userForm.get('phone');
}
public get password(): AbstractControl | null {
return this.pwdForm.get('password');
}
public get confirmPassword(): AbstractControl | null {
return this.pwdForm.get('confirmPassword');
}
public get envSuffix(): string {
if (this.userLoginMustBeDomain && this.primaryDomain?.domainName) {
return `@${this.primaryDomain.domainName}`;
} else {
return '';
}
}
public get suffixPadding(): string | undefined {
if (this.suffix?.nativeElement.offsetWidth) {
return `${(this.suffix.nativeElement as HTMLElement).offsetWidth + 10}px`;
} else {
return;
}
} }
public compareCountries(i1: CountryPhoneCode, i2: CountryPhoneCode) { public compareCountries(i1: CountryPhoneCode, i2: CountryPhoneCode) {

View File

@@ -1,182 +1,181 @@
<cnsl-top-view <ng-container *ngIf="user$ | async as userQuery">
title="{{ user && user.human ? user.human.profile?.displayName : user?.machine?.name }}" <cnsl-top-view
sub="{{ user?.preferredLoginName }}" title="{{ userName$ | async }}"
[isActive]="user?.state === UserState.USER_STATE_ACTIVE" sub="{{ user(userQuery)?.preferredLoginName }}"
[isInactive]="user?.state === UserState.USER_STATE_INACTIVE" [isActive]="user(userQuery)?.state === UserState.USER_STATE_ACTIVE"
stateTooltip="{{ 'USER.STATE.' + user?.state | translate }}" [isInactive]="user(userQuery)?.state === UserState.USER_STATE_INACTIVE"
[hasBackButton]="['org.read'] | hasRole | async" stateTooltip="{{ 'USER.STATE.' + user(userQuery)?.state | translate }}"
(backRouterLink)="(['/'])" [hasBackButton]="['org.read'] | hasRole | async"
> >
<span *ngIf="!loading && !user">{{ 'USER.PAGES.NOUSER' | translate }}</span> <span *ngIf="userQuery.state === 'notfound'">{{ 'USER.PAGES.NOUSER' | translate }}</span>
<cnsl-info-row topContent *ngIf="user" [user]="user" [loginPolicy]="loginPolicy"></cnsl-info-row> <cnsl-info-row
</cnsl-top-view> topContent
*ngIf="user(userQuery) as user"
[user]="user"
[loginPolicy]="(loginPolicy$ | async) ?? undefined"
></cnsl-info-row>
</cnsl-top-view>
<div *ngIf="loading" class="max-width-container"> <div *ngIf="(user$ | async)?.state === 'loading'" class="max-width-container">
<div class="user-spinner-wrapper"> <div class="user-spinner-wrapper">
<mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
</div> </div>
</div>
<div class="max-width-container"> <div class="max-width-container">
<cnsl-meta-layout> <cnsl-meta-layout *ngIf="user(userQuery) as user">
<cnsl-sidenav <cnsl-sidenav
[(ngModel)]="currentSetting" *ngIf="currentSetting$ | async as currentSetting"
[settingsList]="settingsList" [ngModel]="currentSetting"
queryParam="id" (ngModelChange)="goToSetting($event)"
(ngModelChange)="settingChanged()" [settingsList]="settingsList"
> queryParam="id"
<ng-container *ngIf="currentSetting === 'general'"> >
<cnsl-card <ng-container *ngIf="currentSetting === 'general' && humanUser(userQuery) as humanUser">
*ngIf="user && user.human && user.human.profile" <cnsl-card
class="app-card" *ngIf="humanUser.type.value.profile as profile"
title="{{ 'USER.PROFILE.TITLE' | translate }}" class="app-card"
> title="{{ 'USER.PROFILE.TITLE' | translate }}"
<cnsl-detail-form
[showEditImage]="true"
[preferredLoginName]="user.preferredLoginName"
[genders]="genders"
[languages]="(langSvc.supported$ | async) || []"
[username]="user.userName"
[user]="user.human"
[disabled]="false"
(changedLanguage)="changedLanguage($event)"
(changeUsernameClicked)="changeUsername()"
(submitData)="saveProfile($event)"
(avatarChanged)="refreshUser()"
> >
</cnsl-detail-form> <cnsl-detail-form
</cnsl-card> [preferredLoginName]="user.preferredLoginName"
[disabled]="false"
[genders]="genders"
[languages]="(langSvc.supported$ | async) || []"
[username]="user.userName"
[profile]="profile"
[showEditImage]="true"
(changedLanguage)="changedLanguage($event)"
(changeUsernameClicked)="changeUsername(user)"
(submitData)="saveProfile(user, $event)"
(avatarChanged)="refreshChanges$.emit()"
>
</cnsl-detail-form>
</cnsl-card>
<cnsl-card <cnsl-card
*ngIf="user" title="{{ 'USER.LOGINMETHODS.TITLE' | translate }}"
title="{{ 'USER.LOGINMETHODS.TITLE' | translate }}" description="{{ 'USER.LOGINMETHODS.DESCRIPTION' | translate }}"
description="{{ 'USER.LOGINMETHODS.DESCRIPTION' | translate }}"
>
<button
class="icon-button"
card-actions
mat-icon-button
(click)="refreshUser()"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
> >
<mat-icon class="icon">refresh</mat-icon> <button
</button> class="icon-button"
<cnsl-contact card-actions
*ngIf="user.human" mat-icon-button
[human]="user.human" (click)="refreshChanges$.emit()"
[username]="user.preferredLoginName" matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
[state]="user.state" >
[canWrite]="true" <mat-icon class="icon">refresh</mat-icon>
(editType)="openEditDialog($event)" </button>
(enteredPhoneCode)="enteredPhoneCode($event)" <cnsl-contact
(deletedPhone)="deletePhone()" [human]="humanUser.type.value"
(resendEmailVerification)="resendEmailVerification()" [username]="user.preferredLoginName"
(resendPhoneVerification)="resendPhoneVerification()" [canWrite]="true"
> (editType)="openEditDialog(humanUser, $event)"
</cnsl-contact> (enteredPhoneCode)="enteredPhoneCode($event)"
</cnsl-card> (deletedPhone)="deletePhone(user)"
(resendEmailVerification)="resendEmailVerification(user)"
(resendPhoneVerification)="resendPhoneVerification(user)"
>
</cnsl-contact>
</cnsl-card>
<ng-template cnslHasRole [hasRole]="['user.self.delete']"> <ng-template cnslHasRole [hasRole]="['user.self.delete']">
<cnsl-card title="{{ 'USER.PAGES.DELETEACCOUNT' | translate }}" [warn]="true"> <cnsl-card title="{{ 'USER.PAGES.DELETEACCOUNT' | translate }}" [warn]="true">
<p>{{ 'USER.PAGES.DELETEACCOUNT_DESC' | translate }}</p> <p>{{ 'USER.PAGES.DELETEACCOUNT_DESC' | translate }}</p>
<div class="delete-account-wrapper"> <div class="delete-account-wrapper">
<button color="warn" mat-raised-button (click)="deleteAccount()"> <button color="warn" mat-raised-button (click)="deleteUser(user)">
{{ 'USER.PAGES.DELETEACCOUNT_BTN' | translate }} {{ 'USER.PAGES.DELETEACCOUNT_BTN' | translate }}
</button> </button>
</div>
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting === 'idp'">
<cnsl-external-idps [userId]="user.id" [service]="grpcAuthService"></cnsl-external-idps>
</ng-container>
<ng-container *ngIf="currentSetting === 'security'">
<cnsl-card *ngIf="humanUser(userQuery) as humanUser" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
<div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PASSWORD.LABEL' | translate }}</span>
<span>*********</span>
<ng-content select="[pwdAction]"></ng-content>
</div>
<div class="right">
<a
matTooltip="{{ 'USER.PASSWORD.SET' | translate }}"
[routerLink]="['password']"
[queryParams]="{ username: humanUser.preferredLoginName }"
mat-icon-button
>
<i class="las la-pen"></i>
</a>
</div>
</div>
</div> </div>
</cnsl-card> </cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting === 'idp'"> <cnsl-auth-passwordless #mfaComponent></cnsl-auth-passwordless>
<cnsl-external-idps *ngIf="user && user.id" [userId]="user.id" [service]="userService"></cnsl-external-idps>
</ng-container>
<ng-container *ngIf="currentSetting === 'security'"> <cnsl-auth-user-mfa
<cnsl-card *ngIf="user && user.human" title="{{ 'USER.PASSWORD.TITLE' | translate }}"> [phoneVerified]="humanUser(userQuery)?.type?.value?.phone?.isPhoneVerified ?? false"
<div class="contact-method-col"> #mfaComponent
<div class="contact-method-row"> ></cnsl-auth-user-mfa>
<div class="left"> </ng-container>
<span class="label cnsl-secondary-text">{{ 'USER.PASSWORD.LABEL' | translate }}</span>
<span>*********</span>
<ng-content select="[pwdAction]"></ng-content> <ng-container *ngIf="currentSetting === 'grants'">
</div> <cnsl-card title="{{ 'GRANTS.USER.TITLE' | translate }}" description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}">
<cnsl-user-grants
[userId]="user.id"
[context]="USERGRANTCONTEXT"
[displayedColumns]="[
'org',
'projectId',
'type',
'creationDate',
'changeDate',
'state',
'roleNamesList',
'actions',
]"
[disableWrite]="(['user.grant.write$'] | hasRole | async) === false"
[disableDelete]="(['user.grant.delete$'] | hasRole | async) === false"
>
</cnsl-user-grants>
</cnsl-card>
</ng-container>
<div class="right"> <ng-container *ngIf="currentSetting === 'memberships'">
<a <cnsl-card
matTooltip="{{ 'USER.PASSWORD.SET' | translate }}" title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}"
[routerLink]="['password']" description="{{ 'USER.MEMBERSHIPS.DESCRIPTION' | translate }}"
[queryParams]="{ username: user.preferredLoginName }"
mat-icon-button
>
<i class="las la-pen"></i>
</a>
</div>
</div>
</div>
</cnsl-card>
<cnsl-auth-passwordless *ngIf="user" #mfaComponent></cnsl-auth-passwordless>
<cnsl-auth-user-mfa
[phoneVerified]="user.human?.phone?.isPhoneVerified ?? false"
*ngIf="user"
#mfaComponent
></cnsl-auth-user-mfa>
</ng-container>
<ng-container *ngIf="currentSetting === 'grants'">
<cnsl-card
*ngIf="user && user.id"
title="{{ 'GRANTS.USER.TITLE' | translate }}"
description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}"
>
<cnsl-user-grants
[userId]="user.id"
[context]="USERGRANTCONTEXT"
[displayedColumns]="[
'org',
'projectId',
'type',
'creationDate',
'changeDate',
'state',
'roleNamesList',
'actions',
]"
[disableWrite]="(['user.grant.write$'] | hasRole | async) === false"
[disableDelete]="(['user.grant.delete$'] | hasRole | async) === false"
> >
</cnsl-user-grants> <cnsl-memberships-table></cnsl-memberships-table>
</cnsl-card> </cnsl-card>
</ng-container> </ng-container>
<ng-container *ngIf="currentSetting === 'memberships'"> <ng-container *ngIf="currentSetting === 'metadata' && (metadata$ | async) as metadataQuery">
<cnsl-card <cnsl-metadata
*ngIf="user?.id" *ngIf="metadataQuery.state !== 'error'"
title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}" [metadata]="metadataQuery.value"
description="{{ 'USER.MEMBERSHIPS.DESCRIPTION' | translate }}" [description]="'DESCRIPTIONS.USERS.SELF.METADATA' | translate"
> [disabled]="(['user.write:' + user.id, 'user.write'] | hasRole | async) === false"
<cnsl-memberships-table></cnsl-memberships-table> (editClicked)="editMetadata(user, metadataQuery.value)"
</cnsl-card> (refresh)="refreshMetadata$.next(true)"
</ng-container> [loading]="metadataQuery.state === 'loading'"
></cnsl-metadata>
</ng-container>
</cnsl-sidenav>
<ng-container *ngIf="currentSetting === 'metadata'"> <div metainfo>
<cnsl-metadata <cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.MYUSER"> </cnsl-changes>
[metadata]="metadata" </div>
[description]="'DESCRIPTIONS.USERS.SELF.METADATA' | translate" </cnsl-meta-layout>
[disabled]="(['user.write:' + user.id, 'user.write'] | hasRole | async) === false" </div>
*ngIf="user && user.id" </ng-container>
(editClicked)="editMetadata()"
(refresh)="loadMetadata()"
></cnsl-metadata>
</ng-container>
</cnsl-sidenav>
<div metainfo>
<cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.MYUSER"> </cnsl-changes>
</div>
</cnsl-meta-layout>
</div>

View File

@@ -1,45 +1,73 @@
import { MediaMatcher } from '@angular/cdk/layout'; import { MediaMatcher } from '@angular/cdk/layout';
import { Location } from '@angular/common'; import { Component, DestroyRef, EventEmitter, OnInit } from '@angular/core';
import { Component, EventEmitter, OnDestroy } from '@angular/core';
import { Validators } from '@angular/forms'; import { Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { from, Observable, Subscription, take } from 'rxjs'; import {
combineLatestWith,
defer,
EMPTY,
fromEvent,
mergeWith,
Observable,
of,
shareReplay,
Subject,
switchMap,
take,
} from 'rxjs';
import { ChangeType } from 'src/app/modules/changes/changes.component'; import { ChangeType } from 'src/app/modules/changes/changes.component';
import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component'; import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component';
import { MetadataDialogComponent } from 'src/app/modules/metadata/metadata-dialog/metadata-dialog.component'; import {
MetadataDialogComponent,
MetadataDialogData,
} from 'src/app/modules/metadata/metadata-dialog/metadata-dialog.component';
import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum'; import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component'; import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource'; import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Email, Gender, Phone, Profile, User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { AuthenticationService } from 'src/app/services/authentication.service'; import { AuthenticationService } from 'src/app/services/authentication.service';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { formatPhone } from 'src/app/utils/formatPhone'; import { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogType } from './edit-dialog/edit-dialog.component'; import { EditDialogComponent, EditDialogData, EditDialogResult, EditDialogType } from './edit-dialog/edit-dialog.component';
import { LanguagesService } from '../../../../services/languages.service'; import { LanguagesService } from 'src/app/services/languages.service';
import { Gender, HumanProfile } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { catchError, filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators';
import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith';
import { NewAuthService } from 'src/app/services/new-auth.service';
import { Human, User, UserState } from '@zitadel/proto/zitadel/user_pb';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { Metadata } from '@zitadel/proto/zitadel/metadata_pb';
import { UserService } from 'src/app/services/user.service';
import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { query } from '@angular/animations';
type UserQuery =
| { state: 'success'; value: User }
| { state: 'error'; value: string }
| { state: 'loading'; value?: User }
| { state: 'notfound' };
type MetadataQuery =
| { state: 'success'; value: Metadata[] }
| { state: 'loading'; value: Metadata[] }
| { state: 'error'; value: string };
type UserWithHumanType = Omit<User, 'type'> & { type: { case: 'human'; value: Human } };
@Component({ @Component({
selector: 'cnsl-auth-user-detail', selector: 'cnsl-auth-user-detail',
templateUrl: './auth-user-detail.component.html', templateUrl: './auth-user-detail.component.html',
styleUrls: ['./auth-user-detail.component.scss'], styleUrls: ['./auth-user-detail.component.scss'],
}) })
export class AuthUserDetailComponent implements OnDestroy { export class AuthUserDetailComponent implements OnInit {
public user?: User.AsObject; public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE];
public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE];
private subscription: Subscription = new Subscription();
public loading: boolean = false;
public loadingMetadata: boolean = false;
public ChangeType: any = ChangeType; public ChangeType: any = ChangeType;
public userLoginMustBeDomain: boolean = false; public userLoginMustBeDomain: boolean = false;
@@ -47,8 +75,7 @@ export class AuthUserDetailComponent implements OnDestroy {
public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER;
public refreshChanges$: EventEmitter<void> = new EventEmitter(); public refreshChanges$: EventEmitter<void> = new EventEmitter();
public refreshMetadata$ = new Subject<true>();
public metadata: Metadata.AsObject[] = [];
public settingsList: SidenavSetting[] = [ public settingsList: SidenavSetting[] = [
{ id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }, { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' },
@@ -62,170 +89,218 @@ export class AuthUserDetailComponent implements OnDestroy {
requiredRoles: { [PolicyComponentServiceType.MGMT]: ['user.read'] }, requiredRoles: { [PolicyComponentServiceType.MGMT]: ['user.read'] },
}, },
]; ];
public currentSetting: string | undefined = this.settingsList[0].id; protected readonly user$: Observable<UserQuery>;
public loginPolicy?: LoginPolicy.AsObject; protected readonly metadata$: Observable<MetadataQuery>;
private savedLanguage?: string; private readonly savedLanguage$: Observable<string>;
protected currentSetting$: Observable<string | undefined>;
public loginPolicy$: Observable<LoginPolicy>;
protected userName$: Observable<string>;
constructor( constructor(
public translate: TranslateService, public translate: TranslateService,
private toast: ToastService, private toast: ToastService,
public userService: GrpcAuthService, public grpcAuthService: GrpcAuthService,
private dialog: MatDialog, private dialog: MatDialog,
private auth: AuthenticationService, private auth: AuthenticationService,
private mgmt: ManagementService,
private breadcrumbService: BreadcrumbService, private breadcrumbService: BreadcrumbService,
private mediaMatcher: MediaMatcher, private mediaMatcher: MediaMatcher,
private _location: Location,
activatedRoute: ActivatedRoute,
public langSvc: LanguagesService, public langSvc: LanguagesService,
private readonly route: ActivatedRoute,
private readonly newAuthService: NewAuthService,
private readonly newMgmtService: NewMgmtService,
private readonly userService: UserService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
) { ) {
activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => { this.currentSetting$ = this.getCurrentSetting$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const { id } = params; this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
if (id) { this.userName$ = this.getUserName(this.user$);
this.cleanupTranslation(); this.savedLanguage$ = this.getSavedLanguage$(this.user$);
this.currentSetting = id; this.metadata$ = this.getMetadata$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
});
const mediaq: string = '(max-width: 500px)'; this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
const small = this.mediaMatcher.matchMedia(mediaq).matches; catchError(() => EMPTY),
if (small) { map(({ policy }) => policy),
this.changeSelection(small); filter(Boolean),
} );
this.mediaMatcher.matchMedia(mediaq).onchange = (small) => {
this.changeSelection(small.matches);
};
this.loading = true;
this.refreshUser();
this.userService.getMyLoginPolicy().then((policy) => {
if (policy.policy) {
this.loginPolicy = policy.policy;
}
});
} }
private changeSelection(small: boolean): void { getUserName(user$: Observable<UserQuery>) {
this.cleanupTranslation(); return user$.pipe(
if (small) { map((query) => {
this.currentSetting = undefined; const user = this.user(query);
} else { if (!user) {
this.currentSetting = this.currentSetting === undefined ? this.settingsList[0].id : this.currentSetting; return '';
}
}
public navigateBack(): void {
this._location.back();
}
refreshUser(): void {
this.refreshChanges$.emit();
this.userService
.getMyUser()
.then((resp) => {
if (resp.user) {
this.user = resp.user;
this.loadMetadata();
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: this.user.human?.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
} }
this.savedLanguage = resp.user?.human?.profile?.preferredLanguage; if (user.type.case === 'human') {
this.loading = false; return user.type.value.profile?.displayName ?? '';
}) }
.catch((error) => { if (user.type.case === 'machine') {
this.toast.showError(error); return user.type.value.name;
this.loading = false; }
}); return '';
}),
);
} }
public ngOnDestroy(): void { getSavedLanguage$(user$: Observable<UserQuery>) {
this.cleanupTranslation(); return user$.pipe(
this.subscription.unsubscribe(); switchMap((query) => {
if (query.state !== 'success' || query.value.type.case !== 'human') {
return EMPTY;
}
return query.value.type.value.profile?.preferredLanguage ?? EMPTY;
}),
startWith(this.translate.defaultLang),
);
} }
public settingChanged(): void { ngOnInit(): void {
this.cleanupTranslation(); this.user$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if ((query.state === 'loading' || query.state === 'success') && query.value?.type.case === 'human') {
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: query.value.type.value.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
}
});
this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if (query.state == 'error') {
this.toast.showError(query.value);
}
});
this.savedLanguage$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((savedLanguage) => this.translate.use(savedLanguage));
} }
private cleanupTranslation(): void { private getCurrentSetting$(): Observable<string | undefined> {
if (this?.savedLanguage) { const mediaq: string = '(max-width: 500px)';
this.translate.use(this?.savedLanguage); const matcher = this.mediaMatcher.matchMedia(mediaq);
} else { const small$ = fromEvent(matcher, 'change', ({ matches }: MediaQueryListEvent) => matches).pipe(
this.translate.use(this.translate.defaultLang); startWith(matcher.matches),
} );
return this.route.queryParamMap.pipe(
map((params) => params.get('id')),
filter(Boolean),
startWith('general'),
withLatestFrom(small$),
map(([id, small]) => (small ? undefined : id)),
);
} }
public changeUsername(): void { private getUser$(): Observable<UserQuery> {
const dialogRef = this.dialog.open(EditDialogComponent, { return this.refreshChanges$.pipe(
data: { startWith(true),
confirmKey: 'ACTIONS.CHANGE', switchMap(() => this.getMyUser()),
cancelKey: 'ACTIONS.CANCEL', pairwiseStartWith(undefined),
labelKey: 'ACTIONS.NEWVALUE', map(([prev, curr]) => {
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE', if (prev?.state === 'success' && curr.state === 'loading') {
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC', return { state: 'loading', value: prev.value } as const;
value: this.user?.userName, }
}, return curr;
}),
);
}
private getMyUser(): Observable<UserQuery> {
return defer(() => this.newAuthService.getMyUser()).pipe(
map(({ user }) => {
if (user) {
return { state: 'success', value: user } as const;
}
return { state: 'notfound' } as const;
}),
catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)),
startWith({ state: 'loading' } as const),
);
}
getMetadata$(user$: Observable<UserQuery>): Observable<MetadataQuery> {
return this.refreshMetadata$.pipe(
startWith(true),
combineLatestWith(user$),
switchMap(([_, user]) => {
if (!(user.state === 'success' || user.state === 'loading')) {
return EMPTY;
}
if (!user.value) {
return EMPTY;
}
return this.getMetadataById(user.value.id);
}),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { state: 'loading', value: prev.value } as const;
}
return curr;
}),
);
}
private getMetadataById(userId: string): Observable<MetadataQuery> {
return defer(() => this.newMgmtService.listUserMetadata(userId)).pipe(
map((metadata) => ({ state: 'success', value: metadata.result }) as const),
startWith({ state: 'loading', value: [] as Metadata[] } as const),
catchError((err) => of({ state: 'error', value: err.message ?? '' } as const)),
);
}
public changeUsername(user: User): void {
const data = {
confirmKey: 'ACTIONS.CHANGE' as const,
cancelKey: 'ACTIONS.CANCEL' as const,
labelKey: 'ACTIONS.NEWVALUE' as const,
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE' as const,
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC' as const,
value: user.userName,
};
const dialogRef = this.dialog.open<EditDialogComponent, typeof data, EditDialogResult>(EditDialogComponent, {
data,
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp: { value: string }) => { dialogRef
if (resp && resp.value && resp.value !== this.user?.userName) { .afterClosed()
this.userService .pipe(
.updateMyUserName(resp.value) map((value) => value?.value),
.then(() => { filter(Boolean),
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true); filter((value) => user.userName != value),
this.refreshUser(); switchMap((username) => this.userService.updateUser({ userId: user.id, username })),
}) )
.catch((error) => { .subscribe({
this.toast.showError(error); next: () => {
}); this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
}
});
}
public saveProfile(profileData: Profile.AsObject): void {
if (this.user?.human) {
this.user.human.profile = profileData;
this.userService
.updateMyProfile(
this.user.human.profile?.firstName,
this.user.human.profile?.lastName,
this.user.human.profile?.nickName,
this.user.human.profile?.displayName,
this.user.human.profile?.preferredLanguage,
this.user.human.profile?.gender,
)
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.savedLanguage = this.user?.human?.profile?.preferredLanguage;
this.refreshChanges$.emit(); this.refreshChanges$.emit();
}) },
.catch((error) => { error: (error) => {
this.toast.showError(error); this.toast.showError(error);
}); },
} });
} }
public saveEmail(email: string): void { public saveProfile(user: User, profile: HumanProfile): void {
this.userService this.userService
.setMyEmail(email) .updateUser({
userId: user.id,
profile: {
givenName: profile.givenName,
familyName: profile.familyName,
nickName: profile.nickName,
displayName: profile.displayName,
preferredLanguage: profile.preferredLanguage,
gender: profile.gender,
},
})
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true); this.toast.showInfo('USER.TOAST.SAVED', true);
if (this.user?.human) { this.refreshChanges$.emit();
const mailToSet = new Email();
mailToSet.setEmail(email);
this.user.human.email = mailToSet.toObject();
this.refreshUser();
}
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
@@ -233,11 +308,11 @@ export class AuthUserDetailComponent implements OnDestroy {
} }
public enteredPhoneCode(code: string): void { public enteredPhoneCode(code: string): void {
this.userService this.newAuthService
.verifyMyPhone(code) .verifyMyPhone(code)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true); this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshUser(); this.refreshChanges$.emit();
this.promptSetupforSMSOTP(); this.promptSetupforSMSOTP();
}) })
.catch((error) => { .catch((error) => {
@@ -256,39 +331,26 @@ export class AuthUserDetailComponent implements OnDestroy {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp) { .afterClosed()
this.userService.addMyAuthFactorOTPSMS().then(() => { .pipe(
this.translate filter(Boolean),
.get('USER.MFA.OTPSMSSUCCESS') switchMap(() => this.newAuthService.addMyAuthFactorOTPSMS()),
.pipe(take(1)) switchMap(() => this.translate.get('USER.MFA.OTPSMSSUCCESS').pipe(take(1))),
.subscribe((msg) => { )
this.toast.showInfo(msg); .subscribe({
}); next: (msg) => this.toast.showInfo(msg),
}); error: (err) => this.toast.showError(err),
} });
});
} }
public changedLanguage(language: string): void { public changedLanguage(language: string): void {
this.translate.use(language); this.translate.use(language);
} }
public resendPhoneVerification(): void { public resendEmailVerification(user: User): void {
this.userService this.newMgmtService
.resendMyPhoneVerification() .resendHumanEmailVerification(user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public resendEmailVerification(): void {
this.userService
.resendMyEmailVerification()
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@@ -298,161 +360,187 @@ export class AuthUserDetailComponent implements OnDestroy {
}); });
} }
public deletePhone(): void { public resendPhoneVerification(user: User): void {
this.userService this.newMgmtService
.removeMyPhone() .resendHumanPhoneVerification(user.id)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
if (this.user?.human?.phone) { this.refreshChanges$.emit();
const phone = new Phone();
this.user.human.phone = phone.toObject();
this.refreshUser();
}
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public savePhone(phone: string): void { public deletePhone(user: User): void {
if (this.user?.human) { this.userService
// Format phone before save (add +) .removePhone(user.id)
const formattedPhone = formatPhone(phone); .then(() => {
if (formattedPhone) { this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
phone = formattedPhone.phone; this.refreshChanges$.emit();
} })
.catch((error) => {
this.userService this.toast.showError(error);
.setMyPhone(phone) });
.then(() => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
if (this.user?.human) {
const phoneToSet = new Phone();
phoneToSet.setPhone(phone);
this.user.human.phone = phoneToSet.toObject();
this.refreshUser();
}
})
.catch((error) => {
this.toast.showError(error);
});
}
} }
public openEditDialog(type: EditDialogType): void { public openEditDialog(user: UserWithHumanType, type: EditDialogType): void {
switch (type) { switch (type) {
case EditDialogType.PHONE: case EditDialogType.PHONE:
const dialogRefPhone = this.dialog.open(EditDialogComponent, { this.openEditPhoneDialog(user);
data: { return;
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'USER.LOGINMETHODS.PHONE.EDITVALUE',
titleKey: 'USER.LOGINMETHODS.PHONE.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.PHONE.EDITDESC',
value: this.user?.human?.phone?.phone,
type: type,
validator: Validators.compose([phoneValidator, requiredValidator]),
},
width: '400px',
});
dialogRefPhone.afterClosed().subscribe((resp: { value: string; isVerified: boolean }) => {
if (resp && resp.value) {
this.savePhone(resp.value);
}
});
break;
case EditDialogType.EMAIL: case EditDialogType.EMAIL:
const dialogRefEmail = this.dialog.open(EditDialogComponent, { this.openEditEmailDialog(user);
data: { return;
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.EMAIL.EDITDESC',
value: this.user?.human?.email?.email,
type: type,
},
width: '400px',
});
dialogRefEmail.afterClosed().subscribe((resp: { value: string; isVerified: boolean }) => {
if (resp && resp.value) {
this.saveEmail(resp.value);
}
});
break;
} }
} }
public deleteAccount(): void { private openEditEmailDialog(user: UserWithHumanType) {
const dialogRef = this.dialog.open(WarnDialogComponent, { const data: EditDialogData = {
data: { confirmKey: 'ACTIONS.SAVE',
confirmKey: 'USER.DIALOG.DELETE_BTN', cancelKey: 'ACTIONS.CANCEL',
cancelKey: 'ACTIONS.CANCEL', labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.DIALOG.DELETE_TITLE', titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE',
descriptionKey: 'USER.DIALOG.DELETE_AUTH_DESCRIPTION', descriptionKey: 'USER.LOGINMETHODS.EMAIL.EDITDESC',
}, value: user.type.value?.email?.email,
type: EditDialogType.EMAIL,
} as const;
const dialogRefEmail = this.dialog.open<EditDialogComponent, EditDialogData, EditDialogResult>(EditDialogComponent, {
data,
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { dialogRefEmail
if (resp) { .afterClosed()
this.userService .pipe(
.RemoveMyUser() filter((resp): resp is Required<EditDialogResult> => !!resp?.value),
.then(() => { switchMap(({ value, isVerified }) =>
this.toast.showInfo('USER.PAGES.DELETEACCOUNT_SUCCESS', true); this.userService.setEmail({
this.auth.signout(); userId: user.id,
}) email: value,
.catch((error) => { verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined },
this.toast.showError(error); }),
}); ),
} )
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true);
this.refreshChanges$.emit();
},
error: (error) => this.toast.showError(error),
});
}
private openEditPhoneDialog(user: UserWithHumanType) {
const data = {
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.PHONE.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.PHONE.EDITDESC',
value: user.type.value.phone?.phone,
type: EditDialogType.PHONE,
validator: Validators.compose([phoneValidator, requiredValidator]),
};
const dialogRefPhone = this.dialog.open<EditDialogComponent, typeof data, { value: string; isVerified: boolean }>(
EditDialogComponent,
{ data, width: '400px' },
);
dialogRefPhone
.afterClosed()
.pipe(
map((resp) => formatPhone(resp?.value)),
filter(Boolean),
switchMap(({ phone }) => this.userService.setPhone({ userId: user.id, phone })),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshChanges$.emit();
},
error: (error) => {
this.toast.showError(error);
},
});
}
public deleteUser(user: User): void {
const data = {
confirmKey: 'USER.DIALOG.DELETE_BTN',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.DIALOG.DELETE_TITLE',
descriptionKey: 'USER.DIALOG.DELETE_AUTH_DESCRIPTION',
};
const dialogRef = this.dialog.open<WarnDialogComponent, typeof data, boolean>(WarnDialogComponent, {
width: '400px',
});
dialogRef
.afterClosed()
.pipe(
filter(Boolean),
switchMap(() => this.userService.deleteUser(user.id)),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.PAGES.DELETEACCOUNT_SUCCESS', true);
this.auth.signout();
},
error: (error) => this.toast.showError(error),
});
}
public editMetadata(user: User, metadata: Metadata[]): void {
const setFcn = (key: string, value: string) =>
this.newMgmtService.setUserMetadata({
key,
value: Buffer.from(value),
id: user.id,
});
const removeFcn = (key: string): Promise<any> => this.newMgmtService.removeUserMetadata({ key, id: user.id });
const dialogRef = this.dialog.open<MetadataDialogComponent, MetadataDialogData>(MetadataDialogComponent, {
data: {
metadata: [...metadata],
setFcn: setFcn,
removeFcn: removeFcn,
},
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.refreshMetadata$.next(true);
});
}
protected readonly query = query;
protected user(user: UserQuery): User | undefined {
if (user.state === 'success' || user.state === 'loading') {
return user.value;
}
return;
}
public async goToSetting(setting: string) {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: { id: setting },
queryParamsHandling: 'merge',
skipLocationChange: true,
}); });
} }
public loadMetadata(): void { public humanUser(userQuery: UserQuery): UserWithHumanType | undefined {
if (this.user) { const user = this.user(userQuery);
this.userService.isAllowed(['user.read']).subscribe((allowed) => { if (user?.type.case === 'human') {
if (allowed) { return { ...user, type: user.type };
this.loadingMetadata = true;
this.mgmt
.listUserMetadata(this.user?.id ?? '')
.then((resp) => {
this.loadingMetadata = false;
this.metadata = resp.resultList.map((md) => {
return {
key: md.key,
value: Buffer.from(md.value as string, 'base64').toString('utf8'),
};
});
})
.catch((error) => {
this.loadingMetadata = false;
this.toast.showError(error);
});
}
});
}
}
public editMetadata(): void {
if (this.user && this.user.id) {
const setFcn = (key: string, value: string): Promise<any> =>
this.mgmt.setUserMetadata(key, Buffer.from(value).toString('base64'), this.user?.id ?? '');
const removeFcn = (key: string): Promise<any> => this.mgmt.removeUserMetadata(key, this.user?.id ?? '');
const dialogRef = this.dialog.open(MetadataDialogComponent, {
data: {
metadata: this.metadata,
setFcn: setFcn,
removeFcn: removeFcn,
},
});
dialogRef.afterClosed().subscribe(() => {
this.loadMetadata();
});
} }
return;
} }
} }

View File

@@ -47,7 +47,7 @@
{{ data.isVerifiedTextKey | translate }} {{ data.isVerifiedTextKey | translate }}
</mat-checkbox> </mat-checkbox>
<cnsl-info-section class="full-width desc"> <cnsl-info-section class="full-width desc">
<span>{{ data.isVerifiedTextDescKey | translate }}</span> <span>{{ data.isVerifiedTextDescKey ?? '' | translate }}</span>
</cnsl-info-section> </cnsl-info-section>
</ng-container> </ng-container>
</form> </form>

View File

@@ -1,5 +1,5 @@
import { Component, Inject, OnInit } from '@angular/core'; import { Component, Inject, OnInit } from '@angular/core';
import { FormGroup, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { FormGroup, UntypedFormControl, UntypedFormGroup, ValidatorFn } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { debounceTime } from 'rxjs'; import { debounceTime } from 'rxjs';
import { requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { requiredValidator } from 'src/app/modules/form-field/validators/validators';
@@ -11,6 +11,24 @@ export enum EditDialogType {
EMAIL = 2, EMAIL = 2,
} }
export type EditDialogData = {
confirmKey: 'ACTIONS.SAVE' | 'ACTIONS.CHANGE';
cancelKey: 'ACTIONS.CANCEL';
labelKey: 'ACTIONS.NEWVALUE';
titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE';
descriptionKey: 'USER.LOGINMETHODS.EMAIL.EDITDESC';
isVerifiedTextKey?: 'USER.LOGINMETHODS.EMAIL.ISVERIFIED';
isVerifiedTextDescKey?: 'USER.LOGINMETHODS.EMAIL.ISVERIFIEDDESC';
value: string | undefined;
type: EditDialogType;
validator?: ValidatorFn;
};
export type EditDialogResult = {
value?: string;
isVerified: boolean;
};
@Component({ @Component({
selector: 'cnsl-edit-dialog', selector: 'cnsl-edit-dialog',
templateUrl: './edit-dialog.component.html', templateUrl: './edit-dialog.component.html',
@@ -31,7 +49,7 @@ export class EditDialogComponent implements OnInit {
public countryPhoneCodes: CountryPhoneCode[] = []; public countryPhoneCodes: CountryPhoneCode[] = [];
constructor( constructor(
public dialogRef: MatDialogRef<EditDialogComponent>, public dialogRef: MatDialogRef<EditDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: EditDialogData,
private countryCallingCodesService: CountryCallingCodesService, private countryCallingCodesService: CountryCallingCodesService,
) { ) {
if (data.type === EditDialogType.PHONE) { if (data.type === EditDialogType.PHONE) {

View File

@@ -1,5 +1,5 @@
<h1 mat-dialog-title> <h1 mat-dialog-title>
<span class="title">{{ 'USER.SENDEMAILDIALOG.TITLE' | translate }} {{ data?.number }}</span> <span class="title">{{ 'USER.SENDEMAILDIALOG.TITLE' | translate }}</span>
</h1> </h1>
<p class="desc cnsl-secondary-text">{{ 'USER.SENDEMAILDIALOG.DESCRIPTION' | translate }}</p> <p class="desc cnsl-secondary-text">{{ 'USER.SENDEMAILDIALOG.DESCRIPTION' | translate }}</p>
<div mat-dialog-content> <div mat-dialog-content>

View File

@@ -1,6 +1,12 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
export type ResendEmailDialogData = {
email: string | '';
};
export type ResendEmailDialogResult = { send: true; email: string } | { send: false };
@Component({ @Component({
selector: 'cnsl-resend-email-dialog', selector: 'cnsl-resend-email-dialog',
templateUrl: './resend-email-dialog.component.html', templateUrl: './resend-email-dialog.component.html',
@@ -9,16 +15,16 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
export class ResendEmailDialogComponent { export class ResendEmailDialogComponent {
public email: string = ''; public email: string = '';
constructor( constructor(
public dialogRef: MatDialogRef<ResendEmailDialogComponent>, public dialogRef: MatDialogRef<ResendEmailDialogComponent, ResendEmailDialogResult>,
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: ResendEmailDialogData,
) { ) {
if (data.email) { if (data.email) {
this.email = data.email; this.email = data.email;
} }
} }
closeDialog(email: string = ''): void { closeDialog(): void {
this.dialogRef.close(email); this.dialogRef.close({ send: false });
} }
closeDialogWithSend(email: string = ''): void { closeDialogWithSend(email: string = ''): void {

View File

@@ -1,10 +1,10 @@
<div class="contact-method-col" *ngIf="human"> <div class="contact-method-col">
<div class="contact-method-row"> <div class="contact-method-row">
<div class="left"> <div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.EMAIL' | translate }}</span> <span class="label cnsl-secondary-text">{{ 'USER.EMAIL' | translate }}</span>
<span class="name">{{ human.email?.email }}</span> <span class="name">{{ human.email?.email }}</span>
<span *ngIf="human.email?.isEmailVerified" class="contact-state verified">{{ 'USER.EMAILVERIFIED' | translate }}</span> <span *ngIf="isEmailVerified" class="contact-state verified">{{ 'USER.EMAILVERIFIED' | translate }}</span>
<div *ngIf="!human.email?.isEmailVerified" class="block"> <div *ngIf="!isEmailVerified" class="block">
<span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span> <span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span>
<ng-container *ngIf="human.email"> <ng-container *ngIf="human.email">
@@ -37,8 +37,8 @@
<div class="left"> <div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PHONE' | translate }}</span> <span class="label cnsl-secondary-text">{{ 'USER.PHONE' | translate }}</span>
<cnsl-phone-detail [phone]="human.phone?.phone"></cnsl-phone-detail> <cnsl-phone-detail [phone]="human.phone?.phone"></cnsl-phone-detail>
<span *ngIf="human.phone?.isPhoneVerified" class="contact-state verified">{{ 'USER.PHONEVERIFIED' | translate }}</span> <span *ngIf="isPhoneVerified" class="contact-state verified">{{ 'USER.PHONEVERIFIED' | translate }}</span>
<div *ngIf="human.phone?.phone && !human.phone?.isPhoneVerified" class="block"> <div *ngIf="human.phone?.phone && !isPhoneVerified" class="block">
<span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span> <span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span>
<ng-container *ngIf="human.phone?.phone"> <ng-container *ngIf="human.phone?.phone">

View File

@@ -1,11 +1,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Human, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { CodeDialogComponent } from '../auth-user-detail/code-dialog/code-dialog.component'; import { CodeDialogComponent } from '../auth-user-detail/code-dialog/code-dialog.component';
import { EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component'; import { EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component';
import { HumanUser, UserState } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { Human } from '@zitadel/proto/zitadel/user_pb';
@Component({ @Component({
selector: 'cnsl-contact', selector: 'cnsl-contact',
@@ -15,15 +16,14 @@ import { EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.comp
export class ContactComponent { export class ContactComponent {
@Input() disablePhoneCode: boolean = false; @Input() disablePhoneCode: boolean = false;
@Input() canWrite: boolean | null = false; @Input() canWrite: boolean | null = false;
@Input() human?: Human.AsObject; @Input({ required: true }) human!: HumanUser | Human;
@Input() username: string = ''; @Input() username: string = '';
@Input() state!: UserState;
@Output() editType: EventEmitter<EditDialogType> = new EventEmitter<EditDialogType>(); @Output() editType: EventEmitter<EditDialogType> = new EventEmitter<EditDialogType>();
@Output() resendEmailVerification: EventEmitter<void> = new EventEmitter<void>(); @Output() resendEmailVerification: EventEmitter<void> = new EventEmitter<void>();
@Output() resendPhoneVerification: EventEmitter<void> = new EventEmitter<void>(); @Output() resendPhoneVerification: EventEmitter<void> = new EventEmitter<void>();
@Output() enteredPhoneCode: EventEmitter<string> = new EventEmitter<string>(); @Output() enteredPhoneCode: EventEmitter<string> = new EventEmitter<string>();
@Output() deletedPhone: EventEmitter<void> = new EventEmitter<void>(); @Output() deletedPhone: EventEmitter<void> = new EventEmitter<void>();
public UserState: any = UserState; public UserState = UserState;
public EditDialogType: any = EditDialogType; public EditDialogType: any = EditDialogType;
constructor( constructor(
@@ -81,4 +81,18 @@ export class ContactComponent {
public openEditDialog(type: EditDialogType): void { public openEditDialog(type: EditDialogType): void {
this.editType.emit(type); this.editType.emit(type);
} }
protected get isPhoneVerified() {
if (this.human.$typeName === 'zitadel.user.v2.HumanUser') {
return !!this.human.phone?.isVerified;
}
return this.human.phone?.isPhoneVerified;
}
protected get isEmailVerified() {
if (this.human.$typeName === 'zitadel.user.v2.HumanUser') {
return !!this.human.email?.isVerified;
}
return this.human.email?.isEmailVerified;
}
} }

View File

@@ -1,4 +1,4 @@
<form [formGroup]="machineForm" *ngIf="machineForm" (ngSubmit)="submitForm()"> <form [formGroup]="machineForm" (ngSubmit)="submitForm()">
<div class="content"> <div class="content">
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.MACHINE.USERNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.MACHINE.USERNAME' | translate }}</cnsl-label>

View File

@@ -1,55 +1,77 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
import { Subscription } from 'rxjs'; import { combineLatestWith, distinctUntilChanged, ReplaySubject } from 'rxjs';
import { requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { AccessTokenType, Human, Machine } from 'src/app/proto/generated/zitadel/user_pb'; import { AccessTokenType, MachineUser } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { startWith } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'cnsl-detail-form-machine', selector: 'cnsl-detail-form-machine',
templateUrl: './detail-form-machine.component.html', templateUrl: './detail-form-machine.component.html',
styleUrls: ['./detail-form-machine.component.scss'], styleUrls: ['./detail-form-machine.component.scss'],
}) })
export class DetailFormMachineComponent implements OnInit, OnDestroy { export class DetailFormMachineComponent {
@Input() public username!: string; @Input({ required: true }) public set username(username: string) {
@Input() public user!: Human.AsObject | Machine.AsObject; this.username$.next(username);
@Input() public disabled: boolean = false; }
@Output() public submitData: EventEmitter<any> = new EventEmitter<any>(); @Input({ required: true }) public set user(user: MachineUser) {
this.user$.next(user);
}
@Input() public set disabled(disabled: boolean) {
this.disabled$.next(disabled);
}
public machineForm!: UntypedFormGroup; private username$ = new ReplaySubject<string>(1);
private user$ = new ReplaySubject<MachineUser>(1);
private disabled$ = new ReplaySubject<boolean>(1);
public accessTokenTypes: AccessTokenType[] = [ public machineForm: ReturnType<typeof this.buildForm>;
AccessTokenType.ACCESS_TOKEN_TYPE_BEARER,
AccessTokenType.ACCESS_TOKEN_TYPE_JWT,
];
private sub: Subscription = new Subscription(); @Output() public submitData = new EventEmitter<ReturnType<(typeof this.machineForm)['getRawValue']>>();
constructor(private fb: UntypedFormBuilder) { public accessTokenTypes: AccessTokenType[] = [AccessTokenType.BEARER, AccessTokenType.JWT];
this.machineForm = this.fb.group({
userName: [{ value: '', disabled: true }, [requiredValidator]], constructor(
name: [{ value: '', disabled: this.disabled }, requiredValidator], private readonly fb: FormBuilder,
description: [{ value: '', disabled: this.disabled }], private readonly destroyRef: DestroyRef,
accessTokenType: [AccessTokenType.ACCESS_TOKEN_TYPE_BEARER, [requiredValidator]], ) {
this.machineForm = this.buildForm();
}
private buildForm() {
const form = this.fb.group({
username: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
name: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
description: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
accessTokenType: new FormControl(AccessTokenType.BEARER, { nonNullable: true, validators: [requiredValidator] }),
}); });
form.controls.username.disable();
this.disabled$
.pipe(startWith(false), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((disabled) => {
this.toggleFormControl(form.controls.name, disabled);
this.toggleFormControl(form.controls.description, disabled);
this.toggleFormControl(form.controls.accessTokenType, disabled);
});
this.username$.pipe(combineLatestWith(this.user$), takeUntilDestroyed(this.destroyRef)).subscribe(([username, user]) => {
this.machineForm.patchValue({ ...user, username });
});
return form;
} }
public ngOnInit(): void { public toggleFormControl<T>(control: FormControl<T>, disabled: boolean) {
this.machineForm.patchValue({ ...this.user, userName: this.username }); if (disabled) {
} control.disable();
return;
public ngOnDestroy(): void { }
this.sub.unsubscribe(); control.enable();
} }
public submitForm(): void { public submitForm(): void {
this.submitData.emit(this.machineForm.value); this.submitData.emit(this.machineForm.getRawValue());
}
public get name(): AbstractControl | null {
return this.machineForm.get('name');
}
public get userName(): AbstractControl | null {
return this.machineForm.get('userName');
} }
} }

View File

@@ -1,20 +1,20 @@
<form [formGroup]="profileForm" *ngIf="profileForm" (ngSubmit)="submitForm()"> <form *ngIf="profile$ | async as profile" [formGroup]="profileForm" (ngSubmit)="submitForm(profile)">
<div class="user-top-content"> <div class="user-top-content">
<div class="user-form-content"> <div class="user-form-content">
<button <button
[disabled]="user && disabled" [disabled]="disabled$ | async"
class="camera-wrapper" class="camera-wrapper"
type="button" type="button"
(click)="showEditImage ? openUploadDialog() : null" (click)="showEditImage ? openUploadDialog(profile) : null"
> >
<div class="i-wrapper" *ngIf="showEditImage"> <div class="i-wrapper" *ngIf="showEditImage">
<i class="las la-camera"></i> <i class="las la-camera"></i>
</div> </div>
<cnsl-avatar <cnsl-avatar
*ngIf="user && user.profile" *ngIf="profile.displayName"
class="avatar" class="avatar"
[name]="user.profile.displayName" [name]="profile.displayName"
[avatarUrl]="user.profile.avatarUrl || ''" [avatarUrl]="profile.avatarUrl || ''"
[forColor]="preferredLoginName" [forColor]="preferredLoginName"
[size]="80" [size]="80"
> >
@@ -24,9 +24,9 @@
<div class="usernamediv"> <div class="usernamediv">
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="userName" /> <input cnslInput formControlName="username" />
</cnsl-form-field> </cnsl-form-field>
<button [disabled]="user && disabled" type="button" mat-stroked-button class="edit" (click)="changeUsername()"> <button [disabled]="disabled$ | async" type="button" mat-stroked-button class="edit" (click)="changeUsername()">
{{ 'USER.PROFILE.CHANGEUSERNAME' | translate }} {{ 'USER.PROFILE.CHANGEUSERNAME' | translate }}
</button> </button>
</div> </div>
@@ -34,11 +34,11 @@
<div class="user-grid"> <div class="user-grid">
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.FIRSTNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PROFILE.FIRSTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="firstName" /> <input cnslInput formControlName="givenName" />
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.LASTNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PROFILE.LASTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="lastName" /> <input cnslInput formControlName="familyName" />
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field class="formfield"> <cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.NICKNAME' | translate }}</cnsl-label> <cnsl-label>{{ 'USER.PROFILE.NICKNAME' | translate }}</cnsl-label>
@@ -67,7 +67,7 @@
</div> </div>
</div> </div>
<div class="btn-container"> <div class="btn-container">
<button [disabled]="disabled" class="submit-button" type="submit" color="primary" mat-raised-button> <button [disabled]="disabled$ | async" class="submit-button" type="submit" color="primary" mat-raised-button>
{{ 'ACTIONS.SAVE' | translate }} {{ 'ACTIONS.SAVE' | translate }}
</button> </button>
</div> </div>

View File

@@ -1,116 +1,138 @@
import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output } from '@angular/core'; import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Subscription } from 'rxjs'; import { combineLatestWith, distinctUntilChanged, ReplaySubject } from 'rxjs';
import { requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { Gender, Human, Profile } from 'src/app/proto/generated/zitadel/user_pb';
import { ProfilePictureComponent } from './profile-picture/profile-picture.component'; import { ProfilePictureComponent } from './profile-picture/profile-picture.component';
import { Gender, HumanProfile, HumanProfileSchema } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { filter, startWith } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Profile } from '@zitadel/proto/zitadel/user_pb';
import { create } from '@bufbuild/protobuf';
function toHumanProfile(profile: HumanProfile | Profile): HumanProfile {
if (profile.$typeName === 'zitadel.user.v2.HumanProfile') {
return profile;
}
return create(HumanProfileSchema, {
givenName: profile.firstName,
familyName: profile.lastName,
nickName: profile.nickName,
displayName: profile.displayName,
preferredLanguage: profile.preferredLanguage,
gender: profile.gender,
avatarUrl: profile.avatarUrl,
});
}
@Component({ @Component({
selector: 'cnsl-detail-form', selector: 'cnsl-detail-form',
templateUrl: './detail-form.component.html', templateUrl: './detail-form.component.html',
styleUrls: ['./detail-form.component.scss'], styleUrls: ['./detail-form.component.scss'],
}) })
export class DetailFormComponent implements OnDestroy, OnChanges { export class DetailFormComponent implements OnInit {
@Input() public showEditImage: boolean = false; @Input() public showEditImage: boolean = false;
@Input() public preferredLoginName: string = ''; @Input() public preferredLoginName: string = '';
@Input() public username!: string; @Input({ required: true }) public set username(username: string) {
@Input() public user!: Human.AsObject; this.username$.next(username);
@Input() public disabled: boolean = true; }
@Input({ required: true }) public set profile(profile: HumanProfile | Profile) {
this.profile$.next(toHumanProfile(profile));
}
@Input() public set disabled(disabled: boolean) {
this.disabled$.next(disabled);
}
@Input() public genders: Gender[] = []; @Input() public genders: Gender[] = [];
@Input() public languages: string[] = ['de', 'en']; @Input() public languages: string[] = ['de', 'en'];
@Output() public submitData: EventEmitter<Profile.AsObject> = new EventEmitter<Profile.AsObject>();
@Output() public changedLanguage: EventEmitter<string> = new EventEmitter<string>(); @Output() public changedLanguage: EventEmitter<string> = new EventEmitter<string>();
@Output() public changeUsernameClicked: EventEmitter<void> = new EventEmitter(); @Output() public changeUsernameClicked: EventEmitter<void> = new EventEmitter();
@Output() public avatarChanged: EventEmitter<void> = new EventEmitter(); @Output() public avatarChanged: EventEmitter<void> = new EventEmitter();
public profileForm!: UntypedFormGroup; private username$ = new ReplaySubject<string>(1);
public profile$ = new ReplaySubject<HumanProfile>(1);
private sub: Subscription = new Subscription(); public profileForm!: ReturnType<typeof this.buildForm>;
public disabled$ = new ReplaySubject<boolean>(1);
@Output() public submitData = new EventEmitter<HumanProfile>();
constructor( constructor(
private fb: UntypedFormBuilder, private readonly fb: FormBuilder,
private dialog: MatDialog, private readonly dialog: MatDialog,
private readonly destroyRef: DestroyRef,
) { ) {
this.profileForm = this.fb.group({ this.profileForm = this.buildForm();
userName: [{ value: '', disabled: true }, [requiredValidator]],
firstName: [{ value: '', disabled: this.disabled }, requiredValidator],
lastName: [{ value: '', disabled: this.disabled }, requiredValidator],
nickName: [{ value: '', disabled: this.disabled }],
displayName: [{ value: '', disabled: this.disabled }, requiredValidator],
gender: [{ value: 0, disabled: this.disabled }],
preferredLanguage: [{ value: '', disabled: this.disabled }],
});
} }
public ngOnChanges(): void { ngOnInit(): void {
this.profileForm = this.fb.group({ this.profileForm.controls.preferredLanguage.valueChanges
userName: [{ value: '', disabled: true }, [requiredValidator]], .pipe(takeUntilDestroyed(this.destroyRef))
firstName: [{ value: '', disabled: this.disabled }, requiredValidator], .subscribe((value) => this.changedLanguage.emit(value));
lastName: [{ value: '', disabled: this.disabled }, requiredValidator], }
nickName: [{ value: '', disabled: this.disabled }],
displayName: [{ value: '', disabled: this.disabled }, requiredValidator], private buildForm() {
gender: [{ value: 0, disabled: this.disabled }], const form = this.fb.group({
preferredLanguage: [{ value: '', disabled: this.disabled }], username: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
givenName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
familyName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
nickName: new FormControl('', { nonNullable: true }),
displayName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
preferredLanguage: new FormControl('', { nonNullable: true }),
gender: new FormControl(Gender.UNSPECIFIED, { nonNullable: true }),
}); });
this.profileForm.patchValue({ userName: this.username, ...this.user.profile }); form.controls.username.disable();
this.disabled$
if (this.preferredLanguage) { .pipe(startWith(true), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
this.sub = this.preferredLanguage.valueChanges.subscribe((value) => { .subscribe((disabled) => {
this.changedLanguage.emit(value); this.toggleFormControl(form.controls.givenName, disabled);
this.toggleFormControl(form.controls.familyName, disabled);
this.toggleFormControl(form.controls.nickName, disabled);
this.toggleFormControl(form.controls.displayName, disabled);
this.toggleFormControl(form.controls.gender, disabled);
this.toggleFormControl(form.controls.preferredLanguage, disabled);
}); });
}
this.username$
.pipe(combineLatestWith(this.profile$), takeUntilDestroyed(this.destroyRef))
.subscribe(([username, profile]) => {
form.patchValue({
username: username,
...profile,
});
});
return form;
} }
public ngOnDestroy(): void { public submitForm(profile: HumanProfile): void {
this.sub.unsubscribe(); this.submitData.emit({ ...profile, ...this.profileForm.getRawValue() });
}
public submitForm(): void {
this.submitData.emit(this.profileForm.value);
} }
public changeUsername(): void { public changeUsername(): void {
this.changeUsernameClicked.emit(); this.changeUsernameClicked.emit();
} }
public openUploadDialog(): void { public openUploadDialog(profile: HumanProfile): void {
const dialogRef = this.dialog.open(ProfilePictureComponent, { const data = {
data: { profilePic: profile.avatarUrl,
profilePic: this.user.profile?.avatarUrl, };
},
const dialogRef = this.dialog.open<ProfilePictureComponent, typeof data, boolean>(ProfilePictureComponent, {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((shouldReload) => { dialogRef
if (shouldReload) { .afterClosed()
.pipe(filter(Boolean))
.subscribe(() => {
this.avatarChanged.emit(); this.avatarChanged.emit();
} });
});
} }
public get userName(): AbstractControl | null { public toggleFormControl<T>(control: FormControl<T>, disabled: boolean) {
return this.profileForm.get('userName'); if (disabled) {
} control.disable();
return;
public get firstName(): AbstractControl | null { }
return this.profileForm.get('firstName'); control.enable();
}
public get lastName(): AbstractControl | null {
return this.profileForm.get('lastName');
}
public get nickName(): AbstractControl | null {
return this.profileForm.get('nickName');
}
public get displayName(): AbstractControl | null {
return this.profileForm.get('displayName');
}
public get gender(): AbstractControl | null {
return this.profileForm.get('gender');
}
public get preferredLanguage(): AbstractControl | null {
return this.profileForm.get('preferredLanguage');
} }
} }

View File

@@ -3,14 +3,14 @@ import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs';
import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { IDPUserLink } from 'src/app/proto/generated/zitadel/idp_pb'; import { IDPUserLink } from 'src/app/proto/generated/zitadel/idp_pb';
import { GrpcAuthService } from '../../../../services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from '../../../../services/mgmt.service'; import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from '../../../../services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
@Component({ @Component({
selector: 'cnsl-external-idps', selector: 'cnsl-external-idps',
@@ -18,7 +18,7 @@ import { ToastService } from '../../../../services/toast.service';
styleUrls: ['./external-idps.component.scss'], styleUrls: ['./external-idps.component.scss'],
}) })
export class ExternalIdpsComponent implements OnInit, OnDestroy { export class ExternalIdpsComponent implements OnInit, OnDestroy {
@Input() service!: GrpcAuthService | ManagementService; @Input({ required: true }) service!: GrpcAuthService | ManagementService;
@Input() userId!: string; @Input() userId!: string;
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; @ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
public totalResult: number = 0; public totalResult: number = 0;
@@ -41,7 +41,7 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.getData(10, 0); this.getData(10, 0).then();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -65,39 +65,37 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
private async getData(limit: number, offset: number): Promise<void> { private async getData(limit: number, offset: number): Promise<void> {
this.loadingSubject.next(true); this.loadingSubject.next(true);
let promise; const promise =
if (this.service instanceof ManagementService) { this.service instanceof ManagementService
promise = (this.service as ManagementService).listHumanLinkedIDPs(this.userId, limit, offset); ? (this.service as ManagementService).listHumanLinkedIDPs(this.userId, limit, offset)
} else if (this.service instanceof GrpcAuthService) { : (this.service as GrpcAuthService).listMyLinkedIDPs(limit, offset);
promise = (this.service as GrpcAuthService).listMyLinkedIDPs(limit, offset);
let resp;
try {
resp = await promise;
} catch (error) {
this.toast.showError(error);
this.loadingSubject.next(false);
return;
} }
if (promise) { this.dataSource.data = resp.resultList;
promise if (resp.details?.viewTimestamp) {
.then((resp) => { this.viewTimestamp = resp.details.viewTimestamp;
this.dataSource.data = resp.resultList;
if (resp.details?.viewTimestamp) {
this.viewTimestamp = resp.details.viewTimestamp;
}
if (resp.details?.totalResult) {
this.totalResult = resp.details?.totalResult;
} else {
this.totalResult = 0;
}
this.loadingSubject.next(false);
})
.catch((error: any) => {
this.toast.showError(error);
this.loadingSubject.next(false);
});
} }
if (resp.details?.totalResult) {
this.totalResult = resp.details?.totalResult;
} else {
this.totalResult = 0;
}
this.loadingSubject.next(false);
} }
public refreshPage(): void { public refreshPage(): void {
this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize); this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize).then();
} }
public removeExternalIdp(idp: IDPUserLink.AsObject): void { public async removeExternalIdp(idp: IDPUserLink.AsObject): Promise<void> {
const dialogRef = this.dialog.open(WarnDialogComponent, { const dialogRef = this.dialog.open(WarnDialogComponent, {
data: { data: {
confirmKey: 'ACTIONS.REMOVE', confirmKey: 'ACTIONS.REMOVE',
@@ -108,27 +106,23 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { const resp = await firstValueFrom(dialogRef.afterClosed());
if (resp) { if (!resp) {
let promise; return;
if (this.service instanceof ManagementService) { }
promise = (this.service as ManagementService).removeHumanLinkedIDP(idp.idpId, idp.providedUserId, idp.userId);
} else if (this.service instanceof GrpcAuthService) {
promise = (this.service as GrpcAuthService).removeMyLinkedIDP(idp.idpId, idp.providedUserId);
}
if (promise) { const promise =
promise this.service instanceof ManagementService
.then((_) => { ? (this.service as ManagementService).removeHumanLinkedIDP(idp.idpId, idp.providedUserId, idp.userId)
setTimeout(() => { : (this.service as GrpcAuthService).removeMyLinkedIDP(idp.idpId, idp.providedUserId);
this.refreshPage();
}, 1000); try {
}) await promise;
.catch((error: any) => { setTimeout(() => {
this.toast.showError(error); this.refreshPage();
}); }, 1000);
} } catch (error) {
} this.toast.showError(error);
}); }
} }
} }

View File

@@ -1,9 +1,9 @@
<cnsl-detail-layout [hasBackButton]="true" title="{{ 'USER.PASSWORD.TITLE' | translate }}"> <cnsl-detail-layout *ngIf="form$ | async as form" [hasBackButton]="true" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<p class="password-info cnsl-secondary-text" sub>{{ 'USER.PASSWORD.DESCRIPTION' | translate }}</p> <p class="password-info cnsl-secondary-text" sub>{{ 'USER.PASSWORD.DESCRIPTION' | translate }}</p>
<ng-container *ngIf="userId; else authUser"> <ng-container *ngIf="id$ | async as id">
<form *ngIf="passwordForm" [formGroup]="passwordForm" (ngSubmit)="setInitialPassword(userId)"> <form [formGroup]="form" (ngSubmit)="setInitialPassword(id, form)">
<input <input
*ngIf="username" *ngIf="username$ | async as username"
class="hiddeninput" class="hiddeninput"
type="hidden" type="hidden"
autocomplete="username" autocomplete="username"
@@ -24,21 +24,22 @@
</cnsl-form-field> </cnsl-form-field>
</div> </div>
<div class="validation" *ngIf="this.policy"> <div class="validation" *ngIf="passwordPolicy$ | async as passwordPolicy">
<cnsl-password-complexity-view [policy]="this.policy" [password]="password"> </cnsl-password-complexity-view> <cnsl-password-complexity-view [policy]="passwordPolicy" [password]="password(form)">
</cnsl-password-complexity-view>
</div> </div>
</div> </div>
<button class="submit-button" [disabled]="passwordForm.invalid" mat-raised-button color="primary"> <button class="submit-button" [disabled]="form.invalid" mat-raised-button color="primary">
{{ 'USER.PASSWORD.SET' | translate }} {{ 'USER.PASSWORD.SET' | translate }}
</button> </button>
</form> </form>
</ng-container> </ng-container>
<ng-template #authUser> <ng-container *ngIf="(id$ | async) === undefined && (user$ | async) as user">
<form *ngIf="passwordForm" [formGroup]="passwordForm" (ngSubmit)="setPassword()"> <form [formGroup]="form" (ngSubmit)="setPassword(form, user)">
<input <input
*ngIf="username" *ngIf="username$ | async as username"
class="hiddeninput" class="hiddeninput"
type="hidden" type="hidden"
autocomplete="username" autocomplete="username"
@@ -53,8 +54,9 @@
<input cnslInput autocomplete="off" name="password" type="password" formControlName="currentPassword" /> <input cnslInput autocomplete="off" name="password" type="password" formControlName="currentPassword" />
</cnsl-form-field> </cnsl-form-field>
<div class="validation between" *ngIf="this.policy"> <div class="validation between" *ngIf="passwordPolicy$ | async as passwordPolicy">
<cnsl-password-complexity-view [policy]="this.policy" [password]="newPassword"> </cnsl-password-complexity-view> <cnsl-password-complexity-view [policy]="passwordPolicy" [password]="newPassword(form)">
</cnsl-password-complexity-view>
</div> </div>
<div class="side-by-side"> <div class="side-by-side">
@@ -74,9 +76,9 @@
</cnsl-form-field> </cnsl-form-field>
</div> </div>
</div> </div>
<button class="submit-button" [disabled]="passwordForm.invalid" mat-raised-button color="primary"> <button class="submit-button" [disabled]="form.invalid" mat-raised-button color="primary">
{{ 'USER.PASSWORD.RESET' | translate }} {{ 'USER.PASSWORD.RESET' | translate }}
</button> </button>
</form> </form>
</ng-template> </ng-container>
</cnsl-detail-layout> </cnsl-detail-layout>

View File

@@ -1,7 +1,18 @@
import { Component, OnDestroy } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Subject, Subscription, take, takeUntil } from 'rxjs'; import {
take,
map,
switchMap,
firstValueFrom,
mergeWith,
Observable,
defer,
of,
shareReplay,
combineLatestWith,
} from 'rxjs';
import { import {
containsLowerCaseValidator, containsLowerCaseValidator,
containsNumberValidator, containsNumberValidator,
@@ -14,161 +25,200 @@ import {
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb'; import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { catchError, filter } from 'rxjs/operators';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UserService } from '../../../../services/user.service';
@Component({ @Component({
selector: 'cnsl-password', selector: 'cnsl-password',
templateUrl: './password.component.html', templateUrl: './password.component.html',
styleUrls: ['./password.component.scss'], styleUrls: ['./password.component.scss'],
}) })
export class PasswordComponent implements OnDestroy { export class PasswordComponent implements OnInit {
userId: string = ''; private readonly breadcrumb$: Observable<Breadcrumb[]>;
public username: string = ''; protected readonly username$: Observable<string>;
protected readonly id$: Observable<string | undefined>;
public policy!: PasswordComplexityPolicy.AsObject; protected readonly form$: Observable<UntypedFormGroup>;
public passwordForm!: UntypedFormGroup; protected readonly passwordPolicy$: Observable<PasswordComplexityPolicy.AsObject | undefined>;
protected readonly user$: Observable<User.AsObject>;
private formSub: Subscription = new Subscription();
private destroy$: Subject<void> = new Subject();
constructor( constructor(
activatedRoute: ActivatedRoute, activatedRoute: ActivatedRoute,
private fb: UntypedFormBuilder, private readonly fb: UntypedFormBuilder,
private authService: GrpcAuthService, private readonly authService: GrpcAuthService,
private mgmtUserService: ManagementService, private readonly userService: UserService,
private toast: ToastService, private readonly toast: ToastService,
private breadcrumbService: BreadcrumbService, private readonly breadcrumbService: BreadcrumbService,
private readonly destroyRef: DestroyRef,
) { ) {
activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((data) => { const usernameParam$ = activatedRoute.queryParamMap.pipe(
const { username } = data; map((params) => params.get('username')),
this.username = username; filter(Boolean),
}); );
activatedRoute.params.pipe(takeUntil(this.destroy$)).subscribe((data) => { this.id$ = activatedRoute.paramMap.pipe(map((params) => params.get('id') ?? undefined));
const { id } = data;
if (id) { this.user$ = this.authService.user.pipe(take(1), filter(Boolean));
this.userId = id; this.username$ = usernameParam$.pipe(mergeWith(this.user$.pipe(map((user) => user.preferredLoginName))));
breadcrumbService.setBreadcrumb([
this.breadcrumb$ = this.getBreadcrumb$(this.id$, this.user$);
this.passwordPolicy$ = this.getPasswordPolicy$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const validators$ = this.getValidators$(this.passwordPolicy$);
this.form$ = this.getForm$(this.id$, validators$);
}
private getBreadcrumb$(id$: Observable<string | undefined>, user$: Observable<User.AsObject>): Observable<Breadcrumb[]> {
return id$.pipe(
switchMap(async (id) => {
if (id) {
return [
new Breadcrumb({
type: BreadcrumbType.ORG,
routerLink: ['/org'],
}),
];
}
const user = await firstValueFrom(user$);
if (!user) {
return [];
}
return [
new Breadcrumb({ new Breadcrumb({
type: BreadcrumbType.ORG, type: BreadcrumbType.AUTHUSER,
routerLink: ['/org'], name: user.human?.profile?.displayName,
routerLink: ['/users', 'me'],
}), }),
]); ];
} else { }),
this.authService.user.pipe(take(1)).subscribe((user) => { );
if (user) { }
this.username = user.preferredLoginName;
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: user.human?.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
}
});
}
const validators: Validators[] = [requiredValidator]; private getValidators$(
this.authService passwordPolicy$: Observable<PasswordComplexityPolicy.AsObject | undefined>,
.getMyPasswordComplexityPolicy() ): Observable<Validators[]> {
.then((resp) => { return passwordPolicy$.pipe(
if (resp.policy) { map((policy) => {
this.policy = resp.policy; const validators: Validators[] = [requiredValidator];
} if (!policy) {
if (this.policy.minLength) { return validators;
validators.push(minLengthValidator(this.policy.minLength)); }
} if (policy.minLength) {
if (this.policy.hasLowercase) { validators.push(minLengthValidator(policy.minLength));
validators.push(containsLowerCaseValidator); }
} if (policy.hasLowercase) {
if (this.policy.hasUppercase) { validators.push(containsLowerCaseValidator);
validators.push(containsUpperCaseValidator); }
} if (policy.hasUppercase) {
if (this.policy.hasNumber) { validators.push(containsUpperCaseValidator);
validators.push(containsNumberValidator); }
} if (policy.hasNumber) {
if (this.policy.hasSymbol) { validators.push(containsNumberValidator);
validators.push(containsSymbolValidator); }
} if (policy.hasSymbol) {
validators.push(containsSymbolValidator);
}
return validators;
}),
);
}
this.setupForm(validators); private getForm$(
}) id$: Observable<string | undefined>,
.catch((error) => { validators$: Observable<Validators[]>,
this.setupForm(validators); ): Observable<UntypedFormGroup> {
}); return id$.pipe(
combineLatestWith(validators$),
map(([id, validators]) => {
if (id) {
return this.fb.group({
password: ['', validators],
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]],
});
} else {
return this.fb.group({
currentPassword: ['', requiredValidator],
newPassword: ['', validators],
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]],
});
}
}),
);
}
private getPasswordPolicy$(): Observable<PasswordComplexityPolicy.AsObject | undefined> {
return defer(() => this.authService.getMyPasswordComplexityPolicy()).pipe(
map((resp) => resp.policy),
catchError(() => of(undefined)),
);
}
ngOnInit() {
this.breadcrumb$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((breadcrumbs) => {
this.breadcrumbService.setBreadcrumb(breadcrumbs);
}); });
} }
ngOnDestroy(): void { public async setInitialPassword(userId: string, form: UntypedFormGroup): Promise<void> {
this.destroy$.next(); const password = this.password(form)?.value;
this.destroy$.complete();
this.formSub.unsubscribe();
}
setupForm(validators: Validators[]): void { if (form.invalid || !password) {
if (this.userId) { return;
this.passwordForm = this.fb.group({ }
password: ['', validators],
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]], try {
await this.userService.setPassword({
userId,
newPassword: {
password,
changeRequired: false,
},
}); });
} else { } catch (error) {
this.passwordForm = this.fb.group({ this.toast.showError(error);
currentPassword: ['', requiredValidator], return;
newPassword: ['', validators], }
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]], this.toast.showInfo('USER.TOAST.INITIALPASSWORDSET', true);
window.history.back();
}
public async setPassword(form: UntypedFormGroup, user: User.AsObject): Promise<void> {
const currentPassword = this.currentPassword(form);
const newPassword = this.newPassword(form);
if (form.invalid || !currentPassword?.value || !newPassword?.value || newPassword?.invalid) {
return;
}
try {
await this.userService.setPassword({
userId: user.id,
newPassword: {
password: newPassword.value,
changeRequired: false,
},
verification: {
case: 'currentPassword',
value: currentPassword.value,
},
}); });
} catch (error) {
this.toast.showError(error);
return;
} }
this.toast.showInfo('USER.TOAST.PASSWORDCHANGED', true);
window.history.back();
} }
public setInitialPassword(userId: string): void { public password(form: UntypedFormGroup): AbstractControl | null {
if (this.passwordForm.valid && this.password && this.password.value) { return form.get('password');
this.mgmtUserService
.setHumanInitialPassword(userId, this.password.value)
.then((data: any) => {
this.toast.showInfo('USER.TOAST.INITIALPASSWORDSET', true);
window.history.back();
})
.catch((error) => {
this.toast.showError(error);
});
}
} }
public setPassword(): void { public newPassword(form: UntypedFormGroup): AbstractControl | null {
if ( return form.get('newPassword');
this.passwordForm.valid &&
this.currentPassword &&
this.currentPassword.value &&
this.newPassword &&
this.newPassword.value &&
this.newPassword.valid
) {
this.authService
.updateMyPassword(this.currentPassword.value, this.newPassword.value)
.then((data: any) => {
this.toast.showInfo('USER.TOAST.PASSWORDCHANGED', true);
window.history.back();
})
.catch((error) => {
this.toast.showError(error);
});
}
} }
public get password(): AbstractControl | null { public currentPassword(form: UntypedFormGroup): AbstractControl | null {
return this.passwordForm.get('password'); return form.get('currentPassword');
}
public get newPassword(): AbstractControl | null {
return this.passwordForm.get('newPassword');
}
public get currentPassword(): AbstractControl | null {
return this.passwordForm.get('currentPassword');
}
public get confirmPassword(): AbstractControl | null {
return this.passwordForm.get('confirmPassword');
} }
} }

View File

@@ -14,7 +14,7 @@
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" [dataSize]="dataSource.data.length"> <cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" [dataSize]="dataSource.data.length">
<button <button
actions actions
[disabled]="user && disabled" [disabled]="disabled"
class="button" class="button"
(click)="sendPasswordlessRegistration()" (click)="sendPasswordlessRegistration()"
mat-raised-button mat-raised-button
@@ -31,7 +31,7 @@
<th mat-header-cell *matHeaderCellDef>{{ 'USER.PASSWORDLESS.NAME' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'USER.PASSWORDLESS.NAME' | translate }}</th>
<td mat-cell *matCellDef="let mfa"> <td mat-cell *matCellDef="let mfa">
<span *ngIf="mfa?.name" class="centered"> <span *ngIf="mfa?.name" class="centered">
{{ mfa?.name }} {{ mfa.name }}
</span> </span>
</td> </td>
</ng-container> </ng-container>
@@ -42,8 +42,8 @@
<span <span
class="state" class="state"
[ngClass]="{ [ngClass]="{
active: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_READY, active: mfa.state === AuthFactorState.READY,
inactive: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_NOT_READY, inactive: mfa.state === AuthFactorState.NOT_READY,
}" }"
>{{ 'USER.PASSWORDLESS.STATE.' + mfa.state | translate }}</span >{{ 'USER.PASSWORDLESS.STATE.' + mfa.state | translate }}</span
> >

View File

@@ -1,12 +1,13 @@
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTable, MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable, switchMap } from 'rxjs';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { AuthFactorState, User, WebAuthNToken } from 'src/app/proto/generated/zitadel/user_pb';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { AuthFactorState, Passkey, User } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { UserService } from 'src/app/services/user.service';
import { filter } from 'rxjs/operators';
export interface WebAuthNOptions { export interface WebAuthNOptions {
challenge: string; challenge: string;
@@ -24,23 +25,22 @@ export interface WebAuthNOptions {
styleUrls: ['./passwordless.component.scss'], styleUrls: ['./passwordless.component.scss'],
}) })
export class PasswordlessComponent implements OnInit, OnDestroy { export class PasswordlessComponent implements OnInit, OnDestroy {
@Input() public user!: User.AsObject; @Input({ required: true }) public user!: User;
@Input() public disabled: boolean = true; @Input() public disabled: boolean = true;
public displayedColumns: string[] = ['name', 'state', 'actions']; public displayedColumns: string[] = ['name', 'state', 'actions'];
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable(); public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@ViewChild(MatTable) public table!: MatTable<WebAuthNToken.AsObject>;
@ViewChild(MatSort) public sort!: MatSort; @ViewChild(MatSort) public sort!: MatSort;
public dataSource: MatTableDataSource<WebAuthNToken.AsObject> = new MatTableDataSource<WebAuthNToken.AsObject>([]); public dataSource: MatTableDataSource<Passkey> = new MatTableDataSource<Passkey>([]);
public AuthFactorState: any = AuthFactorState; public AuthFactorState = AuthFactorState;
public error: string = ''; public error: string = '';
constructor( constructor(
private service: ManagementService,
private toast: ToastService, private toast: ToastService,
private dialog: MatDialog, private dialog: MatDialog,
private userService: UserService,
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
@@ -52,10 +52,10 @@ export class PasswordlessComponent implements OnInit, OnDestroy {
} }
public getPasswordless(): void { public getPasswordless(): void {
this.service this.userService
.listHumanPasswordless(this.user.id) .listPasskeys({ userId: this.user.userId })
.then((passwordless) => { .then((passwordless) => {
this.dataSource = new MatTableDataSource(passwordless.resultList); this.dataSource = new MatTableDataSource(passwordless.result);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
}) })
.catch((error) => { .catch((error) => {
@@ -63,7 +63,7 @@ export class PasswordlessComponent implements OnInit, OnDestroy {
}); });
} }
public deletePasswordless(id?: string): void { public deletePasswordless(passkeyId: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, { const dialogRef = this.dialog.open(WarnDialogComponent, {
data: { data: {
confirmKey: 'ACTIONS.DELETE', confirmKey: 'ACTIONS.DELETE',
@@ -74,24 +74,26 @@ export class PasswordlessComponent implements OnInit, OnDestroy {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp && id) { .afterClosed()
this.service .pipe(
.removeHumanPasswordless(id, this.user.id) filter(Boolean),
.then(() => { switchMap(() => this.userService.removePasskeys({ userId: this.user.userId, passkeyId })),
this.toast.showInfo('USER.TOAST.PASSWORDLESSREMOVED', true); )
this.getPasswordless(); .subscribe({
}) next: () => {
.catch((error) => { this.toast.showInfo('USER.TOAST.PASSWORDLESSREMOVED', true);
this.toast.showError(error); this.getPasswordless();
}); },
} error: (error) => {
}); this.toast.showError(error);
},
});
} }
public sendPasswordlessRegistration(): void { public sendPasswordlessRegistration(): void {
this.service this.userService
.sendPasswordlessRegistration(this.user.id) .createPasskeyRegistrationLink({ userId: this.user.userId, medium: { case: 'sendLink', value: {} } })
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PASSWORDLESSREGISTRATIONSENT', true); this.toast.showInfo('USER.TOAST.PASSWORDLESSREGISTRATIONSENT', true);
}) })

View File

@@ -1,262 +1,277 @@
<cnsl-top-view <ng-container *ngIf="user$ | async as userQuery">
*ngIf="user" <cnsl-top-view
title="{{ user.human ? user.human.profile?.displayName : user.machine?.name }}" *ngIf="(userQuery.state === 'success' || userQuery.state === 'loading') && userQuery.value as user"
docLink="https://zitadel.com/docs/guides/manage/console/users" title="{{ user.type.case === 'human' ? user.type.value.profile?.displayName : user.type.value?.name }}"
sub="{{ user.preferredLoginName }}" docLink="https://zitadel.com/docs/guides/manage/console/users"
[isActive]="user.state === UserState.USER_STATE_ACTIVE" sub="{{ user.preferredLoginName }}"
[isInactive]="user.state === UserState.USER_STATE_INACTIVE" [isActive]="user.state === UserState.ACTIVE"
stateTooltip="{{ 'USER.STATE.' + user.state | translate }}" [isInactive]="user.state === UserState.INACTIVE"
(backClicked)="navigateBack()" stateTooltip="{{ 'USER.STATE.' + user.state | translate }}"
[hasActions]="['user.write$', 'user.write:' + user.id] | hasRole | async" (backClicked)="navigateBack()"
> [hasActions]="['user.write$', 'user.write:' + user.userId] | hasRole | async"
<ng-template topActions cnslHasRole [hasRole]="['user.write$', 'user.write:' + user.id]"> >
<button mat-menu-item color="warn" *ngIf="user?.machine" (click)="generateMachineSecret()"> <ng-template topActions cnslHasRole [hasRole]="['user.write$', 'user.write:' + user.userId]">
{{ 'USER.PAGES.GENERATESECRET' | translate }} <ng-container *ngIf="user.type.case === 'machine'">
</button> <button mat-menu-item color="warn" (click)="generateMachineSecret(user)">
<button mat-menu-item color="warn" *ngIf="user?.machine?.hasSecret" (click)="removeMachineSecret()"> {{ 'USER.PAGES.GENERATESECRET' | translate }}
{{ 'USER.PAGES.REMOVESECRET' | translate }} </button>
</button> <button mat-menu-item color="warn" *ngIf="user.type.value.hasSecret" (click)="removeMachineSecret(user)">
<button mat-menu-item color="warn" *ngIf="user?.state === UserState.USER_STATE_LOCKED" (click)="unlockUser()"> {{ 'USER.PAGES.REMOVESECRET' | translate }}
{{ 'USER.PAGES.UNLOCK' | translate }} </button>
</button> </ng-container>
<button <button mat-menu-item color="warn" *ngIf="user?.state === UserState.LOCKED" (click)="unlockUser(user)">
mat-menu-item {{ 'USER.PAGES.UNLOCK' | translate }}
*ngIf="user?.state === UserState.USER_STATE_ACTIVE"
(click)="changeState(UserState.USER_STATE_INACTIVE)"
>
{{ 'USER.PAGES.DEACTIVATE' | translate }}
</button>
<button
mat-menu-item
*ngIf="user?.state === UserState.USER_STATE_INACTIVE"
(click)="changeState(UserState.USER_STATE_ACTIVE)"
>
{{ 'USER.PAGES.REACTIVATE' | translate }}
</button>
<ng-template cnslHasRole [hasRole]="['user.delete$', 'user.delete:' + user.id]">
<button mat-menu-item matTooltip="{{ 'USER.PAGES.DELETE' | translate }}" (click)="deleteUser()">
<span [style.color]="'var(--warn)'">{{ 'USER.PAGES.DELETE' | translate }}</span>
</button> </button>
<button mat-menu-item *ngIf="user.state === UserState.ACTIVE" (click)="changeState(user, UserState.INACTIVE)">
{{ 'USER.PAGES.DEACTIVATE' | translate }}
</button>
<button mat-menu-item *ngIf="user.state === UserState.INACTIVE" (click)="changeState(user, UserState.ACTIVE)">
{{ 'USER.PAGES.REACTIVATE' | translate }}
</button>
<ng-template cnslHasRole [hasRole]="['user.delete$', 'user.delete:' + user.userId]">
<button mat-menu-item matTooltip="{{ 'USER.PAGES.DELETE' | translate }}" (click)="deleteUser(user)">
<span [style.color]="'var(--warn)'">{{ 'USER.PAGES.DELETE' | translate }}</span>
</button>
</ng-template>
</ng-template> </ng-template>
</ng-template> <cnsl-info-row topContent [user]="user" [loginPolicy]="(loginPolicy$ | async) ?? undefined"></cnsl-info-row>
<cnsl-info-row topContent *ngIf="user" [user]="user" [loginPolicy]="loginPolicy"></cnsl-info-row> </cnsl-top-view>
</cnsl-top-view>
<div *ngIf="loading" class="max-width-container"> <div *ngIf="userQuery.state === 'loading'" class="max-width-container">
<div class="sp-wrapper"> <div class="sp-wrapper">
<mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
</div>
<div *ngIf="!loading && !user" class="max-width-container">
<p class="no-user-error">{{ 'USER.PAGES.NOUSER' | translate }}</p>
</div>
<div class="max-width-container" *ngIf="user && (['user.write$', 'user.write:' + user.id] | hasRole) as canWrite$">
<cnsl-meta-layout>
<cnsl-sidenav [(ngModel)]="currentSetting" [settingsList]="settingsList" queryParam="id">
<div *ngIf="error" class="max-width-container">
<p>{{ error }}</p>
</div>
<div class="max-width-container">
<cnsl-info-section class="locked" *ngIf="user?.state === UserState.USER_STATE_LOCKED" [type]="InfoSectionType.WARN">
{{ 'USER.PAGES.LOCKEDDESCRIPTION' | translate }}</cnsl-info-section
>
<span *ngIf="!loading && !user">{{ 'USER.PAGES.NOUSER' | translate }}</span>
<div *ngIf="user && user.state === UserState.USER_STATE_INITIAL">
<cnsl-info-section class="is-initial-info-section" [type]="InfoSectionType.ALERT">
<div class="is-initial-row">
<span>{{ 'USER.ISINITIAL' | translate }}</span>
<button [disabled]="(canWrite$ | async) === false" mat-stroked-button (click)="resendInitEmail()">
{{ 'USER.RESENDINITIALEMAIL' | translate }}
</button>
</div>
</cnsl-info-section>
</div>
<ng-container *ngIf="currentSetting === 'general'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.id]">
<cnsl-card *ngIf="user.human" title="{{ user.preferredLoginName }} - {{ 'USER.PROFILE.TITLE' | translate }}">
<cnsl-detail-form
[preferredLoginName]="user.preferredLoginName"
[disabled]="(canWrite$ | async) === false"
[genders]="genders"
[languages]="(langSvc.supported$ | async) || []"
[username]="user.userName"
[user]="user.human"
(submitData)="saveProfile($event)"
(changeUsernameClicked)="changeUsername()"
>
</cnsl-detail-form>
</cnsl-card>
<cnsl-card
*ngIf="user.human"
title="{{ 'USER.LOGINMETHODS.TITLE' | translate }}"
description="{{ 'USER.LOGINMETHODS.DESCRIPTION' | translate }}"
>
<button
card-actions
class="icon-button"
mat-icon-button
(click)="refreshUser()"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
>
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-contact
[disablePhoneCode]="true"
[state]="user.state"
[username]="user.preferredLoginName"
[canWrite]="['user.write:' + user.id, 'user.write$'] | hasRole | async"
*ngIf="user?.human"
[human]="user.human"
(editType)="
user.state === UserState.USER_STATE_INITIAL && $event === EditDialogType.EMAIL
? resendInitEmail()
: openEditDialog($event)
"
(deletedPhone)="deletePhone()"
(resendEmailVerification)="resendEmailVerification()"
(resendPhoneVerification)="resendPhoneVerification()"
>
<button
pwdAction
[disabled]="(canWrite$ | async) === false"
(click)="sendSetPasswordNotification()"
mat-stroked-button
*ngIf="
user.state !== UserState.USER_STATE_LOCKED &&
user.state !== UserState.USER_STATE_INACTIVE &&
user.state !== UserState.USER_STATE_INITIAL
"
>
{{ 'USER.PASSWORD.RESENDNOTIFICATION' | translate }}
</button>
</cnsl-contact>
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'idp'">
<cnsl-external-idps *ngIf="user && user.human && user.id" [userId]="user.id" [service]="mgmtUserService">
</cnsl-external-idps>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'general'">
<cnsl-card *ngIf="user.machine" title="{{ 'USER.MACHINE.TITLE' | translate }}">
<cnsl-detail-form-machine
[disabled]="(canWrite$ | async) === false"
[username]="user.userName"
[user]="user.machine"
(submitData)="saveMachine($event)"
>
</cnsl-detail-form-machine>
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'pat'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.id]">
<cnsl-card
*ngIf="user.machine && user.id"
title="{{ 'USER.MACHINE.TOKENSTITLE' | translate }}"
description="{{ 'USER.MACHINE.TOKENSDESC' | translate }}"
>
<cnsl-personal-access-tokens [userId]="user.id"></cnsl-personal-access-tokens>
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'keys'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.id]">
<cnsl-card
*ngIf="user.machine && user.id"
title="{{ 'USER.MACHINE.KEYSTITLE' | translate }}"
description="{{ 'USER.MACHINE.KEYSDESC' | translate }}"
>
<cnsl-machine-keys [userId]="user.id"></cnsl-machine-keys>
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'security'">
<cnsl-card *ngIf="user.human" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
<div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PASSWORD.LABEL' | translate }}</span>
<span>*********</span>
<ng-content select="[pwdAction]"></ng-content>
</div>
<div class="right">
<a
matTooltip="{{ 'USER.PASSWORD.SET' | translate }}"
[disabled]="(['user.write:' + user.id, 'user.write$'] | hasRole | async) === false"
[routerLink]="['password']"
[queryParams]="{ username: user.preferredLoginName }"
mat-icon-button
>
<i class="las la-pen"></i>
</a>
</div>
</div>
</div>
</cnsl-card>
<cnsl-passwordless *ngIf="user && !!user.human" [user]="user" [disabled]="(canWrite$ | async) === false">
</cnsl-passwordless>
<cnsl-user-mfa *ngIf="user && user.human" [user]="user"></cnsl-user-mfa>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'grants'">
<cnsl-card
*ngIf="user?.id"
title="{{ 'GRANTS.USER.TITLE' | translate }}"
description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}"
>
<cnsl-user-grants
[userId]="user.id"
[context]="USERGRANTCONTEXT"
[displayedColumns]="['select', 'projectId', 'creationDate', 'changeDate', 'state', 'roleNamesList', 'actions']"
[disableWrite]="(['user.grant.write$'] | hasRole | async) === false"
[disableDelete]="(['user.grant.delete$'] | hasRole | async) === false"
>
</cnsl-user-grants>
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'memberships'">
<cnsl-card
*ngIf="user?.id"
title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}"
description="{{ 'USER.MEMBERSHIPS.DESCRIPTION' | translate }}"
>
<cnsl-memberships-table [userId]="user.id"></cnsl-memberships-table>
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting && currentSetting === 'metadata'">
<cnsl-metadata
[metadata]="metadata"
[description]="
(user.machine ? 'DESCRIPTIONS.USERS.MACHINES.METADATA' : 'DESCRIPTIONS.USERS.HUMANS.METADATA') | translate
"
[disabled]="(['user.write:' + user.id, 'user.write'] | hasRole | async) === false"
*ngIf="user && user.id"
(editClicked)="editMetadata()"
(refresh)="loadMetadata(user.id)"
></cnsl-metadata>
</ng-container>
</div>
</cnsl-sidenav>
<div metainfo>
<cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.USER" [id]="user.id">
</cnsl-changes>
</div> </div>
</cnsl-meta-layout> </div>
</div>
<div *ngIf="userQuery.state === 'notfound'" class="max-width-container">
<p class="no-user-error">{{ 'USER.PAGES.NOUSER' | translate }}</p>
</div>
<ng-container *ngIf="(userQuery.state === 'success' || userQuery.state === 'loading') && userQuery.value as user">
<div class="max-width-container" *ngIf="['user.write$', 'user.write:' + user.userId] | hasRole as canWrite$">
<cnsl-meta-layout>
<cnsl-sidenav
*ngIf="settingsList$ | async as settingsList"
[ngModel]="currentSetting$ | async"
(ngModelChange)="goToSetting($event)"
[settingsList]="settingsList"
queryParam="id"
>
<div class="max-width-container">
<cnsl-info-section class="locked" *ngIf="user?.state === UserState.LOCKED" [type]="InfoSectionType.WARN">
{{ 'USER.PAGES.LOCKEDDESCRIPTION' | translate }}</cnsl-info-section
>
<div *ngIf="user && user.state === UserState.INITIAL">
<cnsl-info-section class="is-initial-info-section" [type]="InfoSectionType.ALERT">
<div class="is-initial-row">
<span>{{ 'USER.ISINITIAL' | translate }}</span>
<button [disabled]="(canWrite$ | async) === false" mat-stroked-button (click)="resendInitEmail(user)">
{{ 'USER.RESENDINITIALEMAIL' | translate }}
</button>
</div>
</cnsl-info-section>
</div>
<ng-container *ngIf="(currentSetting$ | async) === 'general'">
<ng-template
*ngIf="humanUser(user) as user"
cnslHasRole
[hasRole]="['user.read$', 'user.read:' + user.userId]"
>
<cnsl-card
*ngIf="user.type.value.profile as profile"
title="{{ user.preferredLoginName }} - {{ 'USER.PROFILE.TITLE' | translate }}"
>
<cnsl-detail-form
[preferredLoginName]="user.preferredLoginName"
[disabled]="(canWrite$ | async) === false"
[genders]="genders"
[languages]="(langSvc.supported$ | async) || []"
[username]="user.username"
[profile]="profile"
(submitData)="saveProfile(user, $event)"
(changeUsernameClicked)="changeUsername(user)"
>
</cnsl-detail-form>
</cnsl-card>
<cnsl-card
title="{{ 'USER.LOGINMETHODS.TITLE' | translate }}"
description="{{ 'USER.LOGINMETHODS.DESCRIPTION' | translate }}"
>
<button
card-actions
class="icon-button"
mat-icon-button
(click)="refreshChanges$.emit()"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
>
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-contact
[disablePhoneCode]="true"
[username]="user.preferredLoginName"
[canWrite]="['user.write:' + user.userId, 'user.write$'] | hasRole | async"
[human]="user.type.value"
(editType)="
user.state === UserState.INITIAL && $event === EditDialogType.EMAIL
? resendInitEmail(user)
: openEditDialog(user, $event)
"
(deletedPhone)="deletePhone(user)"
(resendEmailVerification)="resendEmailVerification(user)"
(resendPhoneVerification)="resendPhoneVerification(user)"
>
<button
pwdAction
[disabled]="(canWrite$ | async) === false"
(click)="sendSetPasswordNotification(user)"
mat-stroked-button
*ngIf="
user.state !== UserState.LOCKED &&
user.state !== UserState.INACTIVE &&
user.state !== UserState.INITIAL
"
>
{{ 'USER.PASSWORD.RESENDNOTIFICATION' | translate }}
</button>
</cnsl-contact>
</cnsl-card>
</ng-template>
</ng-container>
<cnsl-external-idps
*ngIf="(currentSetting$ | async) === 'idp' && user.type.case === 'human' && user.userId"
[userId]="user.userId"
[service]="mgmtService"
/>
<cnsl-card
*ngIf="(currentSetting$ | async) === 'general' && user.type.case === 'machine'"
title="{{ 'USER.MACHINE.TITLE' | translate }}"
>
<cnsl-detail-form-machine
[disabled]="(canWrite$ | async) === false"
[username]="user.username"
[user]="user.type.value"
(submitData)="saveMachine(user, $event)"
/>
</cnsl-card>
<ng-container *ngIf="(currentSetting$ | async) === 'pat'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.userId]">
<cnsl-card
*ngIf="user.type.case === 'machine' && user.userId"
title="{{ 'USER.MACHINE.TOKENSTITLE' | translate }}"
description="{{ 'USER.MACHINE.TOKENSDESC' | translate }}"
>
<cnsl-personal-access-tokens [userId]="user.userId" />
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'keys'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.userId]">
<cnsl-card
*ngIf="user.type.case === 'machine' && user.userId"
title="{{ 'USER.MACHINE.KEYSTITLE' | translate }}"
description="{{ 'USER.MACHINE.KEYSDESC' | translate }}"
>
<cnsl-machine-keys [userId]="user.userId" />
</cnsl-card>
</ng-template>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'security'">
<cnsl-card *ngIf="user.type.case === 'human'" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
<div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PASSWORD.LABEL' | translate }}</span>
<span>*********</span>
<ng-content select="[pwdAction]"></ng-content>
</div>
<div class="right">
<a
matTooltip="{{ 'USER.PASSWORD.SET' | translate }}"
[disabled]="(['user.write:' + user.userId, 'user.write$'] | hasRole | async) === false"
[routerLink]="['password']"
[queryParams]="{ username: user.preferredLoginName }"
mat-icon-button
>
<i class="las la-pen"></i>
</a>
</div>
</div>
</div>
</cnsl-card>
<cnsl-passwordless *ngIf="user.type.case === 'human'" [user]="user" [disabled]="(canWrite$ | async) === false">
</cnsl-passwordless>
<cnsl-user-mfa *ngIf="user.type.case === 'human'" [user]="user"></cnsl-user-mfa>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'grants'">
<cnsl-card
*ngIf="user.userId"
title="{{ 'GRANTS.USER.TITLE' | translate }}"
description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}"
>
<cnsl-user-grants
[userId]="user.userId"
[context]="USERGRANTCONTEXT"
[displayedColumns]="[
'select',
'projectId',
'creationDate',
'changeDate',
'state',
'roleNamesList',
'actions',
]"
[disableWrite]="(['user.grant.write$'] | hasRole | async) === false"
[disableDelete]="(['user.grant.delete$'] | hasRole | async) === false"
>
</cnsl-user-grants>
</cnsl-card>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'memberships'">
<cnsl-card
*ngIf="user.userId"
title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}"
description="{{ 'USER.MEMBERSHIPS.DESCRIPTION' | translate }}"
>
<cnsl-memberships-table [userId]="user.userId"></cnsl-memberships-table>
</cnsl-card>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'metadata' && (metadata$ | async) as metadataQuery">
<cnsl-metadata
*ngIf="user.userId && metadataQuery.state !== 'error'"
[metadata]="metadataQuery.value"
[description]="
(user.type.case === 'machine'
? 'DESCRIPTIONS.USERS.MACHINES.METADATA'
: 'DESCRIPTIONS.USERS.HUMANS.METADATA'
) | translate
"
[loading]="metadataQuery.state === 'loading'"
[disabled]="(['user.write:' + user.userId, 'user.write'] | hasRole | async) === false"
(editClicked)="editMetadata(user, metadataQuery.value)"
(refresh)="refreshMetadata$.next(true)"
></cnsl-metadata>
</ng-container>
</div>
</cnsl-sidenav>
<div metainfo>
<cnsl-changes class="changes" [refresh]="refreshChanges$" [changeType]="ChangeType.USER" [id]="user.userId">
</cnsl-changes>
</div>
</cnsl-meta-layout>
</div>
</ng-container>
</ng-container>

View File

@@ -1,32 +1,61 @@
import { MediaMatcher } from '@angular/cdk/layout'; import { MediaMatcher } from '@angular/cdk/layout';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component, EventEmitter, OnInit } from '@angular/core'; import { Component, DestroyRef, EventEmitter, OnInit } from '@angular/core';
import { Validators } from '@angular/forms'; import { Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router'; import { ActivatedRoute, Params, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { take } from 'rxjs/operators'; import { catchError, filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators';
import { ChangeType } from 'src/app/modules/changes/changes.component'; import { ChangeType } from 'src/app/modules/changes/changes.component';
import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component'; import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { MetadataDialogComponent } from 'src/app/modules/metadata/metadata-dialog/metadata-dialog.component'; import {
MetadataDialogComponent,
MetadataDialogData,
} from 'src/app/modules/metadata/metadata-dialog/metadata-dialog.component';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component'; import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource'; import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { SendHumanResetPasswordNotificationRequest, UnlockUserRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb';
import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Email, Gender, Machine, Phone, Profile, User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { formatPhone } from 'src/app/utils/formatPhone'; import { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component'; import {
import { ResendEmailDialogComponent } from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component'; EditDialogData,
EditDialogResult,
EditDialogComponent,
EditDialogType,
} from '../auth-user-detail/edit-dialog/edit-dialog.component';
import {
ResendEmailDialogComponent,
ResendEmailDialogData,
ResendEmailDialogResult,
} from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component';
import { MachineSecretDialogComponent } from './machine-secret-dialog/machine-secret-dialog.component'; import { MachineSecretDialogComponent } from './machine-secret-dialog/machine-secret-dialog.component';
import { Observable } from 'rxjs'; import { LanguagesService } from 'src/app/services/languages.service';
import { LanguagesService } from '../../../../services/languages.service'; import { UserService } from 'src/app/services/user.service';
import { Gender, HumanProfile, HumanUser, User as UserV2, UserState } from '@zitadel/proto/zitadel/user/v2/user_pb';
import {
combineLatestWith,
defer,
EMPTY,
fromEvent,
mergeWith,
Observable,
ObservedValueOf,
of,
shareReplay,
Subject,
switchMap,
} from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DetailFormMachineComponent } from '../detail-form-machine/detail-form-machine.component';
import { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb';
import { SendHumanResetPasswordNotificationRequest_Type } from '@zitadel/proto/zitadel/management_pb';
import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith';
import { Metadata } from '@zitadel/proto/zitadel/metadata_pb';
import { ManagementService } from 'src/app/services/mgmt.service';
const GENERAL: SidenavSetting = { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }; const GENERAL: SidenavSetting = { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' };
const GRANTS: SidenavSetting = { id: 'grants', i18nKey: 'USER.SETTINGS.USERGRANTS' }; const GRANTS: SidenavSetting = { id: 'grants', i18nKey: 'USER.SETTINGS.USERGRANTS' };
@@ -37,22 +66,31 @@ const PERSONALACCESSTOKEN: SidenavSetting = { id: 'pat', i18nKey: 'USER.SETTINGS
const KEYS: SidenavSetting = { id: 'keys', i18nKey: 'USER.SETTINGS.KEYS' }; const KEYS: SidenavSetting = { id: 'keys', i18nKey: 'USER.SETTINGS.KEYS' };
const MEMBERSHIPS: SidenavSetting = { id: 'memberships', i18nKey: 'USER.SETTINGS.MEMBERSHIPS' }; const MEMBERSHIPS: SidenavSetting = { id: 'memberships', i18nKey: 'USER.SETTINGS.MEMBERSHIPS' };
type UserQuery =
| { state: 'success'; value: UserV2 }
| { state: 'error'; value: string }
| { state: 'loading'; value?: UserV2 }
| { state: 'notfound' };
type MetadataQuery =
| { state: 'success'; value: Metadata[] }
| { state: 'loading'; value: Metadata[] }
| { state: 'error'; value: string };
type UserWithHumanType = Omit<UserV2, 'type'> & { type: { case: 'human'; value: HumanUser } };
@Component({ @Component({
selector: 'cnsl-user-detail', selector: 'cnsl-user-detail',
templateUrl: './user-detail.component.html', templateUrl: './user-detail.component.html',
styleUrls: ['./user-detail.component.scss'], styleUrls: ['./user-detail.component.scss'],
}) })
export class UserDetailComponent implements OnInit { export class UserDetailComponent implements OnInit {
public user!: User.AsObject; public user$: Observable<UserQuery>;
public metadata: Metadata.AsObject[] = []; public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE];
public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE];
public ChangeType: any = ChangeType; public ChangeType: any = ChangeType;
public loading: boolean = true; public UserState = UserState;
public loadingMetadata: boolean = true;
public UserState: any = UserState;
public copied: string = ''; public copied: string = '';
public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.USER; public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.USER;
@@ -60,32 +98,27 @@ export class UserDetailComponent implements OnInit {
public refreshChanges$: EventEmitter<void> = new EventEmitter(); public refreshChanges$: EventEmitter<void> = new EventEmitter();
public InfoSectionType: any = InfoSectionType; public InfoSectionType: any = InfoSectionType;
public error: string = ''; public currentSetting$: Observable<string | undefined>;
public settingsList$: Observable<SidenavSetting[]>;
public settingsList: SidenavSetting[] = [GENERAL, GRANTS, MEMBERSHIPS, METADATA]; public metadata$: Observable<MetadataQuery>;
public currentSetting: string | undefined = 'general'; public loginPolicy$: Observable<LoginPolicy>;
public loginPolicy?: LoginPolicy.AsObject; public refreshMetadata$ = new Subject<true>();
constructor( constructor(
public translate: TranslateService, public translate: TranslateService,
private route: ActivatedRoute, private readonly route: ActivatedRoute,
private toast: ToastService, private toast: ToastService,
public mgmtUserService: ManagementService,
private _location: Location, private _location: Location,
private dialog: MatDialog, private dialog: MatDialog,
private router: Router, private router: Router,
activatedRoute: ActivatedRoute,
private mediaMatcher: MediaMatcher, private mediaMatcher: MediaMatcher,
public langSvc: LanguagesService, public langSvc: LanguagesService,
private readonly userService: UserService,
private readonly newMgmtService: NewMgmtService,
public readonly mgmtService: ManagementService,
breadcrumbService: BreadcrumbService, breadcrumbService: BreadcrumbService,
private readonly destroyRef: DestroyRef,
) { ) {
activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => {
const { id } = params;
if (id) {
this.currentSetting = id;
}
});
breadcrumbService.setBreadcrumb([ breadcrumbService.setBreadcrumb([
new Breadcrumb({ new Breadcrumb({
type: BreadcrumbType.ORG, type: BreadcrumbType.ORG,
@@ -93,63 +126,127 @@ export class UserDetailComponent implements OnInit {
}), }),
]); ]);
this.currentSetting$ = this.getCurrentSetting$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.settingsList$ = this.getSettingsList$(this.user$);
this.metadata$ = this.getMetadata$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
catchError(() => EMPTY),
map(({ policy }) => policy),
filter(Boolean),
);
}
private getId$(): Observable<string> {
return this.route.paramMap.pipe(
map((params) => params.get('id')),
filter(Boolean),
);
}
private getUser$(): Observable<UserQuery> {
return this.getId$().pipe(
combineLatestWith(this.refreshChanges$.pipe(startWith(undefined))),
switchMap(([id]) => this.getUserById(id)),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { state: 'loading', value: prev.value } as const;
}
return curr;
}),
);
}
private getSettingsList$(user$: Observable<UserQuery>): Observable<SidenavSetting[]> {
return user$.pipe(
switchMap((user) => {
if (user.state !== 'success') {
return EMPTY;
}
if (user.value.type.case === 'human') {
return of([GENERAL, SECURITY, IDP, GRANTS, MEMBERSHIPS, METADATA]);
} else if (user.value.type.case === 'machine') {
return of([GENERAL, GRANTS, MEMBERSHIPS, PERSONALACCESSTOKEN, KEYS, METADATA]);
}
return EMPTY;
}),
startWith([GENERAL, GRANTS, MEMBERSHIPS, METADATA]),
);
}
private getCurrentSetting$(): Observable<string | undefined> {
const mediaq: string = '(max-width: 500px)'; const mediaq: string = '(max-width: 500px)';
const small = this.mediaMatcher.matchMedia(mediaq).matches; const matcher = this.mediaMatcher.matchMedia(mediaq);
if (small) { const small$ = fromEvent(matcher, 'change', ({ matches }: MediaQueryListEvent) => matches).pipe(
this.changeSelection(small); startWith(matcher.matches),
} );
this.mediaMatcher.matchMedia(mediaq).onchange = (small) => {
this.changeSelection(small.matches); return this.route.queryParamMap.pipe(
}; map((params) => params.get('id')),
filter(Boolean),
startWith('general'),
withLatestFrom(small$),
map(([id, small]) => (small ? undefined : id)),
);
} }
private changeSelection(small: boolean): void { public async goToSetting(setting: string) {
if (small) { await this.router.navigate([], {
this.currentSetting = undefined; relativeTo: this.route,
} else { queryParams: { id: setting },
this.currentSetting = this.currentSetting === undefined ? 'general' : this.currentSetting; queryParamsHandling: 'merge',
} skipLocationChange: true,
}
refreshUser(): void {
this.refreshChanges$.emit();
this.route.params.pipe(take(1)).subscribe((params) => {
this.loading = true;
const { id } = params;
this.mgmtUserService
.getUserByID(id)
.then((resp) => {
this.loadMetadata(id);
this.loading = false;
if (resp.user) {
this.user = resp.user;
if (this.user.human) {
this.settingsList = [GENERAL, SECURITY, IDP, GRANTS, MEMBERSHIPS, METADATA];
} else if (this.user.machine) {
this.settingsList = [GENERAL, GRANTS, MEMBERSHIPS, PERSONALACCESSTOKEN, KEYS, METADATA];
}
}
})
.catch((err) => {
this.error = err.message ?? '';
this.loading = false;
this.toast.showError(err);
});
}); });
} }
public ngOnInit(): void { private getUserById(userId: string): Observable<UserQuery> {
this.refreshUser(); return defer(() => this.userService.getUserById(userId)).pipe(
map(({ user }) => {
if (user) {
return { state: 'success', value: user } as const;
}
return { state: 'notfound' } as const;
}),
catchError((error) => of({ state: 'error', value: error.message ?? '' } as const)),
startWith({ state: 'loading' } as const),
);
}
this.mgmtUserService.getLoginPolicy().then((policy) => { getMetadata$(user$: Observable<UserQuery>): Observable<MetadataQuery> {
if (policy.policy) { return this.refreshMetadata$.pipe(
this.loginPolicy = policy.policy; startWith(true),
combineLatestWith(user$),
switchMap(([_, user]) => {
if (!(user.state === 'success' || user.state === 'loading')) {
return EMPTY;
}
if (!user.value) {
return EMPTY;
}
return this.getMetadataById(user.value.userId);
}),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { state: 'loading', value: prev.value } as const;
}
return curr;
}),
);
}
public ngOnInit(): void {
this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if (query.state == 'error') {
this.toast.showError(query.value);
} }
}); });
} }
public changeUsername(): void { public changeUsername(user: UserV2): void {
const dialogRef = this.dialog.open(EditDialogComponent, { const dialogRef = this.dialog.open(EditDialogComponent, {
data: { data: {
confirmKey: 'ACTIONS.CHANGE', confirmKey: 'ACTIONS.CHANGE',
@@ -157,43 +254,45 @@ export class UserDetailComponent implements OnInit {
labelKey: 'ACTIONS.NEWVALUE', labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE', titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE',
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC', descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC',
value: this.user.userName, value: user.username,
}, },
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp: { value: string }) => { dialogRef
if (resp.value && resp.value !== this.user.userName) { .afterClosed()
this.mgmtUserService .pipe(
.updateUserName(this.user.id, resp.value) map(({ value }: { value?: string }) => value),
.then(() => { filter(Boolean),
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true); filter((value) => user.username != value),
this.refreshUser(); switchMap((username) => this.userService.updateUser({ userId: user.userId, username })),
}) )
.catch((error) => { .subscribe({
this.toast.showError(error); next: () => {
}); this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
} this.refreshChanges$.emit();
}); },
error: (error) => {
this.toast.showError(error);
},
});
} }
public unlockUser(): void { public unlockUser(user: UserV2): void {
const req = new UnlockUserRequest(); this.userService
req.setId(this.user.id); .unlockUser(user.userId)
this.mgmtUserService
.unlockUser(req)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.UNLOCKED', true); this.toast.showInfo('USER.TOAST.UNLOCKED', true);
this.refreshUser(); this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public generateMachineSecret(): void { public generateMachineSecret(user: UserV2): void {
this.mgmtUserService this.newMgmtService
.generateMachineSecret(this.user.id) .generateMachineSecret(user.userId)
.then((resp) => { .then((resp) => {
this.toast.showInfo('USER.TOAST.SECRETGENERATED', true); this.toast.showInfo('USER.TOAST.SECRETGENERATED', true);
this.dialog.open(MachineSecretDialogComponent, { this.dialog.open(MachineSecretDialogComponent, {
@@ -203,64 +302,41 @@ export class UserDetailComponent implements OnInit {
}, },
width: '400px', width: '400px',
}); });
this.refreshUser(); this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public removeMachineSecret(): void { public removeMachineSecret(user: UserV2): void {
this.mgmtUserService this.newMgmtService
.removeMachineSecret(this.user.id) .removeMachineSecret(user.userId)
.then((resp) => { .then(() => {
this.toast.showInfo('USER.TOAST.SECRETREMOVED', true); this.toast.showInfo('USER.TOAST.SECRETREMOVED', true);
this.refreshUser(); this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public changeState(newState: UserState): void { public changeState(user: UserV2, newState: UserState): void {
if (newState === UserState.USER_STATE_ACTIVE) { if (newState === UserState.ACTIVE) {
this.mgmtUserService this.userService
.reactivateUser(this.user.id) .reactivateUser(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.REACTIVATED', true); this.toast.showInfo('USER.TOAST.REACTIVATED', true);
this.user.state = newState; this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} else if (newState === UserState.USER_STATE_INACTIVE) { } else if (newState === UserState.INACTIVE) {
this.mgmtUserService this.userService
.deactivateUser(this.user.id) .deactivateUser(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.DEACTIVATED', true); this.toast.showInfo('USER.TOAST.DEACTIVATED', true);
this.user.state = newState;
})
.catch((error) => {
this.toast.showError(error);
});
}
}
public saveProfile(profileData: Profile.AsObject): void {
if (this.user.human) {
this.user.human.profile = profileData;
this.mgmtUserService
.updateHumanProfile(
this.user.id,
this.user.human.profile.firstName,
this.user.human.profile.lastName,
this.user.human.profile.nickName,
this.user.human.profile.displayName,
this.user.human.profile.preferredLanguage,
this.user.human.profile.gender,
)
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
@@ -269,32 +345,48 @@ export class UserDetailComponent implements OnInit {
} }
} }
public saveMachine(machineData: Machine.AsObject): void { public saveProfile(user: UserV2, profile: HumanProfile): void {
if (this.user.machine) { this.userService
this.user.machine.name = machineData.name; .updateUser({
this.user.machine.description = machineData.description; userId: user.userId,
this.user.machine.accessTokenType = machineData.accessTokenType; profile: {
givenName: profile.givenName,
this.mgmtUserService familyName: profile.familyName,
.updateMachine( nickName: profile.nickName,
this.user.id, displayName: profile.displayName,
this.user.machine.name, preferredLanguage: profile.preferredLanguage,
this.user.machine.description, gender: profile.gender,
this.user.machine.accessTokenType, },
) })
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true); this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
}
} }
public resendEmailVerification(): void { public saveMachine(user: UserV2, form: ObservedValueOf<DetailFormMachineComponent['submitData']>): void {
this.mgmtUserService this.newMgmtService
.resendHumanEmailVerification(this.user.id) .updateMachine({
userId: user.userId,
name: form.name,
description: form.description,
accessTokenType: form.accessTokenType,
})
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public resendEmailVerification(user: UserV2): void {
this.newMgmtService
.resendHumanEmailVerification(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@@ -304,9 +396,9 @@ export class UserDetailComponent implements OnInit {
}); });
} }
public resendPhoneVerification(): void { public resendPhoneVerification(user: UserV2): void {
this.mgmtUserService this.newMgmtService
.resendHumanPhoneVerification(this.user.id) .resendHumanPhoneVerification(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@@ -316,79 +408,25 @@ export class UserDetailComponent implements OnInit {
}); });
} }
public deletePhone(): void { public deletePhone(user: UserV2): void {
this.mgmtUserService this.userService
.removeHumanPhone(this.user.id) .removePhone(user.userId)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
if (this.user.human) { this.refreshChanges$.emit();
this.user.human.phone = new Phone().setPhone('').toObject();
this.refreshUser();
}
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public saveEmail(email: string, isVerified: boolean): void {
if (this.user.id && email) {
this.mgmtUserService
.updateHumanEmail(this.user.id, email, isVerified)
.then(() => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true);
if (this.user.state === UserState.USER_STATE_INITIAL) {
this.mgmtUserService
.resendHumanInitialization(this.user.id, email ?? '')
.then(() => {
this.toast.showInfo('USER.TOAST.INITEMAILSENT', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
if (this.user.human) {
this.user.human.email = new Email().setEmail(email).toObject();
this.refreshUser();
}
})
.catch((error) => {
this.toast.showError(error);
});
}
}
public savePhone(phone: string): void {
if (this.user.id && phone) {
// Format phone before save (add +)
const formattedPhone = formatPhone(phone);
if (formattedPhone) {
phone = formattedPhone.phone;
}
this.mgmtUserService
.updateHumanPhone(this.user.id, phone)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
if (this.user.human) {
this.user.human.phone = new Phone().setPhone(phone).toObject();
this.refreshUser();
}
})
.catch((error) => {
this.toast.showError(error);
});
}
}
public navigateBack(): void { public navigateBack(): void {
this._location.back(); this._location.back();
} }
public sendSetPasswordNotification(): void { public sendSetPasswordNotification(user: UserV2): void {
this.mgmtUserService this.newMgmtService
.sendHumanResetPasswordNotification(this.user.id, SendHumanResetPasswordNotificationRequest.Type.TYPE_EMAIL) .sendHumanResetPasswordNotification(user.userId, SendHumanResetPasswordNotificationRequest_Type.EMAIL)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.PASSWORDNOTIFICATIONSENT', true); this.toast.showInfo('USER.TOAST.PASSWORDNOTIFICATIONSENT', true);
this.refreshChanges$.emit(); this.refreshChanges$.emit();
@@ -398,143 +436,189 @@ export class UserDetailComponent implements OnInit {
}); });
} }
public deleteUser(): void { public deleteUser(user: UserV2): void {
const dialogRef = this.dialog.open(WarnDialogComponent, { const data = {
data: { confirmKey: 'ACTIONS.DELETE',
confirmKey: 'ACTIONS.DELETE', cancelKey: 'ACTIONS.CANCEL',
cancelKey: 'ACTIONS.CANCEL', titleKey: 'USER.DIALOG.DELETE_TITLE',
titleKey: 'USER.DIALOG.DELETE_TITLE', descriptionKey: 'USER.DIALOG.DELETE_DESCRIPTION',
descriptionKey: 'USER.DIALOG.DELETE_DESCRIPTION', };
},
const dialogRef = this.dialog.open<WarnDialogComponent, typeof data, boolean>(WarnDialogComponent, {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp) { .afterClosed()
this.mgmtUserService .pipe(
.removeUser(this.user.id) filter(Boolean),
.then(() => { switchMap(() => this.userService.deleteUser(user.userId)),
const params: Params = { )
deferredReload: true, .subscribe({
type: this.user.human ? 'humans' : 'machines', next: () => {
}; const params: Params = {
this.router.navigate(['/users'], { queryParams: params }); deferredReload: true,
this.toast.showInfo('USER.TOAST.DELETED', true); type: user.type.case === 'human' ? 'humans' : 'machines',
}) };
.catch((error) => { this.router.navigate(['/users'], { queryParams: params }).then();
this.toast.showError(error); this.toast.showInfo('USER.TOAST.DELETED', true);
}); },
} error: (error) => this.toast.showError(error),
}); });
} }
public resendInitEmail(): void { public resendInitEmail(user: UserV2): void {
const dialogRef = this.dialog.open(ResendEmailDialogComponent, { const dialogRef = this.dialog.open<ResendEmailDialogComponent, ResendEmailDialogData, ResendEmailDialogResult>(
width: '400px', ResendEmailDialogComponent,
data: { {
email: this.user.human?.email?.email ?? '', width: '400px',
data: {
email: user.type.case === 'human' ? (user.type.value.email?.email ?? '') : '',
},
}, },
}); );
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp.send && this.user.id) { .afterClosed()
this.mgmtUserService .pipe(
.resendHumanInitialization(this.user.id, resp.email ?? '') filter((resp): resp is { send: true; email: string } => !!resp?.send && !!user.userId),
.then(() => { switchMap(({ email }) => this.newMgmtService.resendHumanInitialization(user.userId, email)),
this.toast.showInfo('USER.TOAST.INITEMAILSENT', true); )
this.refreshChanges$.emit(); .subscribe({
}) next: () => {
.catch((error) => { this.toast.showInfo('USER.TOAST.INITEMAILSENT', true);
this.toast.showError(error); this.refreshChanges$.emit();
}); },
} error: (error) => this.toast.showError(error),
}); });
} }
public openEditDialog(type: EditDialogType): void { public openEditDialog(user: UserWithHumanType, type: EditDialogType): void {
switch (type) { switch (type) {
case EditDialogType.PHONE: case EditDialogType.PHONE:
const dialogRefPhone = this.dialog.open(EditDialogComponent, { this.openEditPhoneDialog(user);
data: { return;
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.PHONE.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.PHONE.EDITDESC',
value: this.user.human?.phone?.phone,
type: EditDialogType.PHONE,
validator: Validators.compose([phoneValidator, requiredValidator]),
},
width: '400px',
});
dialogRefPhone.afterClosed().subscribe((resp: { value: string; isVerified: boolean }) => {
if (resp && resp.value) {
this.savePhone(resp.value);
}
});
break;
case EditDialogType.EMAIL: case EditDialogType.EMAIL:
const dialogRefEmail = this.dialog.open(EditDialogComponent, { this.openEditEmailDialog(user);
data: { return;
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.EMAIL.EDITDESC',
isVerifiedTextKey: 'USER.LOGINMETHODS.EMAIL.ISVERIFIED',
isVerifiedTextDescKey: 'USER.LOGINMETHODS.EMAIL.ISVERIFIEDDESC',
value: this.user.human?.email?.email,
type: EditDialogType.EMAIL,
},
width: '400px',
});
dialogRefEmail.afterClosed().subscribe((resp: { value: string; isVerified: boolean }) => {
if (resp && resp.value) {
this.saveEmail(resp.value, resp.isVerified);
}
});
break;
} }
} }
public loadMetadata(id: string): Promise<any> | void { private openEditEmailDialog(user: UserWithHumanType) {
this.loadingMetadata = true; const data: EditDialogData = {
return this.mgmtUserService confirmKey: 'ACTIONS.SAVE',
.listUserMetadata(id) cancelKey: 'ACTIONS.CANCEL',
.then((resp) => { labelKey: 'ACTIONS.NEWVALUE',
this.loadingMetadata = false; titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE',
this.metadata = resp.resultList.map((md) => { descriptionKey: 'USER.LOGINMETHODS.EMAIL.EDITDESC',
return { isVerifiedTextKey: 'USER.LOGINMETHODS.EMAIL.ISVERIFIED',
key: md.key, isVerifiedTextDescKey: 'USER.LOGINMETHODS.EMAIL.ISVERIFIEDDESC',
value: Buffer.from(md.value as string, 'base64').toString('utf-8'), value: user.type.value?.email?.email,
}; type: EditDialogType.EMAIL,
}); } as const;
})
.catch((error) => { const dialogRefEmail = this.dialog.open<EditDialogComponent, EditDialogData, EditDialogResult>(EditDialogComponent, {
this.loadingMetadata = false; data,
this.toast.showError(error); width: '400px',
});
dialogRefEmail
.afterClosed()
.pipe(
filter((resp): resp is Required<EditDialogResult> => !!resp?.value),
switchMap(({ value, isVerified }) =>
this.userService.setEmail({
userId: user.userId,
email: value,
verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined },
}),
),
switchMap(() => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true);
this.refreshChanges$.emit();
if (user.state !== UserState.INITIAL) {
return EMPTY;
}
return this.userService.resendInviteCode(user.userId);
}),
)
.subscribe({
next: () => this.toast.showInfo('USER.TOAST.INITEMAILSENT', true),
error: (error) => this.toast.showError(error),
}); });
} }
public editMetadata(): void { private openEditPhoneDialog(user: UserWithHumanType) {
if (this.user) { const data = {
const setFcn = (key: string, value: string): Promise<any> => confirmKey: 'ACTIONS.SAVE',
this.mgmtUserService.setUserMetadata(key, Buffer.from(value).toString('base64'), this.user.id); cancelKey: 'ACTIONS.CANCEL',
const removeFcn = (key: string): Promise<any> => this.mgmtUserService.removeUserMetadata(key, this.user.id); labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.PHONE.EDITTITLE',
descriptionKey: 'USER.LOGINMETHODS.PHONE.EDITDESC',
value: user.type.value.phone?.phone,
type: EditDialogType.PHONE,
validator: Validators.compose([phoneValidator, requiredValidator]),
};
const dialogRefPhone = this.dialog.open<EditDialogComponent, typeof data, { value: string; isVerified: boolean }>(
EditDialogComponent,
{ data, width: '400px' },
);
const dialogRef = this.dialog.open(MetadataDialogComponent, { dialogRefPhone
data: { .afterClosed()
metadata: this.metadata, .pipe(
setFcn: setFcn, map((resp) => formatPhone(resp?.value)),
removeFcn: removeFcn, filter(Boolean),
switchMap(({ phone }) => this.userService.setPhone({ userId: user.userId, phone })),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshChanges$.emit();
},
error: (error) => {
this.toast.showError(error);
}, },
}); });
}
dialogRef.afterClosed().subscribe(() => { private getMetadataById(userId: string): Observable<MetadataQuery> {
this.loadMetadata(this.user.id); return defer(() => this.newMgmtService.listUserMetadata(userId)).pipe(
map((metadata) => ({ state: 'success', value: metadata.result }) as const),
startWith({ state: 'loading', value: [] as Metadata[] } as const),
catchError((err) => of({ state: 'error', value: err.message ?? '' } as const)),
);
}
public editMetadata(user: UserV2, metadata: Metadata[]): void {
const setFcn = (key: string, value: string) =>
this.newMgmtService.setUserMetadata({
key,
value: Buffer.from(value),
id: user.userId,
}); });
const removeFcn = (key: string): Promise<any> => this.newMgmtService.removeUserMetadata({ key, id: user.userId });
const dialogRef = this.dialog.open<MetadataDialogComponent, MetadataDialogData>(MetadataDialogComponent, {
data: {
metadata: [...metadata],
setFcn: setFcn,
removeFcn: removeFcn,
},
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.refreshMetadata$.next(true);
});
}
public humanUser(user: UserV2): UserWithHumanType | undefined {
if (user.type.case === 'human') {
return { ...user, type: user.type };
} }
return undefined;
} }
} }

View File

@@ -1,8 +1,12 @@
<cnsl-card title="{{ 'USER.MFA.TITLE' | translate }}" description="{{ 'USER.MFA.DESCRIPTION' | translate }}"> <cnsl-card
*ngIf="mfaQuery$ | async as mfaQuery"
title="{{ 'USER.MFA.TITLE' | translate }}"
description="{{ 'USER.MFA.DESCRIPTION' | translate }}"
>
<button <button
card-actions card-actions
mat-icon-button mat-icon-button
(click)="getMFAs()" (click)="refresh$.next(true)"
class="icon-button" class="icon-button"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}" matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
> >
@@ -10,26 +14,26 @@
</button> </button>
<cnsl-refresh-table <cnsl-refresh-table
[hideRefresh]="true" [hideRefresh]="true"
[loading]="loading$ | async" [loading]="mfaQuery.state === 'loading'"
(refreshed)="getMFAs()" (refreshed)="refresh$.next(true)"
[dataSize]="dataSource.data.length" [dataSize]="mfaQuery.value.data.length"
> >
<table class="table" mat-table [dataSource]="dataSource"> <table class="table" mat-table [dataSource]="mfaQuery.value">
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.TABLETYPE' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.TABLETYPE' | translate }}</th>
<td mat-cell *matCellDef="let mfa"> <td mat-cell *matCellDef="let mfa">
<span *ngIf="mfa.otp !== undefined">TOTP (Time-based One-Time Password)</span> <span *ngIf="mfa.type.case === 'otp'">TOTP (Time-based One-Time Password)</span>
<span *ngIf="mfa.u2f !== undefined">U2F (Universal 2nd Factor)</span> <span *ngIf="mfa.type.case === 'u2f'">U2F (Universal 2nd Factor)</span>
<span *ngIf="mfa.otpSms !== undefined">One-Time Password SMS</span> <span *ngIf="mfa.type.case === 'otpSms'">One-Time Password SMS</span>
<span *ngIf="mfa.otpEmail !== undefined">One-Time Password Email</span> <span *ngIf="mfa.type.case === 'otpEmail'">One-Time Password Email</span>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.NAME' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.NAME' | translate }}</th>
<td mat-cell *matCellDef="let mfa"> <td mat-cell *matCellDef="let mfa">
<span *ngIf="mfa?.u2f?.name" class="centered"> <span *ngIf="mfa.type.case === 'u2f'" class="centered">
{{ mfa.u2f.name }} {{ mfa.type.value.name }}
</span> </span>
</td> </td>
</ng-container> </ng-container>
@@ -40,8 +44,8 @@
<span <span
class="state" class="state"
[ngClass]="{ [ngClass]="{
active: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_READY, active: mfa.state === AuthFactorState.READY,
inactive: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_NOT_READY, inactive: mfa.state === AuthFactorState.NOT_READY,
}" }"
> >
{{ 'USER.MFA.STATE.' + mfa.state | translate }} {{ 'USER.MFA.STATE.' + mfa.state | translate }}
@@ -58,7 +62,7 @@
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}" matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn" color="warn"
mat-icon-button mat-icon-button
(click)="deleteMFA(mfa)" (click)="deleteMFA(mfaQuery.user, mfa)"
> >
<i class="las la-trash"></i> <i class="las la-trash"></i>
</button> </button>
@@ -69,13 +73,13 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table> </table>
<div *ngIf="(loading$ | async) === false && !dataSource?.data?.length" class="no-content-row"> <div *ngIf="mfaQuery.state === 'success' && !mfaQuery.value.data.length" class="no-content-row">
<i class="las la-exclamation"></i> <i class="las la-exclamation"></i>
<span>{{ 'USER.MFA.EMPTY' | translate }}</span> <span>{{ 'USER.MFA.EMPTY' | translate }}</span>
</div> </div>
</cnsl-refresh-table> </cnsl-refresh-table>
<div class="table-wrapper"> <div class="table-wrapper">
<div class="spinner-container" *ngIf="loading$ | async"> <div class="spinner-container" *ngIf="mfaQuery.state === 'loading'">
<mat-spinner diameter="50"></mat-spinner> <mat-spinner diameter="50"></mat-spinner>
</div> </div>
</div> </div>

View File

@@ -1,65 +1,104 @@
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, Input, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTable, MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs'; import { combineLatestWith, defer, EMPTY, Observable, ReplaySubject, Subject, switchMap } from 'rxjs';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { AuthFactor, AuthFactorState, User } from 'src/app/proto/generated/zitadel/user_pb';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { UserService } from 'src/app/services/user.service';
import { AuthFactor, AuthFactorState, User } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { catchError, filter, map, startWith } from 'rxjs/operators';
import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith';
export interface MFAItem { export interface MFAItem {
name: string; name: string;
verified: boolean; verified: boolean;
} }
type MFAQuery =
| { state: 'success'; value: MatTableDataSource<AuthFactor>; user: User }
| { state: 'loading'; value: MatTableDataSource<AuthFactor>; user: User };
@Component({ @Component({
selector: 'cnsl-user-mfa', selector: 'cnsl-user-mfa',
templateUrl: './user-mfa.component.html', templateUrl: './user-mfa.component.html',
styleUrls: ['./user-mfa.component.scss'], styleUrls: ['./user-mfa.component.scss'],
}) })
export class UserMfaComponent implements OnInit, OnDestroy { export class UserMfaComponent {
public displayedColumns: string[] = ['type', 'name', 'state', 'actions']; @Input({ required: true }) public set user(user: User) {
@Input() public user!: User.AsObject; this.user$.next(user);
public mfaSubject: BehaviorSubject<AuthFactor.AsObject[]> = new BehaviorSubject<AuthFactor.AsObject[]>([]); }
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@ViewChild(MatTable) public table!: MatTable<AuthFactor.AsObject>;
@ViewChild(MatSort) public sort!: MatSort; @ViewChild(MatSort) public sort!: MatSort;
public dataSource: MatTableDataSource<AuthFactor.AsObject> = new MatTableDataSource<AuthFactor.AsObject>([]); public dataSource = new MatTableDataSource<AuthFactor>([]);
public AuthFactorState: any = AuthFactorState; public displayedColumns: string[] = ['type', 'name', 'state', 'actions'];
private user$ = new ReplaySubject<User>(1);
public mfaQuery$: Observable<MFAQuery>;
public refresh$ = new Subject<true>();
public AuthFactorState = AuthFactorState;
public error: string = '';
constructor( constructor(
private mgmtUserService: ManagementService, private readonly dialog: MatDialog,
private dialog: MatDialog, private readonly toast: ToastService,
private toast: ToastService, private readonly userService: UserService,
) {} ) {
this.mfaQuery$ = this.user$.pipe(
public ngOnInit(): void { combineLatestWith(this.refresh$.pipe(startWith(true))),
this.getMFAs(); switchMap(([user]) => this.listAuthenticationFactors(user)),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { ...prev, state: 'loading' } as const;
}
return curr;
}),
catchError((error) => {
this.toast.showError(error);
return EMPTY;
}),
);
} }
ngOnDestroy(): void { private listAuthenticationFactors(user: User): Observable<MFAQuery> {
this.mfaSubject.complete(); return defer(() => this.userService.listAuthenticationFactors({ userId: user.userId })).pipe(
this.loadingSubject.complete(); map(
({ result }) =>
({
state: 'success',
value: new MatTableDataSource<AuthFactor>(result),
user,
}) as const,
),
startWith({
state: 'loading',
value: new MatTableDataSource<AuthFactor>([]),
user,
} as const),
);
} }
public getMFAs(): void { private async removeTOTP(user: User) {
this.mgmtUserService await this.userService.removeTOTP(user.userId);
.listHumanMultiFactors(this.user.id) return ['USER.TOAST.OTPREMOVED', 'otp'] as const;
.then((mfas) => {
this.dataSource = new MatTableDataSource(mfas.resultList);
this.dataSource.sort = this.sort;
})
.catch((error) => {
this.error = error.message;
});
} }
public deleteMFA(factor: AuthFactor.AsObject): void { private async removeU2F(user: User, u2fId: string) {
await this.userService.removeU2F(user.userId, u2fId);
return ['USER.TOAST.U2FREMOVED', 'u2f'] as const;
}
private async removeOTPEmail(user: User) {
await this.userService.removeOTPEmail(user.userId);
return ['USER.TOAST.OTPREMOVED', 'otpEmail'] as const;
}
private async removeOTPSMS(user: User) {
await this.userService.removeOTPSMS(user.userId);
return ['USER.TOAST.OTPREMOVED', 'otpSms'] as const;
}
public deleteMFA(user: User, factor: AuthFactor): void {
const dialogRef = this.dialog.open(WarnDialogComponent, { const dialogRef = this.dialog.open(WarnDialogComponent, {
data: { data: {
confirmKey: 'ACTIONS.DELETE', confirmKey: 'ACTIONS.DELETE',
@@ -70,70 +109,35 @@ export class UserMfaComponent implements OnInit, OnDestroy {
width: '400px', width: '400px',
}); });
dialogRef.afterClosed().subscribe((resp) => { dialogRef
if (resp) { .afterClosed()
if (factor.otp) { .pipe(
this.mgmtUserService filter(Boolean),
.removeHumanMultiFactorOTP(this.user.id) switchMap(() => {
.then(() => { switch (factor.type.case) {
this.toast.showInfo('USER.TOAST.OTPREMOVED', true); case 'otp':
return this.removeTOTP(user);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.otp); case 'u2f':
if (index > -1) { return this.removeU2F(user, factor.type.value.id);
this.dataSource.data.splice(index, 1); case 'otpEmail':
} return this.removeOTPEmail(user);
this.getMFAs(); case 'otpSms':
}) return this.removeOTPSMS(user);
.catch((error) => { default:
this.toast.showError(error); throw new Error('Unknown MFA type');
}); }
} else if (factor.u2f) { }),
this.mgmtUserService )
.removeHumanAuthFactorU2F(this.user.id, factor.u2f.id) .subscribe({
.then(() => { next: ([translation, caseId]) => {
this.toast.showInfo('USER.TOAST.U2FREMOVED', true); this.toast.showInfo(translation, true);
const index = this.dataSource.data.findIndex((mfa) => mfa.type.case === caseId);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.u2f); if (index > -1) {
if (index > -1) { this.dataSource.data.splice(index, 1);
this.dataSource.data.splice(index, 1); }
} this.refresh$.next(true);
this.getMFAs(); },
}) error: (error) => this.toast.showError(error),
.catch((error) => { });
this.toast.showError(error);
});
} else if (factor.otpEmail) {
this.mgmtUserService
.removeHumanAuthFactorOTPEmail(this.user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.OTPREMOVED', true);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.otpEmail);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
})
.catch((error) => {
this.toast.showError(error);
});
} else if (factor.otpSms) {
this.mgmtUserService
.removeHumanAuthFactorOTPSMS(this.user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.OTPREMOVED', true);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.otpSms);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
})
.catch((error) => {
this.toast.showError(error);
});
}
}
});
} }
} }

View File

@@ -11,21 +11,20 @@
<div leftActions class="user-toggle-group"> <div leftActions class="user-toggle-group">
<cnsl-nav-toggle <cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.HUMANS.TITLE' | translate }}" label="{{ 'DESCRIPTIONS.USERS.HUMANS.TITLE' | translate }}"
(clicked)="setType(Type.TYPE_HUMAN)" (clicked)="setType(Type.HUMAN)"
[active]="type === Type.TYPE_HUMAN" [active]="type === Type.HUMAN"
data-e2e="list-humans" data-e2e="list-humans"
></cnsl-nav-toggle> ></cnsl-nav-toggle>
<cnsl-nav-toggle <cnsl-nav-toggle
label="{{ 'DESCRIPTIONS.USERS.MACHINES.TITLE' | translate }}" label="{{ 'DESCRIPTIONS.USERS.MACHINES.TITLE' | translate }}"
(clicked)="setType(Type.TYPE_MACHINE)" (clicked)="setType(Type.MACHINE)"
[active]="type === Type.TYPE_MACHINE" [active]="type === Type.MACHINE"
data-e2e="list-machines" data-e2e="list-machines"
></cnsl-nav-toggle> ></cnsl-nav-toggle>
</div> </div>
<p class="user-sub cnsl-secondary-text"> <p class="user-sub cnsl-secondary-text">
{{ {{
(type === Type.TYPE_HUMAN ? 'DESCRIPTIONS.USERS.HUMANS.DESCRIPTION' : 'DESCRIPTIONS.USERS.MACHINES.DESCRIPTION') (type === Type.HUMAN ? 'DESCRIPTIONS.USERS.HUMANS.DESCRIPTION' : 'DESCRIPTIONS.USERS.MACHINES.DESCRIPTION') | translate
| translate
}} }}
</p> </p>
<ng-template cnslHasRole [hasRole]="['user.write']" actions> <ng-template cnslHasRole [hasRole]="['user.write']" actions>
@@ -66,7 +65,7 @@
></cnsl-filter-user> ></cnsl-filter-user>
<ng-template cnslHasRole [hasRole]="['user.write']" actions> <ng-template cnslHasRole [hasRole]="['user.write']" actions>
<button <button
(click)="gotoRouterLink(['/users', type === Type.TYPE_HUMAN ? 'create' : 'create-machine'])" (click)="gotoRouterLink(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
color="primary" color="primary"
mat-raised-button mat-raised-button
[disabled]="(canWrite$ | async) === false" [disabled]="(canWrite$ | async) === false"
@@ -78,7 +77,7 @@
<span>{{ 'ACTIONS.NEW' | translate }}</span> <span>{{ 'ACTIONS.NEW' | translate }}</span>
<cnsl-action-keys <cnsl-action-keys
*ngIf="!filterOpen" *ngIf="!filterOpen"
(actionTriggered)="gotoRouterLink(['/users', type === Type.TYPE_HUMAN ? 'create' : 'create-machine'])" (actionTriggered)="gotoRouterLink(['/users', type === Type.HUMAN ? 'create' : 'create-machine'])"
> >
</cnsl-action-keys> </cnsl-action-keys>
</div> </div>
@@ -115,10 +114,10 @@
[checked]="selection.isSelected(user)" [checked]="selection.isSelected(user)"
> >
<cnsl-avatar <cnsl-avatar
*ngIf="user.human && user.human.profile; else cog" *ngIf="user.type.case === 'human' && user.type.value.profile; else cog"
class="avatar" class="avatar"
[name]="user.human.profile.displayName" [name]="user.type.value.profile.displayName"
[avatarUrl]="user.human.profile.avatarUrl || ''" [avatarUrl]="user.type.value.profile.avatarUrl || ''"
[forColor]="user.preferredLoginName" [forColor]="user.preferredLoginName"
[size]="32" [size]="32"
> >
@@ -142,9 +141,9 @@
> >
{{ 'USER.PROFILE.DISPLAYNAME' | translate }} {{ 'USER.PROFILE.DISPLAYNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.human">{{ user.human.profile?.displayName }}</span> <span *ngIf="user.type.case === 'human'">{{ user.type.value?.profile?.displayName }}</span>
<span *ngIf="user.machine">{{ user.machine.name }}</span> <span *ngIf="user.type.case === 'machine'">{{ user.type.value.name }}</span>
</td> </td>
</ng-container> </ng-container>
@@ -157,8 +156,8 @@
> >
{{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.human">{{ user.preferredLoginName }}</span> <span *ngIf="user.type.case === 'human'">{{ user.preferredLoginName }}</span>
</td> </td>
</ng-container> </ng-container>
@@ -171,8 +170,8 @@
> >
{{ 'USER.PROFILE.USERNAME' | translate }} {{ 'USER.PROFILE.USERNAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
{{ user.userName }} {{ user.username }}
</td> </td>
</ng-container> </ng-container>
@@ -185,36 +184,36 @@
> >
{{ 'USER.EMAIL' | translate }} {{ 'USER.EMAIL' | translate }}
</th> </th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span *ngIf="user.human?.email?.email">{{ user.human?.email.email }}</span> <span *ngIf="user.type?.value?.email?.email">{{ user.type.value.email.email }}</span>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="state"> <ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.DATA.STATE' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.DATA.STATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span <span
class="state" class="state"
[ngClass]="{ [ngClass]="{
active: user.state === UserState.USER_STATE_ACTIVE, active: user.state === UserState.ACTIVE,
inactive: user.state === UserState.USER_STATE_INACTIVE, inactive: user.state === UserState.INACTIVE,
}" }"
> >
{{ 'USER.DATA.STATE' + user.state | translate }} {{ 'USER.STATEV2.' + user.state | translate }}
</span> </span>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="creationDate"> <ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.TABLE.CREATIONDATE' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.TABLE.CREATIONDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }}</span> <span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="changeDate"> <ng-container matColumnDef="changeDate">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.TABLE.CHANGEDATE' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'USER.TABLE.CHANGEDATE' | translate }}</th>
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null"> <td mat-cell *matCellDef="let user" [routerLink]="user.userId ? ['/users', user.userId] : null">
<span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }}</span> <span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }}</span>
</td> </td>
</ng-container> </ng-container>
@@ -242,11 +241,11 @@
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="type === Type.TYPE_HUMAN ? displayedColumnsHuman : displayedColumnsMachine"></tr> <tr mat-header-row *matHeaderRowDef="type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"></tr>
<tr <tr
class="highlight pointer" class="highlight pointer"
mat-row mat-row
*matRowDef="let user; columns: type === Type.TYPE_HUMAN ? displayedColumnsHuman : displayedColumnsMachine" *matRowDef="let user; columns: type === Type.HUMAN ? displayedColumnsHuman : displayedColumnsMachine"
></tr> ></tr>
</table> </table>
</div> </div>
@@ -258,7 +257,6 @@
<cnsl-paginator <cnsl-paginator
#paginator #paginator
class="paginator" class="paginator"
[timestamp]="viewTimestamp"
[length]="totalResult || 0" [length]="totalResult || 0"
[pageSize]="INITIAL_PAGE_SIZE" [pageSize]="INITIAL_PAGE_SIZE"
[timestamp]="viewTimestamp" [timestamp]="viewTimestamp"

View File

@@ -1,6 +1,6 @@
import { LiveAnnouncer } from '@angular/cdk/a11y'; import { LiveAnnouncer } from '@angular/cdk/a11y';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output, Signal, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSort, Sort } from '@angular/material/sort'; import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
@@ -12,11 +12,15 @@ import { enterAnimations } from 'src/app/animations';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component';
import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component'; import { PageEvent, PaginatorComponent } from 'src/app/modules/paginator/paginator.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Timestamp } from 'src/app/proto/generated/google/protobuf/timestamp_pb';
import { SearchQuery, Type, TypeQuery, User, UserFieldName, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service';
import { UserService } from 'src/app/services/user.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
import { SearchQuery, SearchQuerySchema, Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb';
import { UserState, User as UserV2 } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { create } from '@bufbuild/protobuf';
import { Timestamp } from '@bufbuild/protobuf/wkt';
enum UserListSearchKey { enum UserListSearchKey {
FIRST_NAME, FIRST_NAME,
@@ -34,20 +38,22 @@ enum UserListSearchKey {
}) })
export class UserTableComponent implements OnInit { export class UserTableComponent implements OnInit {
public userSearchKey: UserListSearchKey | undefined = undefined; public userSearchKey: UserListSearchKey | undefined = undefined;
public Type: any = Type; public Type = Type;
@Input() public type: Type = Type.TYPE_HUMAN; @Input() public type: Type = Type.HUMAN;
@Input() refreshOnPreviousRoutes: string[] = []; @Input() refreshOnPreviousRoutes: string[] = [];
@Input() public canWrite$: Observable<boolean> = of(false); @Input() public canWrite$: Observable<boolean> = of(false);
@Input() public canDelete$: Observable<boolean> = of(false); @Input() public canDelete$: Observable<boolean> = of(false);
private user: Signal<User.AsObject | undefined> = toSignal(this.authService.user, { requireSync: true });
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; @ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
@ViewChild(MatSort) public sort!: MatSort; @ViewChild(MatSort) public sort!: MatSort;
public INITIAL_PAGE_SIZE: number = 20; public INITIAL_PAGE_SIZE: number = 20;
public viewTimestamp!: Timestamp.AsObject; public viewTimestamp!: Timestamp;
public totalResult: number = 0; public totalResult: number = 0;
public dataSource: MatTableDataSource<User.AsObject> = new MatTableDataSource<User.AsObject>(); public dataSource: MatTableDataSource<UserV2> = new MatTableDataSource<UserV2>();
public selection: SelectionModel<User.AsObject> = new SelectionModel<User.AsObject>(true, []); public selection: SelectionModel<UserV2> = new SelectionModel<UserV2>(true, []);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable(); public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@Input() public displayedColumnsHuman: string[] = [ @Input() public displayedColumnsHuman: string[] = [
@@ -70,7 +76,7 @@ export class UserTableComponent implements OnInit {
'actions', 'actions',
]; ];
@Output() public changedSelection: EventEmitter<Array<User.AsObject>> = new EventEmitter(); @Output() public changedSelection: EventEmitter<Array<UserV2>> = new EventEmitter();
public UserState: any = UserState; public UserState: any = UserState;
public UserListSearchKey: any = UserListSearchKey; public UserListSearchKey: any = UserListSearchKey;
@@ -83,7 +89,7 @@ export class UserTableComponent implements OnInit {
private router: Router, private router: Router,
public translate: TranslateService, public translate: TranslateService,
private authService: GrpcAuthService, private authService: GrpcAuthService,
private userService: ManagementService, private userService: UserService,
private toast: ToastService, private toast: ToastService,
private dialog: MatDialog, private dialog: MatDialog,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -97,12 +103,12 @@ export class UserTableComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.route.queryParams.pipe(take(1)).subscribe((params) => { this.route.queryParams.pipe(take(1)).subscribe((params) => {
if (!params['filter']) { if (!params['filter']) {
this.getData(this.INITIAL_PAGE_SIZE, 0, this.type, this.searchQueries); this.getData(this.INITIAL_PAGE_SIZE, 0, this.type, this.searchQueries).then();
} }
if (params['deferredReload']) { if (params['deferredReload']) {
setTimeout(() => { setTimeout(() => {
this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type); this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type).then();
}, 2000); }, 2000);
} }
}); });
@@ -110,16 +116,23 @@ export class UserTableComponent implements OnInit {
public setType(type: Type): void { public setType(type: Type): void {
this.type = type; this.type = type;
this.router.navigate([], { this.router
relativeTo: this.route, .navigate([], {
queryParams: { relativeTo: this.route,
type: type === Type.TYPE_HUMAN ? 'human' : type === Type.TYPE_MACHINE ? 'machine' : 'human', queryParams: {
}, type: type === Type.HUMAN ? 'human' : type === Type.MACHINE ? 'machine' : 'human',
replaceUrl: true, },
queryParamsHandling: 'merge', replaceUrl: true,
skipLocationChange: false, queryParamsHandling: 'merge',
}); skipLocationChange: false,
this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type, this.searchQueries); })
.then();
this.getData(
this.paginator.pageSize,
this.paginator.pageIndex * this.paginator.pageSize,
this.type,
this.searchQueries,
).then();
} }
public isAllSelected(): boolean { public isAllSelected(): boolean {
@@ -134,17 +147,17 @@ export class UserTableComponent implements OnInit {
public changePage(event: PageEvent): void { public changePage(event: PageEvent): void {
this.selection.clear(); this.selection.clear();
this.getData(event.pageSize, event.pageIndex * event.pageSize, this.type, this.searchQueries); this.getData(event.pageSize, event.pageIndex * event.pageSize, this.type, this.searchQueries).then();
} }
public deactivateSelectedUsers(): void { public deactivateSelectedUsers(): void {
Promise.all( const usersToDeactivate = this.selection.selected
this.selection.selected .filter((u) => u.state === UserState.ACTIVE)
.filter((u) => u.state === UserState.USER_STATE_ACTIVE) .map((value) => {
.map((value) => { return this.userService.deactivateUser(value.userId);
return this.userService.deactivateUser(value.id); });
}),
) Promise.all(usersToDeactivate)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true); this.toast.showInfo('USER.TOAST.SELECTEDDEACTIVATED', true);
this.selection.clear(); this.selection.clear();
@@ -158,13 +171,13 @@ export class UserTableComponent implements OnInit {
} }
public reactivateSelectedUsers(): void { public reactivateSelectedUsers(): void {
Promise.all( const usersToReactivate = this.selection.selected
this.selection.selected .filter((u) => u.state === UserState.INACTIVE)
.filter((u) => u.state === UserState.USER_STATE_INACTIVE) .map((value) => {
.map((value) => { return this.userService.reactivateUser(value.userId);
return this.userService.reactivateUser(value.id); });
}),
) Promise.all(usersToReactivate)
.then(() => { .then(() => {
this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true); this.toast.showInfo('USER.TOAST.SELECTEDREACTIVATED', true);
this.selection.clear(); this.selection.clear();
@@ -177,39 +190,43 @@ export class UserTableComponent implements OnInit {
}); });
} }
public gotoRouterLink(rL: any): void { public gotoRouterLink(rL: any): Promise<boolean> {
this.router.navigate(rL); return this.router.navigate(rL);
} }
private async getData(limit: number, offset: number, type: Type, searchQueries?: SearchQuery[]): Promise<void> { private async getData(limit: number, offset: number, type: Type, searchQueries?: SearchQuery[]): Promise<void> {
this.loadingSubject.next(true); this.loadingSubject.next(true);
let queryT = new SearchQuery(); let queryT = create(SearchQuerySchema, {
const typeQuery = new TypeQuery(); query: {
typeQuery.setType(type); case: 'typeQuery',
queryT.setTypeQuery(typeQuery); value: {
type,
},
},
});
let sortingField: UserFieldName | undefined = undefined; let sortingField: UserFieldName | undefined = undefined;
if (this.sort?.active && this.sort?.direction) if (this.sort?.active && this.sort?.direction)
switch (this.sort.active) { switch (this.sort.active) {
case 'displayName': case 'displayName':
sortingField = UserFieldName.USER_FIELD_NAME_DISPLAY_NAME; sortingField = UserFieldName.DISPLAY_NAME;
break; break;
case 'username': case 'username':
sortingField = UserFieldName.USER_FIELD_NAME_USER_NAME; sortingField = UserFieldName.USER_NAME;
break; break;
case 'preferredLoginName': case 'preferredLoginName':
// TODO: replace with preferred username sorting once implemented // TODO: replace with preferred username sorting once implemented
sortingField = UserFieldName.USER_FIELD_NAME_USER_NAME; sortingField = UserFieldName.USER_NAME;
break; break;
case 'email': case 'email':
sortingField = UserFieldName.USER_FIELD_NAME_EMAIL; sortingField = UserFieldName.EMAIL;
break; break;
case 'state': case 'state':
sortingField = UserFieldName.USER_FIELD_NAME_STATE; sortingField = UserFieldName.STATE;
break; break;
case 'creationDate': case 'creationDate':
sortingField = UserFieldName.USER_FIELD_NAME_CREATION_DATE; sortingField = UserFieldName.CREATION_DATE;
break; break;
} }
@@ -223,14 +240,14 @@ export class UserTableComponent implements OnInit {
) )
.then((resp) => { .then((resp) => {
if (resp.details?.totalResult) { if (resp.details?.totalResult) {
this.totalResult = resp.details?.totalResult; this.totalResult = Number(resp.details.totalResult);
} else { } else {
this.totalResult = 0; this.totalResult = 0;
} }
if (resp.details?.viewTimestamp) { if (resp.details?.timestamp) {
this.viewTimestamp = resp.details?.viewTimestamp; this.viewTimestamp = resp.details?.timestamp;
} }
this.dataSource.data = resp.resultList; this.dataSource.data = resp.result;
this.loadingSubject.next(false); this.loadingSubject.next(false);
}) })
.catch((error) => { .catch((error) => {
@@ -240,15 +257,20 @@ export class UserTableComponent implements OnInit {
} }
public refreshPage(): void { public refreshPage(): void {
this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize, this.type, this.searchQueries); this.getData(
this.paginator.pageSize,
this.paginator.pageIndex * this.paginator.pageSize,
this.type,
this.searchQueries,
).then();
} }
public sortChange(sortState: Sort) { public sortChange(sortState: Sort) {
if (sortState.direction && sortState.active) { if (sortState.direction && sortState.active) {
this._liveAnnouncer.announce(`Sorted ${sortState.direction} ending`); this._liveAnnouncer.announce(`Sorted ${sortState.direction} ending`).then();
this.refreshPage(); this.refreshPage();
} else { } else {
this._liveAnnouncer.announce('Sorting cleared'); this._liveAnnouncer.announce('Sorting cleared').then();
} }
} }
@@ -260,10 +282,10 @@ export class UserTableComponent implements OnInit {
this.paginator ? this.paginator.pageIndex * this.paginator.pageSize : 0, this.paginator ? this.paginator.pageIndex * this.paginator.pageSize : 0,
this.type, this.type,
searchQueries, searchQueries,
); ).then();
} }
public deleteUser(user: User.AsObject): void { public deleteUser(user: UserV2): void {
const authUserData = { const authUserData = {
confirmKey: 'ACTIONS.DELETE', confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL', cancelKey: 'ACTIONS.CANCEL',
@@ -286,9 +308,9 @@ export class UserTableComponent implements OnInit {
confirmation: user.preferredLoginName, confirmation: user.preferredLoginName,
}; };
if (user && user.id) { if (user?.userId) {
const authUser = this.authService.userSubject.getValue(); const authUser = this.user();
const isMe = authUser?.id === user.id; const isMe = authUser?.id === user.userId;
let dialogRef; let dialogRef;
@@ -307,7 +329,7 @@ export class UserTableComponent implements OnInit {
dialogRef.afterClosed().subscribe((resp) => { dialogRef.afterClosed().subscribe((resp) => {
if (resp) { if (resp) {
this.userService this.userService
.removeUser(user.id) .deleteUser(user.userId)
.then(() => { .then(() => {
setTimeout(() => { setTimeout(() => {
this.refreshPage(); this.refreshPage();
@@ -325,11 +347,11 @@ export class UserTableComponent implements OnInit {
public get multipleActivatePossible(): boolean { public get multipleActivatePossible(): boolean {
const selected = this.selection.selected; const selected = this.selection.selected;
return selected ? selected.findIndex((user) => user.state !== UserState.USER_STATE_ACTIVE) > -1 : false; return selected ? selected.findIndex((user) => user.state !== UserState.ACTIVE) > -1 : false;
} }
public get multipleDeactivatePossible(): boolean { public get multipleDeactivatePossible(): boolean {
const selected = this.selection.selected; const selected = this.selection.selected;
return selected ? selected.findIndex((user) => user.state !== UserState.USER_STATE_INACTIVE) > -1 : false; return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false;
} }
} }

View File

@@ -1,18 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { Timestamp as ConnectTimestamp } from '@bufbuild/protobuf/wkt';
import { Timestamp } from 'src/app/proto/generated/google/protobuf/timestamp_pb';
@Pipe({ @Pipe({
name: 'timestampToDate', name: 'timestampToDate',
}) })
export class TimestampToDatePipe implements PipeTransform { export class TimestampToDatePipe implements PipeTransform {
transform(value: Timestamp.AsObject, ...args: unknown[]): unknown { transform(value: ConnectTimestamp | Timestamp.AsObject, ...args: unknown[]): unknown {
return this.dateFromTimestamp(value); return this.dateFromTimestamp(value);
} }
private dateFromTimestamp(date: Timestamp.AsObject): any { private dateFromTimestamp(date: ConnectTimestamp | Timestamp.AsObject): any {
if (date?.seconds !== undefined && date?.nanos !== undefined) { if (date?.seconds !== undefined && date?.nanos !== undefined) {
const ts: Date = new Date(date.seconds * 1000 + date.nanos / 1000 / 1000); return new Date(Number(date.seconds) * 1000 + date.nanos / 1000 / 1000);
return ts;
} }
} }
} }

View File

@@ -1,19 +1,19 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service'; import { GrpcService } from './grpc.service';
import {
GetInstanceFeaturesRequest,
GetInstanceFeaturesResponse,
ResetInstanceFeaturesRequest,
SetInstanceFeaturesRequest,
SetInstanceFeaturesResponse,
} from '../proto/generated/zitadel/feature/v2beta/instance_pb';
import { import {
GetOrganizationFeaturesRequest, GetOrganizationFeaturesRequest,
GetOrganizationFeaturesResponse, GetOrganizationFeaturesResponse,
} from '../proto/generated/zitadel/feature/v2beta/organization_pb'; } from '../proto/generated/zitadel/feature/v2beta/organization_pb';
import { GetUserFeaturesRequest, GetUserFeaturesResponse } from '../proto/generated/zitadel/feature/v2beta/user_pb'; import { GetUserFeaturesRequest, GetUserFeaturesResponse } from '../proto/generated/zitadel/feature/v2beta/user_pb';
import { GetSystemFeaturesRequest, GetSystemFeaturesResponse } from '../proto/generated/zitadel/feature/v2beta/system_pb'; import { GetSystemFeaturesRequest, GetSystemFeaturesResponse } from '../proto/generated/zitadel/feature/v2beta/system_pb';
import {
GetInstanceFeaturesRequest,
GetInstanceFeaturesResponse,
ResetInstanceFeaturesRequest,
SetInstanceFeaturesRequest,
SetInstanceFeaturesResponse,
} from '../proto/generated/zitadel/feature/v2/instance_pb';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',

View File

@@ -1,8 +1,22 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { OAuthService } from 'angular-oauth2-oidc'; import { OAuthService } from 'angular-oauth2-oidc';
import { BehaviorSubject, forkJoin, from, Observable, of, Subject } from 'rxjs'; import {
import { catchError, distinctUntilChanged, filter, finalize, map, switchMap, timeout, withLatestFrom } from 'rxjs/operators'; BehaviorSubject,
combineLatestWith,
defer,
distinctUntilKeyChanged,
EMPTY,
forkJoin,
mergeWith,
NEVER,
Observable,
of,
shareReplay,
Subject,
TimeoutError,
} from 'rxjs';
import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators';
import { import {
AddMyAuthFactorOTPEmailRequest, AddMyAuthFactorOTPEmailRequest,
@@ -110,41 +124,17 @@ const ORG_LIMIT = 10;
}) })
export class GrpcAuthService { export class GrpcAuthService {
private _activeOrgChanged: Subject<Org.AsObject | undefined> = new Subject(); private _activeOrgChanged: Subject<Org.AsObject | undefined> = new Subject();
public user!: Observable<User.AsObject | undefined>; public user: Observable<User.AsObject | undefined>;
public userSubject: BehaviorSubject<User.AsObject | undefined> = new BehaviorSubject<User.AsObject | undefined>(undefined);
private triggerPermissionsRefresh: Subject<void> = new Subject(); private triggerPermissionsRefresh: Subject<void> = new Subject();
public zitadelPermissions$: Observable<string[]> = this.triggerPermissionsRefresh.pipe( public zitadelPermissions: Observable<string[]>;
switchMap(() =>
from(this.listMyZitadelPermissions()).pipe(
map((rolesResp) => rolesResp.resultList),
filter((roles) => !!roles.length),
catchError((_) => {
return of([]);
}),
distinctUntilChanged((a, b) => {
return JSON.stringify(a.sort()) === JSON.stringify(b.sort());
}),
finalize(() => {
this.fetchedZitadelPermissions.next(true);
}),
),
),
);
public labelpolicy$!: Observable<LabelPolicy.AsObject>; public labelpolicy$!: Observable<LabelPolicy.AsObject>;
public labelpolicy: BehaviorSubject<LabelPolicy.AsObject | undefined> = new BehaviorSubject<
LabelPolicy.AsObject | undefined
>(undefined);
labelPolicyLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true); labelPolicyLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
public privacypolicy$!: Observable<PrivacyPolicy.AsObject>; public privacypolicy$: Observable<PrivacyPolicy.AsObject>;
public privacypolicy: BehaviorSubject<PrivacyPolicy.AsObject | undefined> = new BehaviorSubject< public privacypolicy: BehaviorSubject<PrivacyPolicy.AsObject | undefined> = new BehaviorSubject<
PrivacyPolicy.AsObject | undefined PrivacyPolicy.AsObject | undefined
>(undefined); >(undefined);
privacyPolicyLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
public zitadelPermissions: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
public readonly fetchedZitadelPermissions: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public cachedOrgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]); public cachedOrgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {}; private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {};
@@ -155,74 +145,63 @@ export class GrpcAuthService {
private oauthService: OAuthService, private oauthService: OAuthService,
private storage: StorageService, private storage: StorageService,
) { ) {
this.zitadelPermissions$.subscribe(this.zitadelPermissions);
this.labelpolicy$ = this.activeOrgChanged.pipe( this.labelpolicy$ = this.activeOrgChanged.pipe(
switchMap((org) => { tap(() => this.labelPolicyLoading$.next(true)),
this.labelPolicyLoading$.next(true); switchMap((org) => this.getMyLabelPolicy(org ? org.id : '')),
return from(this.getMyLabelPolicy(org ? org.id : '')); tap(() => this.labelPolicyLoading$.next(false)),
}), finalize(() => this.labelPolicyLoading$.next(false)),
filter((policy) => !!policy), filter((policy) => !!policy),
shareReplay({ refCount: true, bufferSize: 1 }),
); );
this.labelpolicy$.subscribe({
next: (policy) => {
this.labelpolicy.next(policy);
this.labelPolicyLoading$.next(false);
},
error: (error) => {
console.error(error);
this.labelPolicyLoading$.next(false);
},
});
this.privacypolicy$ = this.activeOrgChanged.pipe( this.privacypolicy$ = this.activeOrgChanged.pipe(
switchMap((org) => { switchMap((org) => this.getMyPrivacyPolicy(org ? org.id : '')),
this.privacyPolicyLoading$.next(true);
return from(this.getMyPrivacyPolicy(org ? org.id : ''));
}),
filter((policy) => !!policy), filter((policy) => !!policy),
catchError((err) => {
console.error(err);
return EMPTY;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
); );
this.privacypolicy$.subscribe({
next: (policy) => {
this.privacypolicy.next(policy);
this.privacyPolicyLoading$.next(false);
},
error: (error) => {
console.error(error);
this.privacyPolicyLoading$.next(false);
},
});
this.user = forkJoin([ this.user = forkJoin([
of(this.oauthService.getAccessToken()), defer(() => of(this.oauthService.getAccessToken())),
this.oauthService.events.pipe( this.oauthService.events.pipe(
filter((e) => e.type === 'token_received'), filter((e) => e.type === 'token_received'),
timeout(this.oauthService.waitForTokenInMsec || 0), timeout(this.oauthService.waitForTokenInMsec ?? 0),
catchError((_) => of(null)), // timeout is not an error catchError((err) => {
if (err instanceof TimeoutError) {
return of(null);
}
throw err;
}), // timeout is not an error
map((_) => this.oauthService.getAccessToken()), map((_) => this.oauthService.getAccessToken()),
), ),
]).pipe( ]).pipe(
filter((token) => (token ? true : false)), filter(([_, token]) => !!token),
distinctUntilChanged(), distinctUntilKeyChanged(1),
switchMap(() => { switchMap(() => this.getMyUser().then((resp) => resp.user)),
return from( startWith(undefined),
this.getMyUser().then((resp) => { shareReplay({ refCount: true, bufferSize: 1 }),
return resp.user;
}),
);
}),
finalize(() => {
this.loadPermissions();
}),
); );
this.user.subscribe(this.userSubject); this.zitadelPermissions = this.user.pipe(
combineLatestWith(this.activeOrgChanged),
this.activeOrgChanged.subscribe(() => { // ignore errors from observables
this.loadPermissions(); catchError(() => of(true)),
}); // make sure observable never completes
mergeWith(NEVER),
switchMap(() =>
this.listMyZitadelPermissions()
.then((resp) => resp.resultList)
.catch(() => <string[]>[]),
),
filter((roles) => !!roles.length),
distinctUntilChanged((a, b) => {
return JSON.stringify(a.sort()) === JSON.stringify(b.sort());
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
} }
public listMyMetadata( public listMyMetadata(
@@ -309,7 +288,7 @@ export class GrpcAuthService {
} }
public get activeOrgChanged(): Observable<Org.AsObject | undefined> { public get activeOrgChanged(): Observable<Org.AsObject | undefined> {
return this._activeOrgChanged; return this._activeOrgChanged.asObservable();
} }
public setActiveOrg(org: Org.AsObject): void { public setActiveOrg(org: Org.AsObject): void {
@@ -328,18 +307,15 @@ export class GrpcAuthService {
* @param roles roles of the user * @param roles roles of the user
*/ */
public isAllowed(roles: string[] | RegExp[], requiresAll: boolean = false): Observable<boolean> { public isAllowed(roles: string[] | RegExp[], requiresAll: boolean = false): Observable<boolean> {
if (roles && roles.length > 0) { if (!roles?.length) {
return this.fetchedZitadelPermissions.pipe(
withLatestFrom(this.zitadelPermissions),
filter(([hL, p]) => {
return hL === true && !!p.length;
}),
map(([_, zroles]) => this.hasRoles(zroles, roles, requiresAll)),
distinctUntilChanged(),
);
} else {
return of(false); return of(false);
} }
return this.zitadelPermissions.pipe(
filter((permissions) => !!permissions.length),
map((permissions) => this.hasRoles(permissions, roles, requiresAll)),
distinctUntilChanged(),
);
} }
/** /**
@@ -353,17 +329,14 @@ export class GrpcAuthService {
mapper: (attr: any) => string[] | RegExp[], mapper: (attr: any) => string[] | RegExp[],
requiresAll: boolean = false, requiresAll: boolean = false,
): Observable<T[]> { ): Observable<T[]> {
return this.fetchedZitadelPermissions.pipe( return this.zitadelPermissions.pipe(
withLatestFrom(this.zitadelPermissions), filter((permissions) => !!permissions.length),
filter(([hL, p]) => { map((permissions) =>
return hL === true && !!p.length; objects.filter((obj) => {
}),
map(([_, zroles]) => {
return objects.filter((obj) => {
const roles = mapper(obj); const roles = mapper(obj);
return this.hasRoles(zroles, roles, requiresAll); return this.hasRoles(permissions, roles, requiresAll);
}); }),
}), ),
); );
} }
@@ -395,19 +368,6 @@ export class GrpcAuthService {
.then((resp) => resp.toObject()); .then((resp) => resp.toObject());
} }
public loadMyUser(): void {
from(this.getMyUser())
.pipe(
map((resp) => resp.user),
catchError((_) => {
return of(undefined);
}),
)
.subscribe((user) => {
this.userSubject.next(user);
});
}
public getMyUser(): Promise<GetMyUserResponse.AsObject> { public getMyUser(): Promise<GetMyUserResponse.AsObject> {
return this.grpcService.auth.getMyUser(new GetMyUserRequest(), null).then((resp) => resp.toObject()); return this.grpcService.auth.getMyUser(new GetMyUserRequest(), null).then((resp) => resp.toObject());
} }
@@ -728,40 +688,39 @@ export class GrpcAuthService {
public getMyLabelPolicy(orgIdForCache?: string): Promise<LabelPolicy.AsObject> { public getMyLabelPolicy(orgIdForCache?: string): Promise<LabelPolicy.AsObject> {
if (orgIdForCache && this.cachedLabelPolicies[orgIdForCache]) { if (orgIdForCache && this.cachedLabelPolicies[orgIdForCache]) {
return Promise.resolve(this.cachedLabelPolicies[orgIdForCache]); return Promise.resolve(this.cachedLabelPolicies[orgIdForCache]);
} else {
return this.grpcService.auth
.getMyLabelPolicy(new GetMyLabelPolicyRequest(), null)
.then((resp) => resp.toObject())
.then((resp) => {
if (resp.policy) {
if (orgIdForCache) {
this.cachedLabelPolicies[orgIdForCache] = resp.policy;
}
return Promise.resolve(resp.policy);
} else {
return Promise.reject();
}
});
} }
return this.grpcService.auth
.getMyLabelPolicy(new GetMyLabelPolicyRequest(), null)
.then((resp) => resp.toObject())
.then((resp) => {
if (!resp.policy) {
return Promise.reject();
}
if (orgIdForCache) {
this.cachedLabelPolicies[orgIdForCache] = resp.policy;
}
return resp.policy;
});
} }
public getMyPrivacyPolicy(orgIdForCache?: string): Promise<PrivacyPolicy.AsObject> { public getMyPrivacyPolicy(orgIdForCache?: string): Promise<PrivacyPolicy.AsObject> {
if (orgIdForCache && this.cachedPrivacyPolicies[orgIdForCache]) { if (orgIdForCache && this.cachedPrivacyPolicies[orgIdForCache]) {
return Promise.resolve(this.cachedPrivacyPolicies[orgIdForCache]); return Promise.resolve(this.cachedPrivacyPolicies[orgIdForCache]);
} else {
return this.grpcService.auth
.getMyPrivacyPolicy(new GetMyPrivacyPolicyRequest(), null)
.then((resp) => resp.toObject())
.then((resp) => {
if (resp.policy) {
if (orgIdForCache) {
this.cachedPrivacyPolicies[orgIdForCache] = resp.policy;
}
return Promise.resolve(resp.policy);
} else {
return Promise.reject();
}
});
} }
return this.grpcService.auth
.getMyPrivacyPolicy(new GetMyPrivacyPolicyRequest(), null)
.then((resp) => resp.toObject())
.then((resp) => {
if (!resp.policy) {
return Promise.reject();
}
if (orgIdForCache) {
this.cachedPrivacyPolicies[orgIdForCache] = resp.policy;
}
return resp.policy;
});
} }
} }

View File

@@ -1,9 +1,8 @@
import { PlatformLocation } from '@angular/common'; import { PlatformLocation } from '@angular/common';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AuthConfig } from 'angular-oauth2-oidc'; import { AuthConfig } from 'angular-oauth2-oidc';
import { catchError, switchMap, tap, throwError } from 'rxjs'; import { catchError, firstValueFrom, switchMap, tap } from 'rxjs';
import { AdminServiceClient } from '../proto/generated/zitadel/AdminServiceClientPb'; import { AdminServiceClient } from '../proto/generated/zitadel/AdminServiceClientPb';
import { AuthServiceClient } from '../proto/generated/zitadel/AuthServiceClientPb'; import { AuthServiceClient } from '../proto/generated/zitadel/AuthServiceClientPb';
@@ -12,13 +11,18 @@ import { fallbackLanguage, supportedLanguagesRegexp } from '../utils/language';
import { AuthenticationService } from './authentication.service'; import { AuthenticationService } from './authentication.service';
import { EnvironmentService } from './environment.service'; import { EnvironmentService } from './environment.service';
import { ExhaustedService } from './exhausted.service'; import { ExhaustedService } from './exhausted.service';
import { AuthInterceptor } from './interceptors/auth.interceptor'; import { AuthInterceptor, AuthInterceptorProvider, NewConnectWebAuthInterceptor } from './interceptors/auth.interceptor';
import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.interceptor'; import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.interceptor';
import { I18nInterceptor } from './interceptors/i18n.interceptor'; import { I18nInterceptor } from './interceptors/i18n.interceptor';
import { OrgInterceptor } from './interceptors/org.interceptor'; import { OrgInterceptor } from './interceptors/org.interceptor';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import { FeatureServiceClient } from '../proto/generated/zitadel/feature/v2beta/Feature_serviceServiceClientPb'; import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb';
import { GrpcAuthService } from './grpc-auth.service'; //@ts-ignore
import { createUserServiceClient } from '@zitadel/client/v2';
//@ts-ignore
import { createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { FeatureServiceClient } from '../proto/generated/zitadel/feature/v2/Feature_serviceServiceClientPb';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -28,15 +32,20 @@ export class GrpcService {
public mgmt!: ManagementServiceClient; public mgmt!: ManagementServiceClient;
public admin!: AdminServiceClient; public admin!: AdminServiceClient;
public feature!: FeatureServiceClient; public feature!: FeatureServiceClient;
public user!: UserServiceClient;
public userNew!: ReturnType<typeof createUserServiceClient>;
public mgmtNew!: ReturnType<typeof createManagementServiceClient>;
public authNew!: ReturnType<typeof createAuthServiceClient>;
constructor( constructor(
private envService: EnvironmentService, private readonly envService: EnvironmentService,
private platformLocation: PlatformLocation, private readonly platformLocation: PlatformLocation,
private authenticationService: AuthenticationService, private readonly authenticationService: AuthenticationService,
private storageService: StorageService, private readonly storageService: StorageService,
private dialog: MatDialog, private readonly translate: TranslateService,
private translate: TranslateService, private readonly exhaustedService: ExhaustedService,
private exhaustedService: ExhaustedService, private readonly authInterceptor: AuthInterceptor,
private readonly authInterceptorProvider: AuthInterceptorProvider,
) {} ) {}
public loadAppEnvironment(): Promise<any> { public loadAppEnvironment(): Promise<any> {
@@ -44,66 +53,79 @@ export class GrpcService {
const browserLanguage = this.translate.getBrowserLang(); const browserLanguage = this.translate.getBrowserLang();
const language = browserLanguage?.match(supportedLanguagesRegexp) ? browserLanguage : fallbackLanguage; const language = browserLanguage?.match(supportedLanguagesRegexp) ? browserLanguage : fallbackLanguage;
return this.translate const init = this.translate.use(language || this.translate.defaultLang).pipe(
.use(language || this.translate.defaultLang) switchMap(() => this.envService.env),
.pipe( tap((env) => {
switchMap(() => this.envService.env), if (!env?.api || !env?.issuer) {
tap((env) => { return;
if (!env?.api || !env?.issuer) { }
return; const interceptors = {
} unaryInterceptors: [
const interceptors = { new ExhaustedGrpcInterceptor(this.exhaustedService, this.envService),
unaryInterceptors: [ new OrgInterceptor(this.storageService),
new ExhaustedGrpcInterceptor(this.exhaustedService, this.envService), this.authInterceptor,
new OrgInterceptor(this.storageService), new I18nInterceptor(this.translate),
new AuthInterceptor(this.authenticationService, this.storageService, this.dialog), ],
new I18nInterceptor(this.translate), };
],
};
this.auth = new AuthServiceClient( this.auth = new AuthServiceClient(
env.api, env.api,
null, null,
// @ts-ignore // @ts-ignore
interceptors, interceptors,
); );
this.mgmt = new ManagementServiceClient( this.mgmt = new ManagementServiceClient(
env.api, env.api,
null, null,
// @ts-ignore // @ts-ignore
interceptors, interceptors,
); );
this.admin = new AdminServiceClient( this.admin = new AdminServiceClient(
env.api, env.api,
null, null,
// @ts-ignore // @ts-ignore
interceptors, interceptors,
); );
this.feature = new FeatureServiceClient( this.feature = new FeatureServiceClient(
env.api, env.api,
null, null,
// @ts-ignore // @ts-ignore
interceptors, interceptors,
); );
this.user = new UserServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
const authConfig: AuthConfig = { const transport = createGrpcWebTransport({
scope: 'openid profile email', baseUrl: env.api,
responseType: 'code', interceptors: [NewConnectWebAuthInterceptor(this.authInterceptorProvider)],
oidc: true, });
clientId: env.clientid, this.userNew = createUserServiceClient(transport);
issuer: env.issuer, this.mgmtNew = createManagementServiceClient(transport);
redirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'auth/callback', this.authNew = createAuthServiceClient(transport);
postLogoutRedirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'signedout',
requireHttps: false,
};
this.authenticationService.initConfig(authConfig); const authConfig: AuthConfig = {
}), scope: 'openid profile email',
catchError((err) => { responseType: 'code',
console.error('Failed to load environment from assets', err); oidc: true,
return throwError(() => err); clientId: env.clientid,
}), issuer: env.issuer,
) redirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'auth/callback',
.toPromise(); postLogoutRedirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'signedout',
requireHttps: false,
};
this.authenticationService.initConfig(authConfig);
}),
catchError((err) => {
console.error('Failed to load environment from assets', err);
throw err;
}),
);
return firstValueFrom(init);
} }
} }

View File

@@ -1,59 +1,53 @@
import { Injectable } from '@angular/core'; import { DestroyRef, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Request, UnaryInterceptor, UnaryResponse } from 'grpc-web'; import { Request, RpcError, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { Subject } from 'rxjs'; import { firstValueFrom, identity, lastValueFrom, Observable, Subject } from 'rxjs';
import { debounceTime, filter, first, map, take, tap } from 'rxjs/operators'; import { debounceTime, filter, map } from 'rxjs/operators';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { AuthenticationService } from '../authentication.service'; import { AuthenticationService } from '../authentication.service';
import { StorageService } from '../storage.service'; import { StorageService } from '../storage.service';
import { AuthConfig } from 'angular-oauth2-oidc'; import { AuthConfig } from 'angular-oauth2-oidc';
import { GrpcAuthService } from '../grpc-auth.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ConnectError, Interceptor } from '@connectrpc/connect';
const authorizationKey = 'Authorization'; const authorizationKey = 'Authorization';
const bearerPrefix = 'Bearer'; const bearerPrefix = 'Bearer';
const accessTokenStorageKey = 'access_token'; const accessTokenStorageKey = 'access_token';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
/** export class AuthInterceptorProvider {
* Set the authentication token
*/
export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> {
public triggerDialog: Subject<boolean> = new Subject(); public triggerDialog: Subject<boolean> = new Subject();
constructor( constructor(
private authenticationService: AuthenticationService, private authenticationService: AuthenticationService,
private storageService: StorageService, private storageService: StorageService,
private dialog: MatDialog, private dialog: MatDialog,
private destroyRef: DestroyRef,
) { ) {
this.triggerDialog.pipe(debounceTime(1000)).subscribe(() => { this.triggerDialog.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => this.openDialog());
this.openDialog();
});
} }
public async intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> { getToken(): Observable<string> {
await this.authenticationService.authenticationChanged return this.authenticationService.authenticationChanged.pipe(
.pipe( filter(identity),
filter((authed) => !!authed), map(() => this.storageService.getItem(accessTokenStorageKey)),
first(), map((token) => `${bearerPrefix} ${token}`),
) );
.toPromise();
const metadata = request.getMetadata();
const accessToken = this.storageService.getItem(accessTokenStorageKey);
metadata[authorizationKey] = `${bearerPrefix} ${accessToken}`;
return invoker(request)
.then((response: any) => {
return response;
})
.catch(async (error: any) => {
if (error.code === 16 || (error.code === 7 && error.message === 'mfa required (AUTHZ-Kl3p0)')) {
this.triggerDialog.next(true);
}
return Promise.reject(error);
});
} }
private openDialog(): void { handleError = (error: any): never => {
if (!(error instanceof RpcError) && !(error instanceof ConnectError)) {
throw error;
}
if (error.code === 16 || (error.code === 7 && error.message === 'mfa required (AUTHZ-Kl3p0)')) {
this.triggerDialog.next(true);
}
throw error;
};
private async openDialog(): Promise<void> {
const dialogRef = this.dialog.open(WarnDialogComponent, { const dialogRef = this.dialog.open(WarnDialogComponent, {
data: { data: {
confirmKey: 'ACTIONS.LOGIN', confirmKey: 'ACTIONS.LOGIN',
@@ -64,19 +58,47 @@ export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryIn
width: '400px', width: '400px',
}); });
dialogRef const resp = await lastValueFrom(dialogRef.afterClosed());
.afterClosed() if (!resp) {
.pipe(take(1)) return;
.subscribe((resp) => { }
if (resp) {
const idToken = this.authenticationService.getIdToken(); const idToken = this.authenticationService.getIdToken();
const configWithPrompt: Partial<AuthConfig> = { const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: { customQueryParams: {
id_token_hint: idToken, id_token_hint: idToken,
}, },
}; };
this.authenticationService.authenticate(configWithPrompt, true);
} await this.authenticationService.authenticate(configWithPrompt, true);
});
} }
} }
@Injectable({ providedIn: 'root' })
/**
* Set the authentication token
*/
export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> {
constructor(private readonly authInterceptorProvider: AuthInterceptorProvider) {}
public async intercept(
request: Request<TReq, TResp>,
invoker: (request: Request<TReq, TResp>) => Promise<UnaryResponse<TReq, TResp>>,
): Promise<UnaryResponse<TReq, TResp>> {
const metadata = request.getMetadata();
metadata[authorizationKey] = await firstValueFrom(this.authInterceptorProvider.getToken());
return invoker(request).catch(this.authInterceptorProvider.handleError);
}
}
export function NewConnectWebAuthInterceptor(authInterceptorProvider: AuthInterceptorProvider): Interceptor {
return (next) => async (req) => {
if (!req.header.get('Authorization')) {
const token = await firstValueFrom(authInterceptorProvider.getToken());
req.header.set('Authorization', token);
}
return next(req).catch(authInterceptorProvider.handleError);
};
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Request, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web'; import { Request, RpcError, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { EnvironmentService } from '../environment.service'; import { EnvironmentService } from '../environment.service';
import { ExhaustedService } from '../exhausted.service'; import { ExhaustedService } from '../exhausted.service';
import { lastValueFrom } from 'rxjs';
/** /**
* ExhaustedGrpcInterceptor shows the exhausted dialog after receiving a gRPC response status 8. * ExhaustedGrpcInterceptor shows the exhausted dialog after receiving a gRPC response status 8.
@@ -17,16 +18,13 @@ export class ExhaustedGrpcInterceptor<TReq = unknown, TResp = unknown> implement
request: Request<TReq, TResp>, request: Request<TReq, TResp>,
invoker: (request: Request<TReq, TResp>) => Promise<UnaryResponse<TReq, TResp>>, invoker: (request: Request<TReq, TResp>) => Promise<UnaryResponse<TReq, TResp>>,
): Promise<UnaryResponse<TReq, TResp>> { ): Promise<UnaryResponse<TReq, TResp>> {
return invoker(request).catch((error: any) => { try {
if (error.code === StatusCode.RESOURCE_EXHAUSTED) { return await invoker(request);
return this.exhaustedSvc } catch (error: any) {
.showExhaustedDialog(this.envSvc.env) if (error instanceof RpcError && error.code === StatusCode.RESOURCE_EXHAUSTED) {
.toPromise() await lastValueFrom(this.exhaustedSvc.showExhaustedDialog(this.envSvc.env));
.then(() => {
throw error;
});
} }
throw error; throw error;
}); }
} }
} }

View File

@@ -10,7 +10,7 @@ const i18nHeader = 'Accept-Language';
export class I18nInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> { export class I18nInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> {
constructor(private translate: TranslateService) {} constructor(private translate: TranslateService) {}
public async intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> { public intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> {
const metadata = request.getMetadata(); const metadata = request.getMetadata();
const navLang = this.translate.currentLang ?? navigator.language; const navLang = this.translate.currentLang ?? navigator.language;
@@ -18,12 +18,6 @@ export class I18nInterceptor<TReq = unknown, TResp = unknown> implements UnaryIn
metadata[i18nHeader] = navLang; metadata[i18nHeader] = navLang;
} }
return invoker(request) return invoker(request);
.then((response: any) => {
return response;
})
.catch((error: any) => {
return Promise.reject(error);
});
} }
} }

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Request, UnaryInterceptor, UnaryResponse } from 'grpc-web'; import { Request, RpcError, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { Org } from 'src/app/proto/generated/zitadel/org_pb'; import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { StorageKey, StorageLocation, StorageService } from '../storage.service'; import { StorageKey, StorageLocation, StorageService } from '../storage.service';
@@ -9,7 +9,7 @@ const ORG_HEADER_KEY = 'x-zitadel-orgid';
export class OrgInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> { export class OrgInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> {
constructor(private readonly storageService: StorageService) {} constructor(private readonly storageService: StorageService) {}
public intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> { public async intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> {
const metadata = request.getMetadata(); const metadata = request.getMetadata();
const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session); const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session);
@@ -18,15 +18,17 @@ export class OrgInterceptor<TReq = unknown, TResp = unknown> implements UnaryInt
metadata[ORG_HEADER_KEY] = `${org.id}`; metadata[ORG_HEADER_KEY] = `${org.id}`;
} }
return invoker(request) try {
.then((response: any) => { return await invoker(request);
return response; } catch (error: any) {
}) if (
.catch((error: any) => { error instanceof RpcError &&
if (error.code === 7 && error.message.startsWith("Organisation doesn't exist")) { error.code === StatusCode.PERMISSION_DENIED &&
this.storageService.removeItem(StorageKey.organization, StorageLocation.session); error.message.startsWith("Organisation doesn't exist")
} ) {
return Promise.reject(error); this.storageService.removeItem(StorageKey.organization, StorageLocation.session);
}); }
throw error;
}
} }
} }

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import { create } from '@bufbuild/protobuf';
import {
AddMyAuthFactorOTPSMSRequestSchema,
AddMyAuthFactorOTPSMSResponse,
GetMyUserRequestSchema,
GetMyUserResponse,
VerifyMyPhoneRequestSchema,
VerifyMyPhoneResponse,
} from '@zitadel/proto/zitadel/auth_pb';
@Injectable({
providedIn: 'root',
})
export class NewAuthService {
constructor(private readonly grpcService: GrpcService) {}
public getMyUser(): Promise<GetMyUserResponse> {
return this.grpcService.authNew.getMyUser(create(GetMyUserRequestSchema));
}
public verifyMyPhone(code: string): Promise<VerifyMyPhoneResponse> {
return this.grpcService.authNew.verifyMyPhone(create(VerifyMyPhoneRequestSchema, { code }));
}
public addMyAuthFactorOTPSMS(): Promise<AddMyAuthFactorOTPSMSResponse> {
return this.grpcService.authNew.addMyAuthFactorOTPSMS(create(AddMyAuthFactorOTPSMSRequestSchema));
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
GenerateMachineSecretRequestSchema,
GenerateMachineSecretResponse,
GetLoginPolicyRequestSchema,
GetLoginPolicyResponse,
ListUserMetadataRequestSchema,
ListUserMetadataResponse,
RemoveMachineSecretRequestSchema,
RemoveMachineSecretResponse,
RemoveUserMetadataRequestSchema,
RemoveUserMetadataResponse,
ResendHumanEmailVerificationRequestSchema,
ResendHumanEmailVerificationResponse,
ResendHumanInitializationRequestSchema,
ResendHumanInitializationResponse,
ResendHumanPhoneVerificationRequestSchema,
ResendHumanPhoneVerificationResponse,
SendHumanResetPasswordNotificationRequest_Type,
SendHumanResetPasswordNotificationRequestSchema,
SendHumanResetPasswordNotificationResponse,
SetUserMetadataRequestSchema,
SetUserMetadataResponse,
UpdateMachineRequestSchema,
UpdateMachineResponse,
} from '@zitadel/proto/zitadel/management_pb';
import { MessageInitShape, create } from '@bufbuild/protobuf';
@Injectable({
providedIn: 'root',
})
export class NewMgmtService {
constructor(private readonly grpcService: GrpcService) {}
public getLoginPolicy(): Promise<GetLoginPolicyResponse> {
return this.grpcService.mgmtNew.getLoginPolicy(create(GetLoginPolicyRequestSchema));
}
public generateMachineSecret(userId: string): Promise<GenerateMachineSecretResponse> {
return this.grpcService.mgmtNew.generateMachineSecret(create(GenerateMachineSecretRequestSchema, { userId }));
}
public removeMachineSecret(userId: string): Promise<RemoveMachineSecretResponse> {
return this.grpcService.mgmtNew.removeMachineSecret(create(RemoveMachineSecretRequestSchema, { userId }));
}
public updateMachine(req: MessageInitShape<typeof UpdateMachineRequestSchema>): Promise<UpdateMachineResponse> {
return this.grpcService.mgmtNew.updateMachine(create(UpdateMachineRequestSchema, req));
}
public resendHumanEmailVerification(userId: string): Promise<ResendHumanEmailVerificationResponse> {
return this.grpcService.mgmtNew.resendHumanEmailVerification(
create(ResendHumanEmailVerificationRequestSchema, { userId }),
);
}
public resendHumanPhoneVerification(userId: string): Promise<ResendHumanPhoneVerificationResponse> {
return this.grpcService.mgmtNew.resendHumanPhoneVerification(
create(ResendHumanPhoneVerificationRequestSchema, { userId }),
);
}
public sendHumanResetPasswordNotification(
userId: string,
type: SendHumanResetPasswordNotificationRequest_Type,
): Promise<SendHumanResetPasswordNotificationResponse> {
return this.grpcService.mgmtNew.sendHumanResetPasswordNotification(
create(SendHumanResetPasswordNotificationRequestSchema, { userId, type }),
);
}
public resendHumanInitialization(userId: string, email: string = ''): Promise<ResendHumanInitializationResponse> {
return this.grpcService.mgmtNew.resendHumanInitialization(
create(ResendHumanInitializationRequestSchema, { userId, email }),
);
}
public listUserMetadata(id: string): Promise<ListUserMetadataResponse> {
return this.grpcService.mgmtNew.listUserMetadata(create(ListUserMetadataRequestSchema, { id }));
}
public setUserMetadata(req: MessageInitShape<typeof SetUserMetadataRequestSchema>): Promise<SetUserMetadataResponse> {
return this.grpcService.mgmtNew.setUserMetadata(create(SetUserMetadataRequestSchema, req));
}
public removeUserMetadata(
req: MessageInitShape<typeof RemoveUserMetadataRequestSchema>,
): Promise<RemoveUserMetadataResponse> {
return this.grpcService.mgmtNew.removeUserMetadata(create(RemoveUserMetadataRequestSchema, req));
}
}

View File

@@ -0,0 +1,302 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddHumanUserRequestSchema,
AddHumanUserResponse,
CreateInviteCodeRequestSchema,
CreateInviteCodeResponse,
CreatePasskeyRegistrationLinkRequestSchema,
CreatePasskeyRegistrationLinkResponse,
DeactivateUserRequestSchema,
DeactivateUserResponse,
DeleteUserRequestSchema,
DeleteUserResponse,
GetUserByIDRequestSchema,
GetUserByIDResponse,
ListAuthenticationFactorsRequestSchema,
ListAuthenticationFactorsResponse,
ListPasskeysRequestSchema,
ListPasskeysResponse,
ListUsersRequestSchema,
ListUsersResponse,
LockUserRequestSchema,
LockUserResponse,
PasswordResetRequestSchema,
ReactivateUserRequestSchema,
ReactivateUserResponse,
RemoveOTPEmailRequestSchema,
RemoveOTPEmailResponse,
RemoveOTPSMSRequestSchema,
RemoveOTPSMSResponse,
RemovePasskeyRequestSchema,
RemovePasskeyResponse,
RemovePhoneRequestSchema,
RemovePhoneResponse,
RemoveTOTPRequestSchema,
RemoveTOTPResponse,
RemoveU2FRequestSchema,
RemoveU2FResponse,
ResendInviteCodeRequestSchema,
ResendInviteCodeResponse,
SetEmailRequestSchema,
SetEmailResponse,
SetPasswordRequestSchema,
SetPasswordResponse,
SetPhoneRequestSchema,
SetPhoneResponse,
UnlockUserRequestSchema,
UnlockUserResponse,
UpdateHumanUserRequestSchema,
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, ListQuerySchema } from '@zitadel/proto/zitadel/object/v2/object_pb';
import { SearchQuery, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb';
import { SortDirection } from '@angular/material/sort';
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';
@Injectable({
providedIn: 'root',
})
export class UserService {
constructor(private readonly grpcService: GrpcService) {}
public addHumanUser(req: MessageInitShape<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> {
return this.grpcService.userNew.addHumanUser(create(AddHumanUserRequestSchema, req));
}
public listUsers(
limit: number,
offset: number,
queriesList?: SearchQuery[],
sortingColumn?: UserFieldName,
sortingDirection?: SortDirection,
): Promise<ListUsersResponse> {
const query = create(ListQuerySchema);
if (limit) {
query.limit = limit;
}
if (offset) {
query.offset = BigInt(offset);
}
if (sortingDirection) {
query.asc = sortingDirection === 'asc';
}
const req = create(ListUsersRequestSchema, {
query,
});
if (sortingColumn) {
req.sortingColumn = sortingColumn;
}
if (queriesList) {
req.queries = queriesList;
}
return this.grpcService.userNew.listUsers(req);
}
public getUserById(userId: string): Promise<GetUserByIDResponse> {
return this.grpcService.userNew.getUserByID(create(GetUserByIDRequestSchema, { userId }));
}
public deactivateUser(userId: string): Promise<DeactivateUserResponse> {
return this.grpcService.userNew.deactivateUser(create(DeactivateUserRequestSchema, { userId }));
}
public reactivateUser(userId: string): Promise<ReactivateUserResponse> {
return this.grpcService.userNew.reactivateUser(create(ReactivateUserRequestSchema, { userId }));
}
public deleteUser(userId: string): Promise<DeleteUserResponse> {
return this.grpcService.userNew.deleteUser(create(DeleteUserRequestSchema, { userId }));
}
public updateUser(req: MessageInitShape<typeof UpdateHumanUserRequestSchema>): Promise<UpdateHumanUserResponse> {
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 }));
}
public listAuthenticationFactors(
req: MessageInitShape<typeof ListAuthenticationFactorsRequestSchema>,
): Promise<ListAuthenticationFactorsResponse> {
return this.grpcService.userNew.listAuthenticationFactors(create(ListAuthenticationFactorsRequestSchema, req));
}
public listPasskeys(req: MessageInitShape<typeof ListPasskeysRequestSchema>): Promise<ListPasskeysResponse> {
return this.grpcService.userNew.listPasskeys(create(ListPasskeysRequestSchema, req));
}
public removePasskeys(req: MessageInitShape<typeof RemovePasskeyRequestSchema>): Promise<RemovePasskeyResponse> {
return this.grpcService.userNew.removePasskey(create(RemovePasskeyRequestSchema, req));
}
public createPasskeyRegistrationLink(
req: MessageInitShape<typeof CreatePasskeyRegistrationLinkRequestSchema>,
): Promise<CreatePasskeyRegistrationLinkResponse> {
return this.grpcService.userNew.createPasskeyRegistrationLink(create(CreatePasskeyRegistrationLinkRequestSchema, req));
}
public removePhone(userId: string): Promise<RemovePhoneResponse> {
return this.grpcService.userNew.removePhone(create(RemovePhoneRequestSchema, { userId }));
}
public setPhone(req: MessageInitShape<typeof SetPhoneRequestSchema>): Promise<SetPhoneResponse> {
return this.grpcService.userNew.setPhone(create(SetPhoneRequestSchema, req));
}
public setEmail(req: MessageInitShape<typeof SetEmailRequestSchema>): Promise<SetEmailResponse> {
return this.grpcService.userNew.setEmail(create(SetEmailRequestSchema, req));
}
public removeTOTP(userId: string): Promise<RemoveTOTPResponse> {
return this.grpcService.userNew.removeTOTP(create(RemoveTOTPRequestSchema, { userId }));
}
public removeU2F(userId: string, u2fId: string): Promise<RemoveU2FResponse> {
return this.grpcService.userNew.removeU2F(create(RemoveU2FRequestSchema, { userId, u2fId }));
}
public removeOTPSMS(userId: string): Promise<RemoveOTPSMSResponse> {
return this.grpcService.userNew.removeOTPSMS(create(RemoveOTPSMSRequestSchema, { userId }));
}
public removeOTPEmail(userId: string): Promise<RemoveOTPEmailResponse> {
return this.grpcService.userNew.removeOTPEmail(create(RemoveOTPEmailRequestSchema, { userId }));
}
public resendInviteCode(userId: string): Promise<ResendInviteCodeResponse> {
return this.grpcService.userNew.resendInviteCode(create(ResendInviteCodeRequestSchema, { userId }));
}
public createInviteCode(req: MessageInitShape<typeof CreateInviteCodeRequestSchema>): Promise<CreateInviteCodeResponse> {
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));
}
}
function userToV2(user: User): UserV2 {
const details = user.getDetails();
return create(UserSchema, {
userId: user.getId(),
details: details && detailsToV2(details),
state: user.getState() as number as UserState,
username: user.getUserName(),
loginNames: user.getLoginNamesList(),
preferredLoginName: user.getPreferredLoginName(),
type: typeToV2(user),
});
}
function detailsToV2(details: ObjectDetails): Details {
const changeDate = details.getChangeDate();
return create(DetailsSchema, {
sequence: BigInt(details.getSequence()),
changeDate: changeDate && timestampToV2(changeDate),
resourceOwner: details.getResourceOwner(),
});
}
function timestampToV2(timestamp: Timestamp): TimestampV2 {
return create(TimestampSchema, {
seconds: BigInt(timestamp.getSeconds()),
nanos: timestamp.getNanos(),
});
}
function typeToV2(user: User): UserV2['type'] {
const human = user.getHuman();
if (human) {
return { case: 'human', value: humanToV2(user, human) };
}
const machine = user.getMachine();
if (machine) {
return { case: 'machine', value: machineToV2(machine) };
}
return { case: undefined };
}
function humanToV2(user: User, human: Human): HumanUser {
const profile = human.getProfile();
const email = human.getEmail()?.getEmail();
const phone = human.getPhone();
const passwordChanged = human.getPasswordChanged();
return create(HumanUserSchema, {
userId: user.getId(),
state: user.getState() as number as UserState,
username: user.getUserName(),
loginNames: user.getLoginNamesList(),
preferredLoginName: user.getPreferredLoginName(),
profile: profile && humanProfileToV2(profile),
email: { email },
phone: phone && humanPhoneToV2(phone),
passwordChangeRequired: false,
passwordChanged: passwordChanged && timestampToV2(passwordChanged),
});
}
function humanProfileToV2(profile: Profile): HumanProfile {
return create(HumanProfileSchema, {
givenName: profile.getFirstName(),
familyName: profile.getLastName(),
nickName: profile.getNickName(),
displayName: profile.getDisplayName(),
preferredLanguage: profile.getPreferredLanguage(),
gender: profile.getGender() as number as Gender,
avatarUrl: profile.getAvatarUrl(),
});
}
function humanPhoneToV2(phone: Phone): HumanPhone {
return create(HumanPhoneSchema, {
phone: phone.getPhone(),
isVerified: phone.getIsPhoneVerified(),
});
}
function machineToV2(machine: Machine): MachineUser {
return create(MachineUserSchema, {
name: machine.getName(),
description: machine.getDescription(),
hasSecret: machine.getHasSecret(),
accessTokenType: machine.getAccessTokenType() as number as AccessTokenType,
});
}

View File

@@ -1,6 +1,6 @@
import { CountryCode, parsePhoneNumber } from 'libphonenumber-js'; import { CountryCode, parsePhoneNumber } from 'libphonenumber-js';
export function formatPhone(phone: string): { phone: string; country: CountryCode } | null { export function formatPhone(phone?: string): { phone: string; country: CountryCode } | null {
const defaultCountry = 'US'; const defaultCountry = 'US';
if (phone) { if (phone) {

View File

@@ -0,0 +1,11 @@
import { Observable } from 'rxjs';
import { map, pairwise, startWith } from 'rxjs/operators';
export function pairwiseStartWith<T, R>(start: T) {
return (source: Observable<R>) =>
source.pipe(
startWith(start),
pairwise(),
map(([prev, curr]) => [prev, curr] as [T | R, R]),
);
}

View File

@@ -808,6 +808,9 @@
"EMAIL": "Електронна поща", "EMAIL": "Електронна поща",
"PHONE": "Телефонен номер", "PHONE": "Телефонен номер",
"PHONE_HINT": "Използвайте символа +, последван от кода на държавата, на която се обаждате, или изберете държавата от падащото меню и накрая въведете телефонния номер", "PHONE_HINT": "Използвайте символа +, последван от кода на държавата, на която се обаждате, или изберете държавата от падащото меню и накрая въведете телефонния номер",
"PHONE_VERIFIED": "Телефонният номер е потвърден",
"SEND_SMS": "Изпрати SMS за потвърждение",
"SEND_EMAIL": "Изпрати имейл",
"USERNAME": "Потребителско име", "USERNAME": "Потребителско име",
"CHANGEUSERNAME": "променям", "CHANGEUSERNAME": "променям",
"CHANGEUSERNAME_TITLE": "Промяна на потребителското име", "CHANGEUSERNAME_TITLE": "Промяна на потребителското име",
@@ -948,6 +951,14 @@
"5": "Спряно", "5": "Спряно",
"6": "Първоначално" "6": "Първоначално"
}, },
"STATEV2": {
"0": "неизвестен",
"1": "Активен",
"2": "Неактивен",
"3": "Изтрито",
"4": "Заключено",
"5": "Първоначално"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Име за вход (текуща организация)", "ADDITIONAL": "Име за вход (текуща организация)",
"ADDITIONAL-EXTERNAL": "Име за вход (външна организация)" "ADDITIONAL-EXTERNAL": "Име за вход (външна организация)"
@@ -1485,7 +1496,9 @@
"ENABLED": "„Активирано“ се наследява", "ENABLED": "„Активирано“ се наследява",
"DISABLED": "„Деактивирано“ се наследява" "DISABLED": "„Деактивирано“ се наследява"
}, },
"RESET": "Задай всички на наследено" "RESET": "Задай всички на наследено",
"CONSOLEUSEV2USERAPI": "Използвайте V2 API в конзолата за създаване на потребител",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Когато този флаг е активиран, конзолата използва V2 User API за създаване на нови потребители. С V2 API новосъздадените потребители започват без начален статус."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Telefonní číslo", "PHONE": "Telefonní číslo",
"PHONE_HINT": "Použijte symbol + následovaný mezinárodním kódem země, nebo ze seznamu vyberte zemi a nakonec zadejte telefonní číslo", "PHONE_HINT": "Použijte symbol + následovaný mezinárodním kódem země, nebo ze seznamu vyberte zemi a nakonec zadejte telefonní číslo",
"PHONE_VERIFIED": "Telefonní číslo ověřeno",
"SEND_SMS": "Odeslat ověřovací SMS",
"SEND_EMAIL": "Odeslat E-mail",
"USERNAME": "Uživatelské jméno", "USERNAME": "Uživatelské jméno",
"CHANGEUSERNAME": "upravit", "CHANGEUSERNAME": "upravit",
"CHANGEUSERNAME_TITLE": "Změna uživatelského jména", "CHANGEUSERNAME_TITLE": "Změna uživatelského jména",
@@ -949,6 +952,14 @@
"5": "Pozastavený", "5": "Pozastavený",
"6": "Počáteční" "6": "Počáteční"
}, },
"STATEV2": {
"0": "Neznámý",
"1": "Aktivní",
"2": "Neaktivní",
"3": "Smazaný",
"4": "Uzamčený",
"5": "Počáteční"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Přihlašovací jméno (současná organizace)", "ADDITIONAL": "Přihlašovací jméno (současná organizace)",
"ADDITIONAL-EXTERNAL": "Přihlašovací jméno (externí organizace)" "ADDITIONAL-EXTERNAL": "Přihlašovací jméno (externí organizace)"
@@ -1486,7 +1497,9 @@
"ENABLED": "„Povoleno“ je zděděno", "ENABLED": "„Povoleno“ je zděděno",
"DISABLED": "„Zakázáno“ je zděděno" "DISABLED": "„Zakázáno“ je zděděno"
}, },
"RESET": "Nastavit vše na děděné" "RESET": "Nastavit vše na děděné",
"CONSOLEUSEV2USERAPI": "Použijte V2 API v konzoli pro vytvoření uživatele",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Když je tato příznak povolen, konzole používá V2 User API k vytvoření nových uživatelů. S V2 API nově vytvoření uživatelé začínají bez počátečního stavu."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-Mail", "EMAIL": "E-Mail",
"PHONE": "Telefonnummer", "PHONE": "Telefonnummer",
"PHONE_HINT": "Verwenden das Symbol + gefolgt von der Landesvorwahl des Anrufers oder wähle das Land aus der Dropdown-Liste aus und gebe anschließend die Telefonnummer ein.", "PHONE_HINT": "Verwenden das Symbol + gefolgt von der Landesvorwahl des Anrufers oder wähle das Land aus der Dropdown-Liste aus und gebe anschließend die Telefonnummer ein.",
"PHONE_VERIFIED": "Telefonnummer verifiziert",
"SEND_SMS": "Bestätigungs-SMS senden",
"SEND_EMAIL": "E-Mail senden",
"USERNAME": "Benutzername", "USERNAME": "Benutzername",
"CHANGEUSERNAME": "bearbeiten", "CHANGEUSERNAME": "bearbeiten",
"CHANGEUSERNAME_TITLE": "Benutzername ändern", "CHANGEUSERNAME_TITLE": "Benutzername ändern",
@@ -949,6 +952,14 @@
"5": "Suspendiert", "5": "Suspendiert",
"6": "Initialisiert" "6": "Initialisiert"
}, },
"STATEV2": {
"0": "Unbekannt",
"1": "Aktiv",
"2": "Inaktiv",
"3": "Gelöscht",
"4": "Gesperrt",
"5": "Initialisiert"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Benutzer Name (eigene Organisation)", "ADDITIONAL": "Benutzer Name (eigene Organisation)",
"ADDITIONAL-EXTERNAL": "Loginname (externe Organisation)" "ADDITIONAL-EXTERNAL": "Loginname (externe Organisation)"
@@ -1486,7 +1497,9 @@
"ENABLED": "„Aktiviert“ wird vererbt", "ENABLED": "„Aktiviert“ wird vererbt",
"DISABLED": "„Deaktiviert“ wird vererbt" "DISABLED": "„Deaktiviert“ wird vererbt"
}, },
"RESET": "Alle auf Erben setzen" "RESET": "Alle auf Erben setzen",
"CONSOLEUSEV2USERAPI": "Verwende die V2-API in der Konsole zur Erstellung von Benutzern",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Wenn diese Option aktiviert ist, verwendet die Konsole die V2 User API, um neue Benutzer zu erstellen. Mit der V2 API starten neu erstellte Benutzer nicht im Initial Zustand."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Phone number", "PHONE": "Phone number",
"PHONE_HINT": "Use the + symbol followed by the calling country code, or select the country from the dropdown and finally enter the phone number", "PHONE_HINT": "Use the + symbol followed by the calling country code, or select the country from the dropdown and finally enter the phone number",
"PHONE_VERIFIED": "Phone Number Verified",
"SEND_SMS": "Send Verification SMS",
"SEND_EMAIL": "Send E-Mail",
"USERNAME": "User Name", "USERNAME": "User Name",
"CHANGEUSERNAME": "modify", "CHANGEUSERNAME": "modify",
"CHANGEUSERNAME_TITLE": "Change username", "CHANGEUSERNAME_TITLE": "Change username",
@@ -949,6 +952,14 @@
"5": "Suspended", "5": "Suspended",
"6": "Initial" "6": "Initial"
}, },
"STATEV2": {
"0": "Unknown",
"1": "Active",
"2": "Inactive",
"3": "Deleted",
"4": "Locked",
"5": "Initial"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Loginname (current organization)", "ADDITIONAL": "Loginname (current organization)",
"ADDITIONAL-EXTERNAL": "Loginname (external organization)" "ADDITIONAL-EXTERNAL": "Loginname (external organization)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Enabled\" is inherited", "ENABLED": "\"Enabled\" is inherited",
"DISABLED": "\"Disabled\" is inherited" "DISABLED": "\"Disabled\" is inherited"
}, },
"RESET": "Set all to inherit" "RESET": "Set all to inherit",
"CONSOLEUSEV2USERAPI": "Use V2 Api in Console for User creation",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "When this flag is enabled, the console uses the V2 User API to create new users. With the V2 API, newly created users start without an initial state."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Email", "EMAIL": "Email",
"PHONE": "Número de teléfono", "PHONE": "Número de teléfono",
"PHONE_HINT": "Usa el símbolo + seguido del prefijo del país o selecciona el país del menú desplegable y finalmente introduce el número de teléfono", "PHONE_HINT": "Usa el símbolo + seguido del prefijo del país o selecciona el país del menú desplegable y finalmente introduce el número de teléfono",
"PHONE_VERIFIED": "Número de teléfono verificado",
"SEND_SMS": "Enviar SMS de verificación",
"SEND_EMAIL": "Enviar correo electrónico",
"USERNAME": "Nombre de usuario", "USERNAME": "Nombre de usuario",
"CHANGEUSERNAME": "modificar", "CHANGEUSERNAME": "modificar",
"CHANGEUSERNAME_TITLE": "Cambiar nombre de usuario", "CHANGEUSERNAME_TITLE": "Cambiar nombre de usuario",
@@ -949,6 +952,14 @@
"5": "Suspendido", "5": "Suspendido",
"6": "Inicial" "6": "Inicial"
}, },
"STATEV2": {
"0": "Desconocido",
"1": "Activo",
"2": "Inactivo",
"3": "Borrado",
"4": "Bloqueado",
"5": "Inicial"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nombre de inicio de sesión (organización actual)", "ADDITIONAL": "Nombre de inicio de sesión (organización actual)",
"ADDITIONAL-EXTERNAL": "Nombre de inicio de sesión (organización externa)" "ADDITIONAL-EXTERNAL": "Nombre de inicio de sesión (organización externa)"
@@ -1487,7 +1498,9 @@
"ENABLED": "\"Habilitado\" se hereda", "ENABLED": "\"Habilitado\" se hereda",
"DISABLED": "\"Deshabilitado\" se hereda" "DISABLED": "\"Deshabilitado\" se hereda"
}, },
"RESET": "Establecer todo a heredado" "RESET": "Establecer todo a heredado",
"CONSOLEUSEV2USERAPI": "Utilice la API V2 en la consola para la creación de usuarios",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Cuando esta opción está habilitada, la consola utiliza la API V2 de usuario para crear nuevos usuarios. Con la API V2, los usuarios recién creados comienzan sin un estado inicial."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Courriel", "EMAIL": "Courriel",
"PHONE": "Numéro de téléphone", "PHONE": "Numéro de téléphone",
"PHONE_HINT": "Utilisez le symbole + suivi de l'indicatif du pays de l'appelant, ou sélectionnez le pays dans la liste déroulante et saisissez enfin le numéro de téléphone.", "PHONE_HINT": "Utilisez le symbole + suivi de l'indicatif du pays de l'appelant, ou sélectionnez le pays dans la liste déroulante et saisissez enfin le numéro de téléphone.",
"PHONE_VERIFIED": "Numéro de téléphone vérifié",
"SEND_SMS": "Envoyer un SMS de vérification",
"SEND_EMAIL": "Envoyer un Courriel",
"USERNAME": "Nom de l'utilisateur", "USERNAME": "Nom de l'utilisateur",
"CHANGEUSERNAME": "modifier", "CHANGEUSERNAME": "modifier",
"CHANGEUSERNAME_TITLE": "Modifier le nom d'utilisateur", "CHANGEUSERNAME_TITLE": "Modifier le nom d'utilisateur",
@@ -949,6 +952,14 @@
"5": "Suspendu", "5": "Suspendu",
"6": "Initial" "6": "Initial"
}, },
"STATEV2": {
"0": "Inconnu",
"1": "Actif",
"2": "Inactif",
"3": "Supprimé",
"4": "Verrouillé",
"5": "Initial"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nom de connexion (organisation actuelle)", "ADDITIONAL": "Nom de connexion (organisation actuelle)",
"ADDITIONAL-EXTERNAL": "Nom de connexion (organisation externe)" "ADDITIONAL-EXTERNAL": "Nom de connexion (organisation externe)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Activé\" est hérité", "ENABLED": "\"Activé\" est hérité",
"DISABLED": "\"Désactivé\" est hérité" "DISABLED": "\"Désactivé\" est hérité"
}, },
"RESET": "Réinitialiser tout sur hérité" "RESET": "Réinitialiser tout sur hérité",
"CONSOLEUSEV2USERAPI": "Utilisez l'API V2 dans la console pour la création d'utilisateurs",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Lorsque ce drapeau est activé, la console utilise l'API V2 User pour créer de nouveaux utilisateurs. Avec l'API V2, les nouveaux utilisateurs commencent sans état initial."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Telefonszám", "PHONE": "Telefonszám",
"PHONE_HINT": "Használd a + szimbólumot az ország hívókódja előtt, vagy válaszd ki az országot a legördülő menüből, és végül add meg a telefonszámot", "PHONE_HINT": "Használd a + szimbólumot az ország hívókódja előtt, vagy válaszd ki az országot a legördülő menüből, és végül add meg a telefonszámot",
"PHONE_VERIFIED": "Telefonszám ellenőrizve",
"SEND_SMS": "Ellenőrző SMS küldése",
"SEND_EMAIL": "E-mail küldése",
"USERNAME": "Felhasználónév", "USERNAME": "Felhasználónév",
"CHANGEUSERNAME": "módosít", "CHANGEUSERNAME": "módosít",
"CHANGEUSERNAME_TITLE": "Felhasználónév megváltoztatása", "CHANGEUSERNAME_TITLE": "Felhasználónév megváltoztatása",
@@ -949,6 +952,14 @@
"5": "Felfüggesztett", "5": "Felfüggesztett",
"6": "Kezdeti" "6": "Kezdeti"
}, },
"STATEV2": {
"0": "Ismeretlen",
"1": "Aktív",
"2": "Inaktív",
"3": "Törölt",
"4": "Zárolt",
"5": "Kezdeti"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Belépési név (jelenlegi szervezet)", "ADDITIONAL": "Belépési név (jelenlegi szervezet)",
"ADDITIONAL-EXTERNAL": "Belépési név (külső szervezet)" "ADDITIONAL-EXTERNAL": "Belépési név (külső szervezet)"
@@ -1484,7 +1495,9 @@
"ENABLED": "\"Engedélyezve\" öröklődik", "ENABLED": "\"Engedélyezve\" öröklődik",
"DISABLED": "\"Letiltva\" öröklődik" "DISABLED": "\"Letiltva\" öröklődik"
}, },
"RESET": "Mindent állíts öröklésre" "RESET": "Mindent állíts öröklésre",
"CONSOLEUSEV2USERAPI": "Használja a V2 API-t a konzolban felhasználók létrehozásához",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Ha ez a jelző engedélyezve van, a konzol a V2 User API-t használja új felhasználók létrehozásához. A V2 API-val az újonnan létrehozott felhasználók kezdeti állapot nélkül indulnak."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -749,6 +749,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Nomor telepon", "PHONE": "Nomor telepon",
"PHONE_HINT": "Gunakan simbol + diikuti dengan kode negara pemanggil, atau pilih negara dari dropdown dan terakhir masukkan nomor telepon", "PHONE_HINT": "Gunakan simbol + diikuti dengan kode negara pemanggil, atau pilih negara dari dropdown dan terakhir masukkan nomor telepon",
"PHONE_VERIFIED": "Nomor telepon terverifikasi",
"SEND_SMS": "Kirim SMS verifikasi",
"SEND_EMAIL": "Kirim E-mail",
"USERNAME": "Nama belakang", "USERNAME": "Nama belakang",
"CHANGEUSERNAME": "memodifikasi", "CHANGEUSERNAME": "memodifikasi",
"CHANGEUSERNAME_TITLE": "Ubah nama pengguna", "CHANGEUSERNAME_TITLE": "Ubah nama pengguna",
@@ -878,6 +881,14 @@
"5": "Tergantung", "5": "Tergantung",
"6": "Awal" "6": "Awal"
}, },
"STATEV2": {
"0": "Tidak dikenal",
"1": "Aktif",
"2": "Tidak aktif",
"3": "Dihapus",
"4": "Terkunci",
"5": "Awal"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nama login (organisasi saat ini)", "ADDITIONAL": "Nama login (organisasi saat ini)",
"ADDITIONAL-EXTERNAL": "Nama login (organisasi eksternal)" "ADDITIONAL-EXTERNAL": "Nama login (organisasi eksternal)"
@@ -1354,7 +1365,9 @@
"ENABLED": "\"Diaktifkan\" diwariskan", "ENABLED": "\"Diaktifkan\" diwariskan",
"DISABLED": "\"Dinonaktifkan\" diwariskan" "DISABLED": "\"Dinonaktifkan\" diwariskan"
}, },
"RESET": "Tetapkan semua untuk diwarisi" "RESET": "Tetapkan semua untuk diwarisi",
"CONSOLEUSEV2USERAPI": "Gunakan API V2 di konsol untuk pembuatan pengguna",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Ketika flag ini diaktifkan, konsol menggunakan API Pengguna V2 untuk membuat pengguna baru. Dengan API V2, pengguna yang baru dibuat dimulai tanpa keadaan awal."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -808,6 +808,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Numero di telefono", "PHONE": "Numero di telefono",
"PHONE_HINT": "Utilizza il simbolo + seguito dal prefisso del paese, o seleziona il paese ed inserisci il numero di telefono", "PHONE_HINT": "Utilizza il simbolo + seguito dal prefisso del paese, o seleziona il paese ed inserisci il numero di telefono",
"PHONE_VERIFIED": "Numero di telefono verificato",
"SEND_SMS": "Invia SMS di verifica",
"SEND_EMAIL": "Invia E-mail",
"USERNAME": "Nome utente", "USERNAME": "Nome utente",
"CHANGEUSERNAME": "cambia", "CHANGEUSERNAME": "cambia",
"CHANGEUSERNAME_TITLE": "Cambia nome utente", "CHANGEUSERNAME_TITLE": "Cambia nome utente",
@@ -948,6 +951,14 @@
"5": "Sospeso", "5": "Sospeso",
"6": "Initializzato" "6": "Initializzato"
}, },
"STATEV2": {
"0": "Sconosciuto",
"1": "Attivo",
"2": "Inattivo",
"3": "Rimosso",
"4": "Bloccato",
"5": "Initializzato"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nome (organizzazione corrente)", "ADDITIONAL": "Nome (organizzazione corrente)",
"ADDITIONAL-EXTERNAL": "Loginname (organizzazione esterna)" "ADDITIONAL-EXTERNAL": "Loginname (organizzazione esterna)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Abilitato\" viene ereditato", "ENABLED": "\"Abilitato\" viene ereditato",
"DISABLED": "\"Disabilitato\" viene ereditato" "DISABLED": "\"Disabilitato\" viene ereditato"
}, },
"RESET": "Imposta tutto su predefinito" "RESET": "Imposta tutto su predefinito",
"CONSOLEUSEV2USERAPI": "Utilizza l'API V2 nella console per la creazione degli utenti",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Quando questa opzione è abilitata, la console utilizza l'API V2 User per creare nuovi utenti. Con l'API V2, i nuovi utenti creati iniziano senza uno stato iniziale."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Eメール", "EMAIL": "Eメール",
"PHONE": "電話番号", "PHONE": "電話番号",
"PHONE_HINT": "+ マークに続いて電話をかけたい国コードを入力するか、ドロップダウンから国を選択して電話番号を入力します。", "PHONE_HINT": "+ マークに続いて電話をかけたい国コードを入力するか、ドロップダウンから国を選択して電話番号を入力します。",
"PHONE_VERIFIED": "電話番号が確認されました",
"SEND_SMS": "認証SMSを送信",
"SEND_EMAIL": "メールを送信",
"USERNAME": "ユーザー名", "USERNAME": "ユーザー名",
"CHANGEUSERNAME": "変更", "CHANGEUSERNAME": "変更",
"CHANGEUSERNAME_TITLE": "ユーザー名の変更", "CHANGEUSERNAME_TITLE": "ユーザー名の変更",
@@ -949,6 +952,14 @@
"5": "停止", "5": "停止",
"6": "初期化待ち" "6": "初期化待ち"
}, },
"STATEV2": {
"0": "不明",
"1": "アクティブ",
"2": "非アクティブ",
"3": "削除",
"4": "ロック",
"5": "初期化待ち"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "ログインネーム(現在の組織)", "ADDITIONAL": "ログインネーム(現在の組織)",
"ADDITIONAL-EXTERNAL": "ログインネーム(外部の組織)" "ADDITIONAL-EXTERNAL": "ログインネーム(外部の組織)"
@@ -1486,7 +1497,9 @@
"ENABLED": "有効は継承されます", "ENABLED": "有効は継承されます",
"DISABLED": "無効は継承されます" "DISABLED": "無効は継承されます"
}, },
"RESET": "すべて継承に設定" "RESET": "すべて継承に設定",
"CONSOLEUSEV2USERAPI": "コンソールでユーザー作成のためにV2 APIを使用してください。",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "このフラグが有効化されると、コンソールはV2ユーザーAPIを使用して新しいユーザーを作成します。V2 APIでは、新しく作成されたユーザーは初期状態なしで開始します。"
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "이메일", "EMAIL": "이메일",
"PHONE": "전화번호", "PHONE": "전화번호",
"PHONE_HINT": "+ 기호 다음에 국가 코드를 입력하거나 드롭다운에서 국가를 선택한 후 전화번호를 입력하세요.", "PHONE_HINT": "+ 기호 다음에 국가 코드를 입력하거나 드롭다운에서 국가를 선택한 후 전화번호를 입력하세요.",
"PHONE_VERIFIED": "전화번호 확인됨",
"SEND_SMS": "인증 SMS 보내기",
"SEND_EMAIL": "이메일 보내기",
"USERNAME": "사용자 이름", "USERNAME": "사용자 이름",
"CHANGEUSERNAME": "수정", "CHANGEUSERNAME": "수정",
"CHANGEUSERNAME_TITLE": "사용자 이름 변경", "CHANGEUSERNAME_TITLE": "사용자 이름 변경",
@@ -949,6 +952,14 @@
"5": "일시 중단됨", "5": "일시 중단됨",
"6": "초기" "6": "초기"
}, },
"STATEV2": {
"0": "알 수 없음",
"1": "활성",
"2": "비활성",
"3": "삭제됨",
"4": "잠김",
"5": "초기"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "로그인 이름 (현재 조직)", "ADDITIONAL": "로그인 이름 (현재 조직)",
"ADDITIONAL-EXTERNAL": "로그인 이름 (외부 조직)" "ADDITIONAL-EXTERNAL": "로그인 이름 (외부 조직)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"활성화됨\"은 상속되었습니다.", "ENABLED": "\"활성화됨\"은 상속되었습니다.",
"DISABLED": "\"비활성화됨\"은 상속되었습니다." "DISABLED": "\"비활성화됨\"은 상속되었습니다."
}, },
"RESET": "모두 상속으로 설정" "RESET": "모두 상속으로 설정",
"CONSOLEUSEV2USERAPI": "콘솔에서 사용자 생성을 위해 V2 API를 사용하세요",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "이 플래그가 활성화되면 콘솔은 V2 사용자 API를 사용하여 새 사용자를 생성합니다. V2 API를 사용하면 새로 생성된 사용자는 초기 상태 없이 시작합니다."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Е-пошта", "EMAIL": "Е-пошта",
"PHONE": "Телефонски број", "PHONE": "Телефонски број",
"PHONE_HINT": "Користете + и потоа дополнителниот број на земјата, или изберете ја земјата од листата и на крај внесете го телефонскиот број", "PHONE_HINT": "Користете + и потоа дополнителниот број на земјата, или изберете ја земјата од листата и на крај внесете го телефонскиот број",
"PHONE_VERIFIED": "Телефонскиот број е потврден",
"SEND_SMS": "Испрати СМС за верификација",
"SEND_EMAIL": "Испрати е-пошта",
"USERNAME": "Корисничко име", "USERNAME": "Корисничко име",
"CHANGEUSERNAME": "промени", "CHANGEUSERNAME": "промени",
"CHANGEUSERNAME_TITLE": "Промени корисничко име", "CHANGEUSERNAME_TITLE": "Промени корисничко име",
@@ -949,6 +952,14 @@
"5": "Суспендирано", "5": "Суспендирано",
"6": "Иницијално" "6": "Иницијално"
}, },
"STATEV2": {
"0": "Непознато",
"1": "Активно",
"2": "Неактивно",
"3": "Избришано",
"4": "Заклучено",
"5": "Иницијално"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Корисничко име (тековна организација)", "ADDITIONAL": "Корисничко име (тековна организација)",
"ADDITIONAL-EXTERNAL": "Корисничко име (надворешна организација)" "ADDITIONAL-EXTERNAL": "Корисничко име (надворешна организација)"
@@ -1487,7 +1498,9 @@
"ENABLED": "„Овозможено“ е наследено", "ENABLED": "„Овозможено“ е наследено",
"DISABLED": "„Оневозможено“ е наследено" "DISABLED": "„Оневозможено“ е наследено"
}, },
"RESET": "Поставете ги сите да наследат" "RESET": "Поставете ги сите да наследат",
"CONSOLEUSEV2USERAPI": "Користете V2 API во конзолата за креирање на корисници",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Кога ова знаме е овозможено, конзолата го користи V2 User API за креирање на нови корисници. Со V2 API, новосоздадените корисници започнуваат без почетна состојба."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Telefoonnummer", "PHONE": "Telefoonnummer",
"PHONE_HINT": "Gebruik het + symbool gevolgd door de landcode, of selecteer het land uit de dropdown en voer ten slotte het telefoonnummer in", "PHONE_HINT": "Gebruik het + symbool gevolgd door de landcode, of selecteer het land uit de dropdown en voer ten slotte het telefoonnummer in",
"PHONE_VERIFIED": "Telefoonnummer geverifieerd",
"SEND_SMS": "Verificatie-SMS verzenden",
"SEND_EMAIL": "E-mail verzenden",
"USERNAME": "Gebruikersnaam", "USERNAME": "Gebruikersnaam",
"CHANGEUSERNAME": "wijzigen", "CHANGEUSERNAME": "wijzigen",
"CHANGEUSERNAME_TITLE": "Gebruikersnaam wijzigen", "CHANGEUSERNAME_TITLE": "Gebruikersnaam wijzigen",
@@ -949,6 +952,14 @@
"5": "Opgeschort", "5": "Opgeschort",
"6": "Initieel" "6": "Initieel"
}, },
"STATEV2": {
"0": "Onbekend",
"1": "Actief",
"2": "Inactief",
"3": "Verwijderd",
"4": "Vergrendeld",
"5": "Initieel"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Loginnaam (huidige organisatie)", "ADDITIONAL": "Loginnaam (huidige organisatie)",
"ADDITIONAL-EXTERNAL": "Loginnaam (externe organisatie)" "ADDITIONAL-EXTERNAL": "Loginnaam (externe organisatie)"
@@ -1484,7 +1495,9 @@
"ENABLED": "\"Ingeschakeld\" wordt overgenomen", "ENABLED": "\"Ingeschakeld\" wordt overgenomen",
"DISABLED": "\"Uitgeschakeld\" wordt overgenomen" "DISABLED": "\"Uitgeschakeld\" wordt overgenomen"
}, },
"RESET": "Alles instellen op overgenomen" "RESET": "Alles instellen op overgenomen",
"CONSOLEUSEV2USERAPI": "Gebruik de V2 API in de console voor het aanmaken van gebruikers",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Wanneer deze vlag is ingeschakeld, gebruikt de console de V2 User API om nieuwe gebruikers aan te maken. Met de V2 API beginnen nieuw aangemaakte gebruikers zonder een initiële status."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -808,6 +808,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Numer telefonu", "PHONE": "Numer telefonu",
"PHONE_HINT": "Użyj symbolu +, a następnie kodu kraju, z którego dzwonisz, lub wybierz kraj z listy rozwijanej i wprowadź numer telefonu.", "PHONE_HINT": "Użyj symbolu +, a następnie kodu kraju, z którego dzwonisz, lub wybierz kraj z listy rozwijanej i wprowadź numer telefonu.",
"PHONE_VERIFIED": "Numer telefonu zweryfikowany",
"SEND_SMS": "Wyślij SMS weryfikacyjny",
"SEND_EMAIL": "Wyślij E-mail",
"USERNAME": "Nazwa użytkownika", "USERNAME": "Nazwa użytkownika",
"CHANGEUSERNAME": "modyfikuj", "CHANGEUSERNAME": "modyfikuj",
"CHANGEUSERNAME_TITLE": "Zmień nazwę użytkownika", "CHANGEUSERNAME_TITLE": "Zmień nazwę użytkownika",
@@ -948,6 +951,14 @@
"5": "Zawieszony", "5": "Zawieszony",
"6": "Początkowy" "6": "Początkowy"
}, },
"STATEV2": {
"0": "Nieznany",
"1": "Aktywny",
"2": "Nieaktywny",
"3": "Usunięty",
"4": "Zablokowany",
"5": "Początkowy"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nazwa użytkownika (obecna organizacja)", "ADDITIONAL": "Nazwa użytkownika (obecna organizacja)",
"ADDITIONAL-EXTERNAL": "Nazwa użytkownika (organizacja zewnętrzna)" "ADDITIONAL-EXTERNAL": "Nazwa użytkownika (organizacja zewnętrzna)"
@@ -1485,7 +1496,9 @@
"ENABLED": "„Włączony” jest dziedziczone", "ENABLED": "„Włączony” jest dziedziczone",
"DISABLED": "„Wyłączony” jest dziedziczone" "DISABLED": "„Wyłączony” jest dziedziczone"
}, },
"RESET": "Ustaw wszystko na dziedziczone" "RESET": "Ustaw wszystko na dziedziczone",
"CONSOLEUSEV2USERAPI": "Użyj API V2 w konsoli do tworzenia użytkowników",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Gdy ta flaga jest włączona, konsola używa API V2 User do tworzenia nowych użytkowników. W przypadku API V2 nowo utworzeni użytkownicy rozpoczynają bez stanu początkowego."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail", "EMAIL": "E-mail",
"PHONE": "Número de Telefone", "PHONE": "Número de Telefone",
"PHONE_HINT": "Use o símbolo + seguido do código de chamada do país, ou selecione o país na lista suspensa e, em seguida, insira o número de telefone", "PHONE_HINT": "Use o símbolo + seguido do código de chamada do país, ou selecione o país na lista suspensa e, em seguida, insira o número de telefone",
"PHONE_VERIFIED": "Número de telefone verificado",
"SEND_SMS": "Enviar SMS de verificação",
"SEND_EMAIL": "Enviar E-mail",
"USERNAME": "Nome de Usuário", "USERNAME": "Nome de Usuário",
"CHANGEUSERNAME": "modificar", "CHANGEUSERNAME": "modificar",
"CHANGEUSERNAME_TITLE": "Alterar nome de usuário", "CHANGEUSERNAME_TITLE": "Alterar nome de usuário",
@@ -949,6 +952,14 @@
"5": "Suspenso", "5": "Suspenso",
"6": "Inicial" "6": "Inicial"
}, },
"STATEV2": {
"0": "Desconhecido",
"1": "Ativo",
"2": "Inativo",
"3": "Excluído",
"4": "Bloqueado",
"5": "Inicial"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Nome de usuário (organização atual)", "ADDITIONAL": "Nome de usuário (organização atual)",
"ADDITIONAL-EXTERNAL": "Nome de usuário (organização externa)" "ADDITIONAL-EXTERNAL": "Nome de usuário (organização externa)"
@@ -1487,7 +1498,9 @@
"ENABLED": "\"Habilitado\" é herdado", "ENABLED": "\"Habilitado\" é herdado",
"DISABLED": "\"Desabilitado\" é herdado" "DISABLED": "\"Desabilitado\" é herdado"
}, },
"RESET": "Definir tudo para herdar" "RESET": "Definir tudo para herdar",
"CONSOLEUSEV2USERAPI": "Use a API V2 no console para criação de usuários",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Quando esta opção está ativada, o console utiliza a API V2 de Usuários para criar novos usuários. Com a API V2, os novos usuários criados começam sem um estado inicial."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -816,6 +816,9 @@
"EMAIL": "Электронная почта", "EMAIL": "Электронная почта",
"PHONE": "Номер телефона", "PHONE": "Номер телефона",
"PHONE_HINT": "Используйте 00 или символ +, за которым следует код страны вызываемого абонента, или выберите страну из раскрывающегося списка и введите номер телефона.", "PHONE_HINT": "Используйте 00 или символ +, за которым следует код страны вызываемого абонента, или выберите страну из раскрывающегося списка и введите номер телефона.",
"PHONE_VERIFIED": "Номер телефона подтвержден",
"SEND_SMS": "Отправить проверочный SMS",
"SEND_EMAIL": "Отправить e-mail",
"USERNAME": "Имя пользователя", "USERNAME": "Имя пользователя",
"CHANGEUSERNAME": "Изменить", "CHANGEUSERNAME": "Изменить",
"CHANGEUSERNAME_TITLE": "Изменить имя пользователя", "CHANGEUSERNAME_TITLE": "Изменить имя пользователя",
@@ -967,6 +970,14 @@
"5": "Приостановлен", "5": "Приостановлен",
"6": "Начальный" "6": "Начальный"
}, },
"STATEV2": {
"0": "Неизвестен",
"1": "Активен",
"2": "Неактивен",
"3": "Удалён",
"4": "Заблокирован",
"5": "Начальный"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Логин (текущая организация)", "ADDITIONAL": "Логин (текущая организация)",
"ADDITIONAL-EXTERNAL": "Логин (внешняя организация)" "ADDITIONAL-EXTERNAL": "Логин (внешняя организация)"
@@ -1538,7 +1549,9 @@
"ENABLED": "«Включено» наследуется", "ENABLED": "«Включено» наследуется",
"DISABLED": "«Выключено» передается по наследству" "DISABLED": "«Выключено» передается по наследству"
}, },
"RESET": "Установить все по умолчанию" "RESET": "Установить все по умолчанию",
"CONSOLEUSEV2USERAPI": "Используйте V2 API в консоли для создания пользователей",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Когда этот флаг включен, консоль использует V2 User API для создания новых пользователей. С API V2 новые пользователи создаются без начального состояния."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-post", "EMAIL": "E-post",
"PHONE": "Telefonnummer", "PHONE": "Telefonnummer",
"PHONE_HINT": "Använd + symbolen följt av landskoden, eller välj landet från rullgardinsmenyn och ange sedan telefonnumret", "PHONE_HINT": "Använd + symbolen följt av landskoden, eller välj landet från rullgardinsmenyn och ange sedan telefonnumret",
"PHONE_VERIFIED": "Telefonnummer verifierat",
"SEND_SMS": "Skicka verifierings-SMS",
"SEND_EMAIL": "Skicka E-post",
"USERNAME": "Användarnamn", "USERNAME": "Användarnamn",
"CHANGEUSERNAME": "ändra", "CHANGEUSERNAME": "ändra",
"CHANGEUSERNAME_TITLE": "Ändra användarnamn", "CHANGEUSERNAME_TITLE": "Ändra användarnamn",
@@ -949,6 +952,14 @@
"5": "Suspenderad", "5": "Suspenderad",
"6": "Initial" "6": "Initial"
}, },
"STATEV2": {
"0": "Okänd",
"1": "Aktiv",
"2": "Inaktiv",
"3": "Raderad",
"4": "Låst",
"5": "Initial"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "Inloggningsnamn (nuvarande organisation)", "ADDITIONAL": "Inloggningsnamn (nuvarande organisation)",
"ADDITIONAL-EXTERNAL": "Inloggningsnamn (extern organisation)" "ADDITIONAL-EXTERNAL": "Inloggningsnamn (extern organisation)"
@@ -1490,7 +1501,9 @@
"ENABLED": "\"Aktiverad\" ärvs", "ENABLED": "\"Aktiverad\" ärvs",
"DISABLED": "\"Inaktiverad\" ärvs" "DISABLED": "\"Inaktiverad\" ärvs"
}, },
"RESET": "Återställ allt till arv" "RESET": "Återställ allt till arv",
"CONSOLEUSEV2USERAPI": "Använd V2 API i konsolen för att skapa användare",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "När denna flagga är aktiverad använder konsolen V2 User API för att skapa nya användare. Med V2 API startar nyligen skapade användare utan ett initialt tillstånd."
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "电子邮件", "EMAIL": "电子邮件",
"PHONE": "手机号码", "PHONE": "手机号码",
"PHONE_HINT": "使用+号,后跟呼叫者的国家/地区代码,或从下拉列表中选择国家/地区,最后输入电话号码", "PHONE_HINT": "使用+号,后跟呼叫者的国家/地区代码,或从下拉列表中选择国家/地区,最后输入电话号码",
"PHONE_VERIFIED": "电话号码已验证",
"SEND_SMS": "发送验证短信",
"SEND_EMAIL": "发送电子邮件",
"USERNAME": "用户名", "USERNAME": "用户名",
"CHANGEUSERNAME": "修改", "CHANGEUSERNAME": "修改",
"CHANGEUSERNAME_TITLE": "修改用户名称", "CHANGEUSERNAME_TITLE": "修改用户名称",
@@ -949,6 +952,14 @@
"5": "已暂停", "5": "已暂停",
"6": "初始化" "6": "初始化"
}, },
"STATEV2": {
"0": "未知",
"1": "启用",
"2": "停用",
"3": "已删除",
"4": "已锁定",
"5": "初始化"
},
"SEARCH": { "SEARCH": {
"ADDITIONAL": "登录名 (当前组织)", "ADDITIONAL": "登录名 (当前组织)",
"ADDITIONAL-EXTERNAL": "登录名 (外部组织)" "ADDITIONAL-EXTERNAL": "登录名 (外部组织)"
@@ -1486,7 +1497,9 @@
"ENABLED": "“已启用” 是继承的", "ENABLED": "“已启用” 是继承的",
"DISABLED": "“已禁用” 是继承的" "DISABLED": "“已禁用” 是继承的"
}, },
"RESET": "全部设置为继承" "RESET": "全部设置为继承",
"CONSOLEUSEV2USERAPI": "在控制台中使用V2 API创建用户。",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "启用此标志时控制台使用V2用户API创建新用户。使用V2 API新创建的用户将以无初始状态开始。"
}, },
"DIALOG": { "DIALOG": {
"RESET": { "RESET": {

View File

@@ -1501,11 +1501,31 @@
"@bufbuild/buf-win32-arm64" "1.43.0" "@bufbuild/buf-win32-arm64" "1.43.0"
"@bufbuild/buf-win32-x64" "1.43.0" "@bufbuild/buf-win32-x64" "1.43.0"
"@bufbuild/protobuf@^2.2.2":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.2.3.tgz#9cd136f6b687e63e9b517b3a54211ece942897ee"
integrity sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==
"@colors/colors@1.5.0": "@colors/colors@1.5.0":
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
"@connectrpc/connect-node@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@connectrpc/connect-node/-/connect-node-2.0.0.tgz#1ea4eff7f2633fbe3d80378e1420bd213d9d83a4"
integrity sha512-DoI5T+SUvlS/8QBsxt2iDoUg15dSxqhckegrgZpWOtADtmGohBIVbx1UjtWmjLBrP4RdD0FeBw+XyRUSbpKnJQ==
"@connectrpc/connect-web@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@connectrpc/connect-web/-/connect-web-2.0.0.tgz#14055c933bfe846e75856bce49ecd0250cbec3b2"
integrity sha512-oeCxqHXLXlWJdmcvp9L3scgAuK+FjNSn+twyhUxc8yvDbTumnt5Io+LnBzSYxAdUdYqTw5yHfTSCJ4hj0QID0g==
"@connectrpc/connect@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@connectrpc/connect/-/connect-2.0.0.tgz#0a34d934bdce5fd3723f79b70aa843d4c1213df8"
integrity sha512-Usm8jgaaULANJU8vVnhWssSA6nrZ4DJEAbkNtXSoZay2YD5fDyMukCxu8NEhCvFzfHvrhxhcjttvgpyhOM7xAQ==
"@ctrl/ngx-codemirror@^6.1.0": "@ctrl/ngx-codemirror@^6.1.0":
version "6.1.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/@ctrl/ngx-codemirror/-/ngx-codemirror-6.1.0.tgz#9324a56e4b709be9c515364d21e05e1d7589f009" resolved "https://registry.yarnpkg.com/@ctrl/ngx-codemirror/-/ngx-codemirror-6.1.0.tgz#9324a56e4b709be9c515364d21e05e1d7589f009"
@@ -3496,6 +3516,25 @@
js-yaml "^3.10.0" js-yaml "^3.10.0"
tslib "^2.4.0" tslib "^2.4.0"
"@zitadel/client@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@zitadel/client/-/client-1.0.6.tgz#9fe44ff7c757e8f38fa08d25083dc036afebf5cb"
integrity sha512-MG6RAApoI2Y3QGRfKByISOqGTSFsMr5YtKQYPFDAJhivYK32d7hUiMEv+WzShfGHEI38336FbKz9vg/4M961Lg==
dependencies:
"@bufbuild/protobuf" "^2.2.2"
"@connectrpc/connect" "^2.0.0"
"@connectrpc/connect-node" "^2.0.0"
"@connectrpc/connect-web" "^2.0.0"
"@zitadel/proto" "1.0.3"
jose "^5.3.0"
"@zitadel/proto@1.0.3", "@zitadel/proto@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@zitadel/proto/-/proto-1.0.3.tgz#28721710d9e87009adf14f90e0c8cb9bae5275ec"
integrity sha512-95XPGgFgfTwU1A3oQYxTv4p+Qy/9yMO/o21VRtPBfVhPusFFCW0ddg4YoKTKpQl9FbIG7VYMLmRyuJBPuf3r+g==
dependencies:
"@bufbuild/protobuf" "^2.2.2"
"@zkochan/js-yaml@0.0.6": "@zkochan/js-yaml@0.0.6":
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz#975f0b306e705e28b8068a07737fa46d3fc04826" resolved "https://registry.yarnpkg.com/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz#975f0b306e705e28b8068a07737fa46d3fc04826"
@@ -6538,6 +6577,11 @@ jiti@^1.18.2:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268"
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
jose@^5.3.0:
version "5.9.6"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.9.6.tgz#77f1f901d88ebdc405e57cce08d2a91f47521883"
integrity sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

View File

@@ -56,8 +56,8 @@ describe('machines', () => {
loginName = loginname(machine.removeName, Cypress.env('ORGANIZATION')); loginName = loginname(machine.removeName, Cypress.env('ORGANIZATION'));
} }
it('should delete a machine', () => { it('should delete a machine', () => {
const rowSelector = `tr:contains(${machine.removeName})`; const rowSelector = `tr:contains('${machine.removeName}')`;
cy.get(rowSelector).find('[data-e2e="enabled-delete-button"]').click({ force: true }); cy.get(rowSelector).should('be.visible').find('[data-e2e="enabled-delete-button"]').click({ force: true });
cy.get('[data-e2e="confirm-dialog-input"]').focus().should('be.enabled').type(loginName); cy.get('[data-e2e="confirm-dialog-input"]').focus().should('be.enabled').type(loginName);
cy.get('[data-e2e="confirm-dialog-button"]').click(); cy.get('[data-e2e="confirm-dialog-button"]').click();
cy.shouldConfirmSuccess(); cy.shouldConfirmSuccess();

View File

@@ -71,6 +71,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com
EnableBackChannelLogout: req.EnableBackChannelLogout, EnableBackChannelLogout: req.EnableBackChannelLogout,
LoginV2: loginV2, LoginV2: loginV2,
PermissionCheckV2: req.PermissionCheckV2, PermissionCheckV2: req.PermissionCheckV2,
ConsoleUseV2UserApi: req.ConsoleUseV2UserApi,
}, nil }, nil
} }
@@ -91,6 +92,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat
EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout),
LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2),
PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2),
ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi),
} }
} }

View File

@@ -183,6 +183,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) {
Required: true, Required: true,
BaseUri: gu.Ptr("https://login.com"), BaseUri: gu.Ptr("https://login.com"),
}, },
ConsoleUseV2UserApi: gu.Ptr(true),
} }
want := &command.InstanceFeatures{ want := &command.InstanceFeatures{
LoginDefaultOrg: gu.Ptr(true), LoginDefaultOrg: gu.Ptr(true),
@@ -200,6 +201,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) {
Required: true, Required: true,
BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, BaseURI: &url.URL{Scheme: "https", Host: "login.com"},
}, },
ConsoleUseV2UserApi: gu.Ptr(true),
} }
got, err := instanceFeaturesToCommand(arg) got, err := instanceFeaturesToCommand(arg)
assert.Equal(t, want, got) assert.Equal(t, want, got)
@@ -264,6 +266,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
Level: feature.LevelInstance, Level: feature.LevelInstance,
Value: true, Value: true,
}, },
ConsoleUseV2UserApi: query.FeatureSource[bool]{
Level: feature.LevelInstance,
Value: true,
},
} }
want := &feature_pb.GetInstanceFeaturesResponse{ want := &feature_pb.GetInstanceFeaturesResponse{
Details: &object.Details{ Details: &object.Details{
@@ -328,6 +334,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
Enabled: true, Enabled: true,
Source: feature_pb.Source_SOURCE_INSTANCE, Source: feature_pb.Source_SOURCE_INSTANCE,
}, },
ConsoleUseV2UserApi: &feature_pb.FeatureFlag{
Enabled: true,
Source: feature_pb.Source_SOURCE_INSTANCE,
},
} }
got := instanceFeaturesToPb(arg) got := instanceFeaturesToPb(arg)
assert.Equal(t, want, got) assert.Equal(t, want, got)

View File

@@ -30,6 +30,7 @@ type InstanceFeatures struct {
EnableBackChannelLogout *bool EnableBackChannelLogout *bool
LoginV2 *feature.LoginV2 LoginV2 *feature.LoginV2
PermissionCheckV2 *bool PermissionCheckV2 *bool
ConsoleUseV2UserApi *bool
} }
func (m *InstanceFeatures) isEmpty() bool { func (m *InstanceFeatures) isEmpty() bool {
@@ -47,7 +48,7 @@ func (m *InstanceFeatures) isEmpty() bool {
m.DisableUserTokenEvent == nil && m.DisableUserTokenEvent == nil &&
m.EnableBackChannelLogout == nil && m.EnableBackChannelLogout == nil &&
m.LoginV2 == nil && m.LoginV2 == nil &&
m.PermissionCheckV2 == nil m.PermissionCheckV2 == nil && m.ConsoleUseV2UserApi == nil
} }
func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) {

View File

@@ -80,6 +80,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder {
feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout,
feature_v2.InstanceLoginVersion, feature_v2.InstanceLoginVersion,
feature_v2.InstancePermissionCheckV2, feature_v2.InstancePermissionCheckV2,
feature_v2.InstanceConsoleUseV2UserApi,
). ).
Builder().ResourceOwner(m.ResourceOwner) Builder().ResourceOwner(m.ResourceOwner)
} }
@@ -133,6 +134,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an
case feature.KeyPermissionCheckV2: case feature.KeyPermissionCheckV2:
v := value.(bool) v := value.(bool)
features.PermissionCheckV2 = &v features.PermissionCheckV2 = &v
case feature.KeyConsoleUseV2UserApi:
v := value.(bool)
features.ConsoleUseV2UserApi = &v
} }
} }
@@ -153,5 +157,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.InstanceLoginVersion) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.InstanceLoginVersion)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.InstancePermissionCheckV2) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.InstancePermissionCheckV2)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.ConsoleUseV2UserApi, f.ConsoleUseV2UserApi, feature_v2.InstanceConsoleUseV2UserApi)
return cmds return cmds
} }

View File

@@ -24,6 +24,7 @@ const (
KeyEnableBackChannelLogout KeyEnableBackChannelLogout
KeyLoginV2 KeyLoginV2
KeyPermissionCheckV2 KeyPermissionCheckV2
KeyConsoleUseV2UserApi
) )
//go:generate enumer -type Level -transform snake -trimprefix Level //go:generate enumer -type Level -transform snake -trimprefix Level
@@ -54,6 +55,7 @@ type Features struct {
EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"`
LoginV2 LoginV2 `json:"login_v2,omitempty"` LoginV2 LoginV2 `json:"login_v2,omitempty"`
PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` PermissionCheckV2 bool `json:"permission_check_v2,omitempty"`
ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"`
} }
type ImprovedPerformanceType int32 type ImprovedPerformanceType int32

View File

@@ -7,11 +7,11 @@ import (
"strings" "strings"
) )
const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api"
var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255, 274} var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255, 274, 297}
const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2" const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api"
func (i Key) String() string { func (i Key) String() string {
if i < 0 || i >= Key(len(_KeyIndex)-1) { if i < 0 || i >= Key(len(_KeyIndex)-1) {
@@ -39,9 +39,10 @@ func _KeyNoOp() {
_ = x[KeyEnableBackChannelLogout-(12)] _ = x[KeyEnableBackChannelLogout-(12)]
_ = x[KeyLoginV2-(13)] _ = x[KeyLoginV2-(13)]
_ = x[KeyPermissionCheckV2-(14)] _ = x[KeyPermissionCheckV2-(14)]
_ = x[KeyConsoleUseV2UserApi-(15)]
} }
var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2} var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi}
var _KeyNameToValueMap = map[string]Key{ var _KeyNameToValueMap = map[string]Key{
_KeyName[0:11]: KeyUnspecified, _KeyName[0:11]: KeyUnspecified,
@@ -74,6 +75,8 @@ var _KeyNameToValueMap = map[string]Key{
_KeyLowerName[247:255]: KeyLoginV2, _KeyLowerName[247:255]: KeyLoginV2,
_KeyName[255:274]: KeyPermissionCheckV2, _KeyName[255:274]: KeyPermissionCheckV2,
_KeyLowerName[255:274]: KeyPermissionCheckV2, _KeyLowerName[255:274]: KeyPermissionCheckV2,
_KeyName[274:297]: KeyConsoleUseV2UserApi,
_KeyLowerName[274:297]: KeyConsoleUseV2UserApi,
} }
var _KeyNames = []string{ var _KeyNames = []string{
@@ -92,6 +95,7 @@ var _KeyNames = []string{
_KeyName[221:247], _KeyName[221:247],
_KeyName[247:255], _KeyName[247:255],
_KeyName[255:274], _KeyName[255:274],
_KeyName[274:297],
} }
// KeyString retrieves an enum value from the enum constants string name. // KeyString retrieves an enum value from the enum constants string name.

View File

@@ -23,6 +23,7 @@ type InstanceFeatures struct {
EnableBackChannelLogout FeatureSource[bool] EnableBackChannelLogout FeatureSource[bool]
LoginV2 FeatureSource[*feature.LoginV2] LoginV2 FeatureSource[*feature.LoginV2]
PermissionCheckV2 FeatureSource[bool] PermissionCheckV2 FeatureSource[bool]
ConsoleUseV2UserApi FeatureSource[bool]
} }
func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) {

View File

@@ -76,6 +76,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder {
feature_v2.InstanceEnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout,
feature_v2.InstanceLoginVersion, feature_v2.InstanceLoginVersion,
feature_v2.InstancePermissionCheckV2, feature_v2.InstancePermissionCheckV2,
feature_v2.InstanceConsoleUseV2UserApi,
). ).
Builder().ResourceOwner(m.ResourceOwner) Builder().ResourceOwner(m.ResourceOwner)
} }
@@ -142,6 +143,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_
features.LoginV2.set(level, event.Value) features.LoginV2.set(level, event.Value)
case feature.KeyPermissionCheckV2: case feature.KeyPermissionCheckV2:
features.PermissionCheckV2.set(level, event.Value) features.PermissionCheckV2.set(level, event.Value)
case feature.KeyConsoleUseV2UserApi:
features.ConsoleUseV2UserApi.set(level, event.Value)
} }
return nil return nil
} }

View File

@@ -116,6 +116,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer {
Event: feature_v2.InstancePermissionCheckV2, Event: feature_v2.InstancePermissionCheckV2,
Reduce: reduceInstanceSetFeature[bool], Reduce: reduceInstanceSetFeature[bool],
}, },
{
Event: feature_v2.InstanceConsoleUseV2UserApi,
Reduce: reduceInstanceSetFeature[bool],
},
{ {
Event: instance.InstanceRemovedEventType, Event: instance.InstanceRemovedEventType,
Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol),

View File

@@ -35,4 +35,5 @@ func init() {
eventstore.RegisterFilterEventMapper(AggregateType, InstanceEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceEnableBackChannelLogout, eventstore.GenericEventMapper[SetEvent[bool]])
eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginVersion, eventstore.GenericEventMapper[SetEvent[*feature.LoginV2]])
eventstore.RegisterFilterEventMapper(AggregateType, InstancePermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstancePermissionCheckV2, eventstore.GenericEventMapper[SetEvent[bool]])
eventstore.RegisterFilterEventMapper(AggregateType, InstanceConsoleUseV2UserApi, eventstore.GenericEventMapper[SetEvent[bool]])
} }

View File

@@ -40,6 +40,7 @@ var (
InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout)
InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2)
InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2)
InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi)
) )
const ( const (

View File

@@ -106,6 +106,13 @@ message SetInstanceFeaturesRequest{
description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs.";
} }
]; ];
optional bool console_use_v2_user_api = 15 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "true";
description: "If this is enabled the console web client will use the new User v2 API for certain calls";
}
];
} }
message SetInstanceFeaturesResponse { message SetInstanceFeaturesResponse {
@@ -225,4 +232,11 @@ message GetInstanceFeaturesResponse {
description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs."; description: "Enable a newer, more performant, permission check used for v2 and v3 resource based APIs.";
} }
]; ];
FeatureFlag console_use_v2_user_api = 16 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "true";
description: "If this is enabled the console web client will use the new User v2 API for certain calls";
}
];
} }