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",
"maximumWarning": "8mb",
"maximumError": "9mb"
"maximumError": "10mb"
},
{
"type": "anyComponentStyle",

View File

@@ -24,6 +24,8 @@
"@angular/platform-browser-dynamic": "^16.2.5",
"@angular/router": "^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",
"@fortawesome/angular-fontawesome": "^0.13.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
@@ -31,6 +33,8 @@
"@grpc/grpc-js": "^1.11.2",
"@netlify/framework-info": "^9.8.13",
"@ngx-translate/core": "^15.0.0",
"@zitadel/client": "^1.0.6",
"@zitadel/proto": "^1.0.3",
"angular-oauth2-oidc": "^15.0.1",
"angularx-qrcode": "^16.0.0",
"buffer": "^6.0.3",

View File

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

View File

@@ -8,7 +8,7 @@ import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
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 { Org } from './proto/generated/zitadel/org_pb';
@@ -275,7 +275,7 @@ export class AppComponent implements OnDestroy {
const currentUrl = this.router.url;
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
// 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.setDefaultLang(fallbackLanguage);
this.authService.userSubject.pipe(takeUntil(this.destroy$)).subscribe((userprofile) => {
if (userprofile) {
const cropped = navigator.language.split('-')[0] ?? fallbackLanguage;
const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
this.authService.user.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((userprofile) => {
const cropped = navigator.language.split('-')[0] ?? fallbackLanguage;
const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp)
? userprofile.human.profile?.preferredLanguage
: fallbackLang;
this.translate.use(lang);
this.language = lang;
this.document.documentElement.lang = lang;
}
const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp)
? userprofile.human.profile?.preferredLanguage
: fallbackLang;
this.translate.use(lang);
this.language = lang;
this.document.documentElement.lang = lang;
});
}
@@ -308,7 +306,7 @@ export class AppComponent implements OnDestroy {
}
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) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrlDark).then((response) => {

View File

@@ -403,6 +403,34 @@
'SETTING.FEATURES.OIDCSINGLEV1SESSIONTERMINATION_DESCRIPTION' | translate
}}</cnsl-info-section>
</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>
</cnsl-card>
</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 { Event } from 'src/app/proto/generated/zitadel/event_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 { FeatureService } from 'src/app/services/feature.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 {
ENABLED = 'ENABLED',
@@ -39,6 +40,7 @@ type ToggleStates = {
oidcTokenExchange?: FeatureState;
actions?: FeatureState;
oidcSingleV1SessionTermination?: FeatureState;
consoleUseV2UserApi?: FeatureState;
};
@Component({
@@ -142,6 +144,7 @@ export class FeaturesComponent implements OnDestroy {
);
changed = true;
}
req.setConsoleUseV2UserApi(this.toggleStates?.consoleUseV2UserApi?.state === ToggleState.ENABLED);
if (changed) {
this.featureService
@@ -232,6 +235,10 @@ export class FeaturesComponent implements OnDestroy {
? ToggleState.ENABLED
: 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 { Subject, takeUntil } from 'rxjs';
import { DestroyRef, Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Directive({
selector: '[cnslHasRole]',
})
export class HasRoleDirective implements OnDestroy {
private destroy$: Subject<void> = new Subject();
export class HasRoleDirective {
private hasView: boolean = false;
@Input() public set hasRole(roles: string[] | RegExp[] | undefined) {
if (roles && roles.length > 0) {
this.authService
.isAllowed(roles)
.pipe(takeUntil(this.destroy$))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isAllowed) => {
if (isAllowed && !this.hasView) {
if (this.viewContainerRef.length !== 0) {
@@ -38,10 +37,6 @@ export class HasRoleDirective implements OnDestroy {
private authService: GrpcAuthService,
protected templateRef: TemplateRef<any>,
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 { AuthenticationService } from 'src/app/services/authentication.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-accounts-card',
@@ -18,6 +19,8 @@ export class AccountsCardComponent implements OnInit {
public sessions: Session.AsObject[] = [];
public loadingUsers: boolean = false;
public UserState: any = UserState;
private labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined });
constructor(
public authService: AuthenticationService,
private router: Router,
@@ -68,7 +71,7 @@ export class AccountsCardComponent implements OnInit {
}
public logout(): void {
const lP = JSON.stringify(this.userService.labelpolicy.getValue());
const lP = JSON.stringify(this.labelpolicy());
localStorage.setItem('labelPolicyOnSignout', lP);
this.authService.signout();

View File

@@ -1,6 +1,6 @@
<div class="footer-wrapper">
<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>
<span>{{ 'FOOTER.LINKS.TOS' | translate }}</span>
<i class="las la-external-link-alt"></i>
@@ -11,7 +11,7 @@
</a>
</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">
<a target="_blank" rel="noreferrer" href="https://github.com/zitadel">
<i class="text-3xl lab la-github"></i>

View File

@@ -4,7 +4,7 @@
<mat-spinner [diameter]="20"></mat-spinner>
</div>
<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
class="logo"
alt="home logo"
@@ -24,7 +24,7 @@
</ng-template>
<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
class="logo"
alt="zitadel logo"
@@ -168,7 +168,7 @@
<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">
{{ pP.customLinkText }}
</a>

View File

@@ -9,26 +9,26 @@
inactive: user.state === UserState.USER_STATE_INACTIVE,
}"
>
{{ 'USER.DATA.STATE' + user.state | translate }}
{{ (isV2(user) ? 'USER.STATEV2.' : 'USER.STATE.') + user.state | translate }}
</p>
</div>
<div class="info-wrapper">
<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 class="info-wrapper">
<p class="info-row-title">{{ 'USER.DETAILS.DATECREATED' | translate }}</p>
<p *ngIf="user && user.details && user.details.creationDate" class="info-row-desc">
{{ user.details.creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
<p *ngIf="creationDate" class="info-row-desc">
{{ creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
</p>
</div>
<div class="info-wrapper">
<p class="info-row-title">{{ 'USER.DETAILS.DATECHANGED' | translate }}</p>
<p *ngIf="user && user.details && user.details.changeDate" class="info-row-desc">
{{ user.details.changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
<p *ngIf="changeDate" class="info-row-desc">
{{ changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}
</p>
</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 { 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 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({
selector: 'cnsl-info-row',
@@ -13,14 +16,14 @@ import { User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
styleUrls: ['./info-row.component.scss'],
})
export class InfoRowComponent {
@Input() public user!: User.AsObject;
@Input() public user?: User.AsObject | UserV2 | UserV1;
@Input() public org!: Org.AsObject;
@Input() public instance!: InstanceDetail.AsObject;
@Input() public app!: App.AsObject;
@Input() public idp!: IDP.AsObject;
@Input() public project!: Project.AsObject;
@Input() public grantedProject!: GrantedProject.AsObject;
@Input() public loginPolicy?: LoginPolicy.AsObject;
@Input() public loginPolicy?: LoginPolicy.AsObject | LoginPolicyV2;
public UserState: any = UserState;
public State: any = State;
@@ -35,25 +38,77 @@ export class InfoRowComponent {
constructor() {}
public get loginMethods(): Set<string> {
const methods = this.user?.loginNamesList;
let email: string = '';
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;
}
if (!this.user) {
return new Set();
}
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 { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
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 { 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({
selector: 'cnsl-metadata-dialog',
@@ -10,72 +18,63 @@ import { ToastService } from 'src/app/services/toast.service';
styleUrls: ['./metadata-dialog.component.scss'],
})
export class MetadataDialogComponent {
public metadata: Partial<Metadata.AsObject>[] = [];
public metadata: { key: string; value: string }[] = [];
public ts!: Timestamp.AsObject | undefined;
constructor(
private toast: ToastService,
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 {
const newGroup = {
this.metadata.push({
key: '',
value: '',
};
this.metadata.push(newGroup);
});
}
public removeEntry(index: number): void {
public async removeEntry(index: number) {
const key = this.metadata[index].key;
if (key) {
this.removeMetadata(key).then(() => {
this.metadata.splice(index, 1);
if (this.metadata.length === 0) {
this.addEntry();
}
});
} else {
if (!key) {
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 {
const metadataElement = this.metadata[index];
public async saveElement(index: number) {
const { key, value } = this.metadata[index];
if (metadataElement.key && metadataElement.value) {
this.setMetadata(metadataElement.key, metadataElement.value as string);
if (!key || !value) {
return;
}
}
public setMetadata(key: string, value: string): void {
if (key && value) {
this.data
.setFcn(key, value)
.then(() => {
this.toast.showInfo('METADATA.SETSUCCESS', true);
})
.catch((error: any) => {
this.toast.showError(error);
});
try {
await this.data.setFcn(key, value);
this.toast.showInfo('METADATA.SETSUCCESS', true);
} catch (error) {
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 {
this.dialogRef.close();
}

View File

@@ -1,7 +1,12 @@
<cnsl-card class="metadata-details" [title]="'DESCRIPTIONS.METADATA_TITLE' | translate" [description]="description">
<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()">
{{ 'ACTIONS.EDIT' | translate }}
</button>
@@ -28,7 +33,7 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</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>
<span>{{ 'USER.MFA.EMPTY' | translate }}</span>
</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 { MatTable, MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs';
import { MatTableDataSource } from '@angular/material/table';
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 { Buffer } from 'buffer';
type StringMetadata = {
key: string;
value: string;
};
@Component({
selector: 'cnsl-metadata',
templateUrl: './metadata.component.html',
styleUrls: ['./metadata.component.scss'],
})
export class MetadataComponent implements OnChanges {
@Input() public metadata: Metadata.AsObject[] = [];
export class MetadataComponent implements OnInit {
@Input({ required: true }) public set metadata(metadata: (Metadata.AsObject | MetadataV2)[]) {
this.metadata$.next(metadata);
}
@Input() public disabled: boolean = false;
@Input() public loading: boolean = false;
@Input({ required: true }) public description!: string;
@@ -18,18 +28,23 @@ export class MetadataComponent implements OnChanges {
@Output() public refresh: EventEmitter<void> = new EventEmitter();
public displayedColumns: string[] = ['key', 'value'];
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
public metadata$ = new ReplaySubject<(Metadata.AsObject | MetadataV2)[]>(1);
public dataSource$?: Observable<MatTableDataSource<StringMetadata>>;
@ViewChild(MatTable) public table!: MatTable<Metadata.AsObject>;
@ViewChild(MatSort) public sort!: MatSort;
public dataSource: MatTableDataSource<Metadata.AsObject> = new MatTableDataSource<Metadata.AsObject>([]);
constructor() {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['metadata']?.currentValue) {
this.dataSource = new MatTableDataSource<Metadata.AsObject>(changes['metadata'].currentValue);
}
ngOnInit() {
this.dataSource$ = this.metadata$.pipe(
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 { Timestamp } from 'src/app/proto/generated/google/protobuf/timestamp_pb';
import { Timestamp as ConnectTimestamp } from '@bufbuild/protobuf/wkt';
export interface PageEvent {
length: number;
@@ -14,7 +15,7 @@ export interface PageEvent {
styleUrls: ['./paginator.component.scss'],
})
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 pageSize: number = 10;
@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 { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { RefreshService } from 'src/app/services/refresh.service';
import { Timestamp as ConnectTimestamp } from '@bufbuild/protobuf/wkt';
import { ActionKeysType } from '../action-keys/action-keys.component';
@@ -27,7 +28,7 @@ const rotate = animation([
})
export class RefreshTableComponent implements OnInit {
@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 emitRefreshAfterTimeoutInMs: number = 0;
@Input() public loading: boolean | null = false;

View File

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

View File

@@ -19,16 +19,21 @@ export class SignedoutComponent {
const lP = localStorage.getItem(LABELPOLICY_LOCALSTORAGE_KEY);
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 {
if (!lP) {
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 }}"
[createSteps]="1"
[currentCreateStep]="1"
(closed)="close()"
(closed)="location.back()"
>
<div class="user-create-main-content">
<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">
<p class="user-create-section">{{ 'USER.CREATE.NAMEANDEMAILSECTION' | translate }}</p>
@@ -19,12 +24,13 @@
<cnsl-form-field>
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input
*ngIf="suffixPadding$ | async as suffixPadding"
cnslInput
formControlName="userName"
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>
@@ -42,28 +48,39 @@
</div>
<div class="email-is-verified">
<mat-checkbox class="block-checkbox" formControlName="isVerified">
<mat-checkbox class="block-checkbox" formControlName="emailVerified">
{{ 'USER.LOGINMETHODS.EMAIL.ISVERIFIED' | translate }}
</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 }">
{{ 'ORG.PAGES.USEPASSWORD' | translate }}
</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>
</cnsl-info-section>
</div>
<div class="pwd-section" *ngIf="usePassword && pwdForm">
<cnsl-password-complexity-view class="complexity-view" [policy]="this.policy" [password]="password">
<div class="pwd-section" *ngIf="usePassword && (passwordComplexityPolicy$ | async) as passwordComplexityPolicy">
<cnsl-password-complexity-view
class="complexity-view"
[policy]="passwordComplexityPolicy"
[password]="pwdForm.controls.password"
>
</cnsl-password-complexity-view>
<form [formGroup]="pwdForm">
<div class="user-create-grid">
<cnsl-form-field *ngIf="password">
<cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.NEWINITIAL' | translate }}</cnsl-label>
<input cnslInput autocomplete="off" name="firstpassword" formControlName="password" type="password" />
</cnsl-form-field>
<cnsl-form-field *ngIf="confirmPassword">
<cnsl-form-field>
<cnsl-label>{{ 'USER.PASSWORD.CONFIRMINITIAL' | translate }}</cnsl-label>
<input
cnslInput
@@ -128,6 +145,18 @@
<input cnslInput formControlName="phone" matTooltip="{{ 'USER.PROFILE.PHONE_HINT' | translate }}" />
</cnsl-form-field>
</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 class="user-create-btn-container">
<button

View File

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

View File

@@ -1,10 +1,19 @@
import { Location } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms';
import { Component, DestroyRef, ElementRef, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, FormControl, ValidatorFn } from '@angular/forms';
import { Router } from '@angular/router';
import { Subject, debounceTime, Observable } from 'rxjs';
import { AddHumanUserRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Domain } from 'src/app/proto/generated/zitadel/org_pb';
import {
debounceTime,
defer,
of,
Observable,
shareReplay,
firstValueFrom,
forkJoin,
ObservedValueOf,
EMPTY,
ReplaySubject,
} from 'rxjs';
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Gender } from 'src/app/proto/generated/zitadel/user_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
@@ -23,249 +32,354 @@ import {
passwordConfirmValidator,
phoneValidator,
requiredValidator,
} from '../../../modules/form-field/validators/validators';
import { LanguagesService } from '../../../services/languages.service';
} from 'src/app/modules/form-field/validators/validators';
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({
selector: 'cnsl-user-create',
templateUrl: './user-create.component.html',
styleUrls: ['./user-create.component.scss'],
})
export class UserCreateComponent implements OnInit, OnDestroy {
public user: AddHumanUserRequest.AsObject = new AddHumanUserRequest().toObject();
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
export class UserCreateComponent implements OnInit {
public readonly genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
public selected: CountryPhoneCode | undefined = {
countryCallingCode: '1',
countryCode: 'US',
countryName: 'United States of America',
};
public countryPhoneCodes: CountryPhoneCode[] = [];
public userForm!: UntypedFormGroup;
public pwdForm!: UntypedFormGroup;
private destroyed$: Subject<void> = new Subject();
public readonly countryPhoneCodes: CountryPhoneCode[];
public userLoginMustBeDomain: boolean = false;
public loading: boolean = false;
public loading = 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 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(
private router: Router,
private toast: ToastService,
private fb: UntypedFormBuilder,
private mgmtService: ManagementService,
private changeDetRef: ChangeDetectorRef,
private _location: Location,
private countryCallingCodesService: CountryCallingCodesService,
public langSvc: LanguagesService,
breadcrumbService: BreadcrumbService,
private readonly router: Router,
private readonly toast: ToastService,
private readonly fb: FormBuilder,
private readonly mgmtService: ManagementService,
private readonly userService: UserService,
public readonly langSvc: LanguagesService,
private readonly featureService: FeatureService,
private readonly destroyRef: DestroyRef,
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({
type: BreadcrumbType.ORG,
routerLink: ['/org'],
}),
]);
this.loading = true;
this.loadOrg();
this.mgmtService
.getDomainPolicy()
.then((resp) => {
if (resp.policy?.userLoginMustBeDomain) {
this.userLoginMustBeDomain = resp.policy.userLoginMustBeDomain;
}
private getUseV2Api(): Observable<boolean> {
return defer(() => this.featureService.getInstanceFeatures(true)).pipe(
map((features) => !!features.getConsoleUseV2UserApi()?.getEnabled()),
timeout(1000),
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;
this.changeDetRef.detectChanges();
})
.catch((error) => {
console.error(error);
this.initForm();
this.loading = false;
this.changeDetRef.detectChanges();
});
}),
catchError(() => of('')),
);
}
public close(): void {
this._location.back();
private getSuffixPadding() {
return this.suffix$.pipe(
map((suffix) => `${suffix.offsetWidth + 10}px`),
startWith('10px'),
);
}
private async loadOrg(): Promise<void> {
const domains = await this.mgmtService.listOrgDomains();
const found = domains.resultList.find((resp) => resp.isPrimary);
if (found) {
this.primaryDomain = found;
}
private getPasswordComplexityPolicy() {
return defer(() => this.mgmtService.getPasswordComplexityPolicy()).pipe(
map(({ policy }) => policy),
filter(Boolean),
catchError((error) => {
this.toast.showError(error);
return EMPTY;
}),
);
}
private initForm(): void {
this.userForm = this.fb.group({
email: ['', [requiredValidator, emailValidator]],
userName: ['', [requiredValidator, minLengthValidator(2)]],
firstName: ['', requiredValidator],
lastName: ['', requiredValidator],
nickName: [''],
gender: [],
preferredLanguage: [''],
phone: ['', phoneValidator],
isVerified: [false, []],
public buildUserForm() {
return this.fb.group({
email: new FormControl('', { nonNullable: true, validators: [requiredValidator, emailValidator] }),
userName: new FormControl('', { nonNullable: true, validators: [requiredValidator, minLengthValidator(2)] }),
firstName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
lastName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
nickName: new FormControl('', { nonNullable: true }),
gender: new FormControl(Gender.GENDER_UNSPECIFIED, { nonNullable: true, validators: [requiredValidator] }),
preferredLanguage: new FormControl('', { nonNullable: true }),
phone: new FormControl('', { nonNullable: true, validators: [phoneValidator] }),
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];
this.mgmtService.getPasswordComplexityPolicy().then((data) => {
if (data.policy) {
this.policy = data.policy;
if (this.policy.minLength) {
validators.push(minLengthValidator(this.policy.minLength));
public buildPwdForm(passwordComplexityPolicy$: Observable<PasswordComplexityPolicy.AsObject>) {
return passwordComplexityPolicy$.pipe(
map((policy) => {
const validators: [ValidatorFn] = [requiredValidator];
if (policy.minLength) {
validators.push(minLengthValidator(policy.minLength));
}
if (this.policy.hasLowercase) {
if (policy.hasLowercase) {
validators.push(containsLowerCaseValidator);
}
if (this.policy.hasUppercase) {
if (policy.hasUppercase) {
validators.push(containsUpperCaseValidator);
}
if (this.policy.hasNumber) {
if (policy.hasNumber) {
validators.push(containsNumberValidator);
}
if (this.policy.hasSymbol) {
if (policy.hasSymbol) {
validators.push(containsSymbolValidator);
}
const pwdValidators = [...validators] as ValidatorFn[];
const confirmPwdValidators = [requiredValidator, passwordConfirmValidator()] as ValidatorFn[];
this.pwdForm = this.fb.group({
password: ['', pwdValidators],
confirmPassword: ['', confirmPwdValidators],
return this.fb.group({
password: new FormControl('', { nonNullable: true, validators }),
confirmPassword: new FormControl('', {
nonNullable: true,
validators: [requiredValidator, passwordConfirmValidator()],
}),
});
}
});
}),
);
}
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);
if (phoneNumber) {
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
this.phone?.setValue(phoneNumber.phone);
phone.setValue(phoneNumber.phone);
}
});
}
public createUser(): void {
this.user = this.userForm.value;
public async createUser(pwdForm: ObservedValueOf<typeof this.pwdForm$>): Promise<void> {
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;
const controls = this.userForm.controls;
const profileReq = new AddHumanUserRequest.Profile();
profileReq.setFirstName(this.firstName?.value);
profileReq.setLastName(this.lastName?.value);
profileReq.setNickName(this.nickName?.value);
profileReq.setPreferredLanguage(this.preferredLanguage?.value);
profileReq.setGender(this.gender?.value);
profileReq.setFirstName(controls.firstName.value);
profileReq.setLastName(controls.lastName.value);
profileReq.setNickName(controls.nickName.value);
profileReq.setPreferredLanguage(controls.preferredLanguage.value);
profileReq.setGender(controls.gender.value);
const humanReq = new AddHumanUserRequest();
humanReq.setUserName(this.userName?.value);
humanReq.setUserName(controls.userName.value);
humanReq.setProfile(profileReq);
const emailreq = new AddHumanUserRequest.Email();
emailreq.setEmail(this.email?.value);
emailreq.setIsEmailVerified(this.isVerified?.value);
emailreq.setEmail(controls.email.value);
emailreq.setIsEmailVerified(controls.emailVerified.value);
humanReq.setEmail(emailreq);
if (this.usePassword && this.password?.value) {
humanReq.setInitialPassword(this.password.value);
if (this.usePassword) {
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
const phoneNumber = formatPhone(this.phone.value);
const phoneNumber = formatPhone(controls.phone.value);
if (phoneNumber) {
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
humanReq.setPhone(new AddHumanUserRequest.Phone().setPhone(phoneNumber.phone));
}
}
this.mgmtService
.addHumanUser(humanReq)
.then((data) => {
this.loading = false;
this.toast.showInfo('USER.TOAST.CREATED', true);
this.router.navigate(['users', data.userId], { queryParams: { new: true } });
})
.catch((error) => {
this.loading = false;
this.toast.showError(error);
try {
const data = await this.mgmtService.addHumanUser(humanReq);
this.toast.showInfo('USER.TOAST.CREATED', true);
this.router.navigate(['users', data.userId], { queryParams: { new: true } }).then();
} catch (error) {
this.toast.showError(error);
} finally {
this.loading = false;
}
}
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 {
let value = (this.phone?.value as string) || '';
let value = this.userForm.controls.phone.value;
this.countryPhoneCodes.forEach((code) => (value = value.replace(`+${code.countryCallingCode}`, '')));
value = value.trim();
this.phone?.setValue('+' + this.selected?.countryCallingCode + ' ' + value);
}
ngOnInit(): void {
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;
}
this.userForm.controls.phone.setValue('+' + this.selected?.countryCallingCode + ' ' + value);
}
public compareCountries(i1: CountryPhoneCode, i2: CountryPhoneCode) {

View File

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

View File

@@ -1,45 +1,73 @@
import { MediaMatcher } from '@angular/cdk/layout';
import { Location } from '@angular/common';
import { Component, EventEmitter, OnDestroy } from '@angular/core';
import { Component, DestroyRef, EventEmitter, OnInit } from '@angular/core';
import { Validators } from '@angular/forms';
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 { 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 { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
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 { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource';
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 { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.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 { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogType } from './edit-dialog/edit-dialog.component';
import { LanguagesService } from '../../../../services/languages.service';
import { EditDialogComponent, EditDialogData, EditDialogResult, EditDialogType } from './edit-dialog/edit-dialog.component';
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({
selector: 'cnsl-auth-user-detail',
templateUrl: './auth-user-detail.component.html',
styleUrls: ['./auth-user-detail.component.scss'],
})
export class AuthUserDetailComponent implements OnDestroy {
public user?: User.AsObject;
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;
export class AuthUserDetailComponent implements OnInit {
public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE];
public ChangeType: any = ChangeType;
public userLoginMustBeDomain: boolean = false;
@@ -47,8 +75,7 @@ export class AuthUserDetailComponent implements OnDestroy {
public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER;
public refreshChanges$: EventEmitter<void> = new EventEmitter();
public metadata: Metadata.AsObject[] = [];
public refreshMetadata$ = new Subject<true>();
public settingsList: SidenavSetting[] = [
{ id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' },
@@ -62,170 +89,218 @@ export class AuthUserDetailComponent implements OnDestroy {
requiredRoles: { [PolicyComponentServiceType.MGMT]: ['user.read'] },
},
];
public currentSetting: string | undefined = this.settingsList[0].id;
public loginPolicy?: LoginPolicy.AsObject;
private savedLanguage?: string;
protected readonly user$: Observable<UserQuery>;
protected readonly metadata$: Observable<MetadataQuery>;
private readonly savedLanguage$: Observable<string>;
protected currentSetting$: Observable<string | undefined>;
public loginPolicy$: Observable<LoginPolicy>;
protected userName$: Observable<string>;
constructor(
public translate: TranslateService,
private toast: ToastService,
public userService: GrpcAuthService,
public grpcAuthService: GrpcAuthService,
private dialog: MatDialog,
private auth: AuthenticationService,
private mgmt: ManagementService,
private breadcrumbService: BreadcrumbService,
private mediaMatcher: MediaMatcher,
private _location: Location,
activatedRoute: ActivatedRoute,
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) => {
const { id } = params;
if (id) {
this.cleanupTranslation();
this.currentSetting = id;
}
});
this.currentSetting$ = this.getCurrentSetting$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.userName$ = this.getUserName(this.user$);
this.savedLanguage$ = this.getSavedLanguage$(this.user$);
this.metadata$ = this.getMetadata$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const mediaq: string = '(max-width: 500px)';
const small = this.mediaMatcher.matchMedia(mediaq).matches;
if (small) {
this.changeSelection(small);
}
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;
}
});
this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
catchError(() => EMPTY),
map(({ policy }) => policy),
filter(Boolean),
);
}
private changeSelection(small: boolean): void {
this.cleanupTranslation();
if (small) {
this.currentSetting = undefined;
} else {
this.currentSetting = this.currentSetting === undefined ? this.settingsList[0].id : this.currentSetting;
}
}
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'],
}),
]);
getUserName(user$: Observable<UserQuery>) {
return user$.pipe(
map((query) => {
const user = this.user(query);
if (!user) {
return '';
}
this.savedLanguage = resp.user?.human?.profile?.preferredLanguage;
this.loading = false;
})
.catch((error) => {
this.toast.showError(error);
this.loading = false;
});
if (user.type.case === 'human') {
return user.type.value.profile?.displayName ?? '';
}
if (user.type.case === 'machine') {
return user.type.value.name;
}
return '';
}),
);
}
public ngOnDestroy(): void {
this.cleanupTranslation();
this.subscription.unsubscribe();
getSavedLanguage$(user$: Observable<UserQuery>) {
return user$.pipe(
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 {
this.cleanupTranslation();
ngOnInit(): void {
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 {
if (this?.savedLanguage) {
this.translate.use(this?.savedLanguage);
} else {
this.translate.use(this.translate.defaultLang);
}
private getCurrentSetting$(): Observable<string | undefined> {
const mediaq: string = '(max-width: 500px)';
const matcher = this.mediaMatcher.matchMedia(mediaq);
const small$ = fromEvent(matcher, 'change', ({ matches }: MediaQueryListEvent) => matches).pipe(
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 {
const dialogRef = this.dialog.open(EditDialogComponent, {
data: {
confirmKey: 'ACTIONS.CHANGE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE',
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC',
value: this.user?.userName,
},
private getUser$(): Observable<UserQuery> {
return this.refreshChanges$.pipe(
startWith(true),
switchMap(() => this.getMyUser()),
pairwiseStartWith(undefined),
map(([prev, curr]) => {
if (prev?.state === 'success' && curr.state === 'loading') {
return { state: 'loading', value: prev.value } as const;
}
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',
});
dialogRef.afterClosed().subscribe((resp: { value: string }) => {
if (resp && resp.value && resp.value !== this.user?.userName) {
this.userService
.updateMyUserName(resp.value)
.then(() => {
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
this.refreshUser();
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
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;
dialogRef
.afterClosed()
.pipe(
map((value) => value?.value),
filter(Boolean),
filter((value) => user.userName != value),
switchMap((username) => this.userService.updateUser({ userId: user.id, username })),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
},
error: (error) => {
this.toast.showError(error);
});
}
},
});
}
public saveEmail(email: string): void {
public saveProfile(user: User, profile: HumanProfile): void {
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(() => {
this.toast.showInfo('USER.TOAST.EMAILSAVED', true);
if (this.user?.human) {
const mailToSet = new Email();
mailToSet.setEmail(email);
this.user.human.email = mailToSet.toObject();
this.refreshUser();
}
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
@@ -233,11 +308,11 @@ export class AuthUserDetailComponent implements OnDestroy {
}
public enteredPhoneCode(code: string): void {
this.userService
this.newAuthService
.verifyMyPhone(code)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONESAVED', true);
this.refreshUser();
this.refreshChanges$.emit();
this.promptSetupforSMSOTP();
})
.catch((error) => {
@@ -256,39 +331,26 @@ export class AuthUserDetailComponent implements OnDestroy {
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.userService.addMyAuthFactorOTPSMS().then(() => {
this.translate
.get('USER.MFA.OTPSMSSUCCESS')
.pipe(take(1))
.subscribe((msg) => {
this.toast.showInfo(msg);
});
});
}
});
dialogRef
.afterClosed()
.pipe(
filter(Boolean),
switchMap(() => this.newAuthService.addMyAuthFactorOTPSMS()),
switchMap(() => this.translate.get('USER.MFA.OTPSMSSUCCESS').pipe(take(1))),
)
.subscribe({
next: (msg) => this.toast.showInfo(msg),
error: (err) => this.toast.showError(err),
});
}
public changedLanguage(language: string): void {
this.translate.use(language);
}
public resendPhoneVerification(): void {
this.userService
.resendMyPhoneVerification()
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public resendEmailVerification(): void {
this.userService
.resendMyEmailVerification()
public resendEmailVerification(user: User): void {
this.newMgmtService
.resendHumanEmailVerification(user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit();
@@ -298,161 +360,187 @@ export class AuthUserDetailComponent implements OnDestroy {
});
}
public deletePhone(): void {
this.userService
.removeMyPhone()
public resendPhoneVerification(user: User): void {
this.newMgmtService
.resendHumanPhoneVerification(user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
if (this.user?.human?.phone) {
const phone = new Phone();
this.user.human.phone = phone.toObject();
this.refreshUser();
}
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public savePhone(phone: string): void {
if (this.user?.human) {
// Format phone before save (add +)
const formattedPhone = formatPhone(phone);
if (formattedPhone) {
phone = formattedPhone.phone;
}
this.userService
.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 deletePhone(user: User): void {
this.userService
.removePhone(user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public openEditDialog(type: EditDialogType): void {
public openEditDialog(user: UserWithHumanType, type: EditDialogType): void {
switch (type) {
case EditDialogType.PHONE:
const dialogRefPhone = this.dialog.open(EditDialogComponent, {
data: {
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;
this.openEditPhoneDialog(user);
return;
case EditDialogType.EMAIL:
const dialogRefEmail = this.dialog.open(EditDialogComponent, {
data: {
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;
this.openEditEmailDialog(user);
return;
}
}
public deleteAccount(): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'USER.DIALOG.DELETE_BTN',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.DIALOG.DELETE_TITLE',
descriptionKey: 'USER.DIALOG.DELETE_AUTH_DESCRIPTION',
},
private openEditEmailDialog(user: UserWithHumanType) {
const data: EditDialogData = {
confirmKey: 'ACTIONS.SAVE',
cancelKey: 'ACTIONS.CANCEL',
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.LOGINMETHODS.EMAIL.EDITTITLE',
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',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.userService
.RemoveMyUser()
.then(() => {
this.toast.showInfo('USER.PAGES.DELETEACCOUNT_SUCCESS', true);
this.auth.signout();
})
.catch((error) => {
this.toast.showError(error);
});
}
dialogRefEmail
.afterClosed()
.pipe(
filter((resp): resp is Required<EditDialogResult> => !!resp?.value),
switchMap(({ value, isVerified }) =>
this.userService.setEmail({
userId: user.id,
email: value,
verification: isVerified ? { case: 'isVerified', value: isVerified } : { case: undefined },
}),
),
)
.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 {
if (this.user) {
this.userService.isAllowed(['user.read']).subscribe((allowed) => {
if (allowed) {
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();
});
public humanUser(userQuery: UserQuery): UserWithHumanType | undefined {
const user = this.user(userQuery);
if (user?.type.case === 'human') {
return { ...user, type: user.type };
}
return;
}
}

View File

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

View File

@@ -1,5 +1,5 @@
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 { debounceTime } from 'rxjs';
import { requiredValidator } from 'src/app/modules/form-field/validators/validators';
@@ -11,6 +11,24 @@ export enum EditDialogType {
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({
selector: 'cnsl-edit-dialog',
templateUrl: './edit-dialog.component.html',
@@ -31,7 +49,7 @@ export class EditDialogComponent implements OnInit {
public countryPhoneCodes: CountryPhoneCode[] = [];
constructor(
public dialogRef: MatDialogRef<EditDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
@Inject(MAT_DIALOG_DATA) public data: EditDialogData,
private countryCallingCodesService: CountryCallingCodesService,
) {
if (data.type === EditDialogType.PHONE) {

View File

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

View File

@@ -1,6 +1,12 @@
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
export type ResendEmailDialogData = {
email: string | '';
};
export type ResendEmailDialogResult = { send: true; email: string } | { send: false };
@Component({
selector: 'cnsl-resend-email-dialog',
templateUrl: './resend-email-dialog.component.html',
@@ -9,16 +15,16 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
export class ResendEmailDialogComponent {
public email: string = '';
constructor(
public dialogRef: MatDialogRef<ResendEmailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<ResendEmailDialogComponent, ResendEmailDialogResult>,
@Inject(MAT_DIALOG_DATA) public data: ResendEmailDialogData,
) {
if (data.email) {
this.email = data.email;
}
}
closeDialog(email: string = ''): void {
this.dialogRef.close(email);
closeDialog(): void {
this.dialogRef.close({ send: false });
}
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="left">
<span class="label cnsl-secondary-text">{{ 'USER.EMAIL' | translate }}</span>
<span class="name">{{ human.email?.email }}</span>
<span *ngIf="human.email?.isEmailVerified" class="contact-state verified">{{ 'USER.EMAILVERIFIED' | translate }}</span>
<div *ngIf="!human.email?.isEmailVerified" class="block">
<span *ngIf="isEmailVerified" class="contact-state verified">{{ 'USER.EMAILVERIFIED' | translate }}</span>
<div *ngIf="!isEmailVerified" class="block">
<span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span>
<ng-container *ngIf="human.email">
@@ -37,8 +37,8 @@
<div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PHONE' | translate }}</span>
<cnsl-phone-detail [phone]="human.phone?.phone"></cnsl-phone-detail>
<span *ngIf="human.phone?.isPhoneVerified" class="contact-state verified">{{ 'USER.PHONEVERIFIED' | translate }}</span>
<div *ngIf="human.phone?.phone && !human.phone?.isPhoneVerified" class="block">
<span *ngIf="isPhoneVerified" class="contact-state verified">{{ 'USER.PHONEVERIFIED' | translate }}</span>
<div *ngIf="human.phone?.phone && !isPhoneVerified" class="block">
<span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span>
<ng-container *ngIf="human.phone?.phone">

View File

@@ -1,11 +1,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
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 { CodeDialogComponent } from '../auth-user-detail/code-dialog/code-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({
selector: 'cnsl-contact',
@@ -15,15 +16,14 @@ import { EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.comp
export class ContactComponent {
@Input() disablePhoneCode: boolean = false;
@Input() canWrite: boolean | null = false;
@Input() human?: Human.AsObject;
@Input({ required: true }) human!: HumanUser | Human;
@Input() username: string = '';
@Input() state!: UserState;
@Output() editType: EventEmitter<EditDialogType> = new EventEmitter<EditDialogType>();
@Output() resendEmailVerification: EventEmitter<void> = new EventEmitter<void>();
@Output() resendPhoneVerification: EventEmitter<void> = new EventEmitter<void>();
@Output() enteredPhoneCode: EventEmitter<string> = new EventEmitter<string>();
@Output() deletedPhone: EventEmitter<void> = new EventEmitter<void>();
public UserState: any = UserState;
public UserState = UserState;
public EditDialogType: any = EditDialogType;
constructor(
@@ -81,4 +81,18 @@ export class ContactComponent {
public openEditDialog(type: EditDialogType): void {
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">
<cnsl-form-field class="formfield">
<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 { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';
import { Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { combineLatestWith, distinctUntilChanged, ReplaySubject } from 'rxjs';
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({
selector: 'cnsl-detail-form-machine',
templateUrl: './detail-form-machine.component.html',
styleUrls: ['./detail-form-machine.component.scss'],
})
export class DetailFormMachineComponent implements OnInit, OnDestroy {
@Input() public username!: string;
@Input() public user!: Human.AsObject | Machine.AsObject;
@Input() public disabled: boolean = false;
@Output() public submitData: EventEmitter<any> = new EventEmitter<any>();
export class DetailFormMachineComponent {
@Input({ required: true }) public set username(username: string) {
this.username$.next(username);
}
@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[] = [
AccessTokenType.ACCESS_TOKEN_TYPE_BEARER,
AccessTokenType.ACCESS_TOKEN_TYPE_JWT,
];
public machineForm: ReturnType<typeof this.buildForm>;
private sub: Subscription = new Subscription();
@Output() public submitData = new EventEmitter<ReturnType<(typeof this.machineForm)['getRawValue']>>();
constructor(private fb: UntypedFormBuilder) {
this.machineForm = this.fb.group({
userName: [{ value: '', disabled: true }, [requiredValidator]],
name: [{ value: '', disabled: this.disabled }, requiredValidator],
description: [{ value: '', disabled: this.disabled }],
accessTokenType: [AccessTokenType.ACCESS_TOKEN_TYPE_BEARER, [requiredValidator]],
public accessTokenTypes: AccessTokenType[] = [AccessTokenType.BEARER, AccessTokenType.JWT];
constructor(
private readonly fb: FormBuilder,
private readonly destroyRef: DestroyRef,
) {
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 {
this.machineForm.patchValue({ ...this.user, userName: this.username });
}
public ngOnDestroy(): void {
this.sub.unsubscribe();
public toggleFormControl<T>(control: FormControl<T>, disabled: boolean) {
if (disabled) {
control.disable();
return;
}
control.enable();
}
public submitForm(): void {
this.submitData.emit(this.machineForm.value);
}
public get name(): AbstractControl | null {
return this.machineForm.get('name');
}
public get userName(): AbstractControl | null {
return this.machineForm.get('userName');
this.submitData.emit(this.machineForm.getRawValue());
}
}

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-form-content">
<button
[disabled]="user && disabled"
[disabled]="disabled$ | async"
class="camera-wrapper"
type="button"
(click)="showEditImage ? openUploadDialog() : null"
(click)="showEditImage ? openUploadDialog(profile) : null"
>
<div class="i-wrapper" *ngIf="showEditImage">
<i class="las la-camera"></i>
</div>
<cnsl-avatar
*ngIf="user && user.profile"
*ngIf="profile.displayName"
class="avatar"
[name]="user.profile.displayName"
[avatarUrl]="user.profile.avatarUrl || ''"
[name]="profile.displayName"
[avatarUrl]="profile.avatarUrl || ''"
[forColor]="preferredLoginName"
[size]="80"
>
@@ -24,9 +24,9 @@
<div class="usernamediv">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="userName" />
<input cnslInput formControlName="username" />
</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 }}
</button>
</div>
@@ -34,11 +34,11 @@
<div class="user-grid">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.FIRSTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="firstName" />
<input cnslInput formControlName="givenName" />
</cnsl-form-field>
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.LASTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="lastName" />
<input cnslInput formControlName="familyName" />
</cnsl-form-field>
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'USER.PROFILE.NICKNAME' | translate }}</cnsl-label>
@@ -67,7 +67,7 @@
</div>
</div>
<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 }}
</button>
</div>

View File

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

View File

@@ -3,14 +3,14 @@ import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table';
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 { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { IDPUserLink } from 'src/app/proto/generated/zitadel/idp_pb';
import { GrpcAuthService } from '../../../../services/grpc-auth.service';
import { ManagementService } from '../../../../services/mgmt.service';
import { ToastService } from '../../../../services/toast.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';
@Component({
selector: 'cnsl-external-idps',
@@ -18,7 +18,7 @@ import { ToastService } from '../../../../services/toast.service';
styleUrls: ['./external-idps.component.scss'],
})
export class ExternalIdpsComponent implements OnInit, OnDestroy {
@Input() service!: GrpcAuthService | ManagementService;
@Input({ required: true }) service!: GrpcAuthService | ManagementService;
@Input() userId!: string;
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
public totalResult: number = 0;
@@ -41,7 +41,7 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
) {}
ngOnInit(): void {
this.getData(10, 0);
this.getData(10, 0).then();
}
ngOnDestroy(): void {
@@ -65,39 +65,37 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
private async getData(limit: number, offset: number): Promise<void> {
this.loadingSubject.next(true);
let promise;
if (this.service instanceof ManagementService) {
promise = (this.service as ManagementService).listHumanLinkedIDPs(this.userId, limit, offset);
} else if (this.service instanceof GrpcAuthService) {
promise = (this.service as GrpcAuthService).listMyLinkedIDPs(limit, offset);
const promise =
this.service instanceof ManagementService
? (this.service as ManagementService).listHumanLinkedIDPs(this.userId, limit, offset)
: (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) {
promise
.then((resp) => {
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);
});
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);
}
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, {
data: {
confirmKey: 'ACTIONS.REMOVE',
@@ -108,27 +106,23 @@ export class ExternalIdpsComponent implements OnInit, OnDestroy {
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
let promise;
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);
}
const resp = await firstValueFrom(dialogRef.afterClosed());
if (!resp) {
return;
}
if (promise) {
promise
.then((_) => {
setTimeout(() => {
this.refreshPage();
}, 1000);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
}
});
const promise =
this.service instanceof ManagementService
? (this.service as ManagementService).removeHumanLinkedIDP(idp.idpId, idp.providedUserId, idp.userId)
: (this.service as GrpcAuthService).removeMyLinkedIDP(idp.idpId, idp.providedUserId);
try {
await promise;
setTimeout(() => {
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>
<ng-container *ngIf="userId; else authUser">
<form *ngIf="passwordForm" [formGroup]="passwordForm" (ngSubmit)="setInitialPassword(userId)">
<ng-container *ngIf="id$ | async as id">
<form [formGroup]="form" (ngSubmit)="setInitialPassword(id, form)">
<input
*ngIf="username"
*ngIf="username$ | async as username"
class="hiddeninput"
type="hidden"
autocomplete="username"
@@ -24,21 +24,22 @@
</cnsl-form-field>
</div>
<div class="validation" *ngIf="this.policy">
<cnsl-password-complexity-view [policy]="this.policy" [password]="password"> </cnsl-password-complexity-view>
<div class="validation" *ngIf="passwordPolicy$ | async as passwordPolicy">
<cnsl-password-complexity-view [policy]="passwordPolicy" [password]="password(form)">
</cnsl-password-complexity-view>
</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 }}
</button>
</form>
</ng-container>
<ng-template #authUser>
<form *ngIf="passwordForm" [formGroup]="passwordForm" (ngSubmit)="setPassword()">
<ng-container *ngIf="(id$ | async) === undefined && (user$ | async) as user">
<form [formGroup]="form" (ngSubmit)="setPassword(form, user)">
<input
*ngIf="username"
*ngIf="username$ | async as username"
class="hiddeninput"
type="hidden"
autocomplete="username"
@@ -53,8 +54,9 @@
<input cnslInput autocomplete="off" name="password" type="password" formControlName="currentPassword" />
</cnsl-form-field>
<div class="validation between" *ngIf="this.policy">
<cnsl-password-complexity-view [policy]="this.policy" [password]="newPassword"> </cnsl-password-complexity-view>
<div class="validation between" *ngIf="passwordPolicy$ | async as passwordPolicy">
<cnsl-password-complexity-view [policy]="passwordPolicy" [password]="newPassword(form)">
</cnsl-password-complexity-view>
</div>
<div class="side-by-side">
@@ -74,9 +76,9 @@
</cnsl-form-field>
</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 }}
</button>
</form>
</ng-template>
</ng-container>
</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 { 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 {
containsLowerCaseValidator,
containsNumberValidator,
@@ -14,161 +25,200 @@ import {
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.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 { 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({
selector: 'cnsl-password',
templateUrl: './password.component.html',
styleUrls: ['./password.component.scss'],
})
export class PasswordComponent implements OnDestroy {
userId: string = '';
public username: string = '';
public policy!: PasswordComplexityPolicy.AsObject;
public passwordForm!: UntypedFormGroup;
private formSub: Subscription = new Subscription();
private destroy$: Subject<void> = new Subject();
export class PasswordComponent implements OnInit {
private readonly breadcrumb$: Observable<Breadcrumb[]>;
protected readonly username$: Observable<string>;
protected readonly id$: Observable<string | undefined>;
protected readonly form$: Observable<UntypedFormGroup>;
protected readonly passwordPolicy$: Observable<PasswordComplexityPolicy.AsObject | undefined>;
protected readonly user$: Observable<User.AsObject>;
constructor(
activatedRoute: ActivatedRoute,
private fb: UntypedFormBuilder,
private authService: GrpcAuthService,
private mgmtUserService: ManagementService,
private toast: ToastService,
private breadcrumbService: BreadcrumbService,
private readonly fb: UntypedFormBuilder,
private readonly authService: GrpcAuthService,
private readonly userService: UserService,
private readonly toast: ToastService,
private readonly breadcrumbService: BreadcrumbService,
private readonly destroyRef: DestroyRef,
) {
activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((data) => {
const { username } = data;
this.username = username;
});
activatedRoute.params.pipe(takeUntil(this.destroy$)).subscribe((data) => {
const { id } = data;
if (id) {
this.userId = id;
breadcrumbService.setBreadcrumb([
const usernameParam$ = activatedRoute.queryParamMap.pipe(
map((params) => params.get('username')),
filter(Boolean),
);
this.id$ = activatedRoute.paramMap.pipe(map((params) => params.get('id') ?? undefined));
this.user$ = this.authService.user.pipe(take(1), filter(Boolean));
this.username$ = usernameParam$.pipe(mergeWith(this.user$.pipe(map((user) => user.preferredLoginName))));
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({
type: BreadcrumbType.ORG,
routerLink: ['/org'],
type: BreadcrumbType.AUTHUSER,
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];
this.authService
.getMyPasswordComplexityPolicy()
.then((resp) => {
if (resp.policy) {
this.policy = resp.policy;
}
if (this.policy.minLength) {
validators.push(minLengthValidator(this.policy.minLength));
}
if (this.policy.hasLowercase) {
validators.push(containsLowerCaseValidator);
}
if (this.policy.hasUppercase) {
validators.push(containsUpperCaseValidator);
}
if (this.policy.hasNumber) {
validators.push(containsNumberValidator);
}
if (this.policy.hasSymbol) {
validators.push(containsSymbolValidator);
}
private getValidators$(
passwordPolicy$: Observable<PasswordComplexityPolicy.AsObject | undefined>,
): Observable<Validators[]> {
return passwordPolicy$.pipe(
map((policy) => {
const validators: Validators[] = [requiredValidator];
if (!policy) {
return validators;
}
if (policy.minLength) {
validators.push(minLengthValidator(policy.minLength));
}
if (policy.hasLowercase) {
validators.push(containsLowerCaseValidator);
}
if (policy.hasUppercase) {
validators.push(containsUpperCaseValidator);
}
if (policy.hasNumber) {
validators.push(containsNumberValidator);
}
if (policy.hasSymbol) {
validators.push(containsSymbolValidator);
}
return validators;
}),
);
}
this.setupForm(validators);
})
.catch((error) => {
this.setupForm(validators);
});
private getForm$(
id$: Observable<string | undefined>,
validators$: Observable<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 {
this.destroy$.next();
this.destroy$.complete();
this.formSub.unsubscribe();
}
public async setInitialPassword(userId: string, form: UntypedFormGroup): Promise<void> {
const password = this.password(form)?.value;
setupForm(validators: Validators[]): void {
if (this.userId) {
this.passwordForm = this.fb.group({
password: ['', validators],
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]],
if (form.invalid || !password) {
return;
}
try {
await this.userService.setPassword({
userId,
newPassword: {
password,
changeRequired: false,
},
});
} else {
this.passwordForm = this.fb.group({
currentPassword: ['', requiredValidator],
newPassword: ['', validators],
confirmPassword: ['', [requiredValidator, passwordConfirmValidator()]],
} catch (error) {
this.toast.showError(error);
return;
}
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 {
if (this.passwordForm.valid && this.password && this.password.value) {
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 password(form: UntypedFormGroup): AbstractControl | null {
return form.get('password');
}
public setPassword(): void {
if (
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 newPassword(form: UntypedFormGroup): AbstractControl | null {
return form.get('newPassword');
}
public get password(): AbstractControl | null {
return this.passwordForm.get('password');
}
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');
public currentPassword(form: UntypedFormGroup): AbstractControl | null {
return form.get('currentPassword');
}
}

View File

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

View File

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

View File

@@ -1,262 +1,277 @@
<cnsl-top-view
*ngIf="user"
title="{{ user.human ? user.human.profile?.displayName : user.machine?.name }}"
docLink="https://zitadel.com/docs/guides/manage/console/users"
sub="{{ user.preferredLoginName }}"
[isActive]="user.state === UserState.USER_STATE_ACTIVE"
[isInactive]="user.state === UserState.USER_STATE_INACTIVE"
stateTooltip="{{ 'USER.STATE.' + user.state | translate }}"
(backClicked)="navigateBack()"
[hasActions]="['user.write$', 'user.write:' + user.id] | hasRole | async"
>
<ng-template topActions cnslHasRole [hasRole]="['user.write$', 'user.write:' + user.id]">
<button mat-menu-item color="warn" *ngIf="user?.machine" (click)="generateMachineSecret()">
{{ 'USER.PAGES.GENERATESECRET' | translate }}
</button>
<button mat-menu-item color="warn" *ngIf="user?.machine?.hasSecret" (click)="removeMachineSecret()">
{{ 'USER.PAGES.REMOVESECRET' | translate }}
</button>
<button mat-menu-item color="warn" *ngIf="user?.state === UserState.USER_STATE_LOCKED" (click)="unlockUser()">
{{ 'USER.PAGES.UNLOCK' | translate }}
</button>
<button
mat-menu-item
*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>
<ng-container *ngIf="user$ | async as userQuery">
<cnsl-top-view
*ngIf="(userQuery.state === 'success' || userQuery.state === 'loading') && userQuery.value as user"
title="{{ user.type.case === 'human' ? user.type.value.profile?.displayName : user.type.value?.name }}"
docLink="https://zitadel.com/docs/guides/manage/console/users"
sub="{{ user.preferredLoginName }}"
[isActive]="user.state === UserState.ACTIVE"
[isInactive]="user.state === UserState.INACTIVE"
stateTooltip="{{ 'USER.STATE.' + user.state | translate }}"
(backClicked)="navigateBack()"
[hasActions]="['user.write$', 'user.write:' + user.userId] | hasRole | async"
>
<ng-template topActions cnslHasRole [hasRole]="['user.write$', 'user.write:' + user.userId]">
<ng-container *ngIf="user.type.case === 'machine'">
<button mat-menu-item color="warn" (click)="generateMachineSecret(user)">
{{ 'USER.PAGES.GENERATESECRET' | translate }}
</button>
<button mat-menu-item color="warn" *ngIf="user.type.value.hasSecret" (click)="removeMachineSecret(user)">
{{ 'USER.PAGES.REMOVESECRET' | translate }}
</button>
</ng-container>
<button mat-menu-item color="warn" *ngIf="user?.state === UserState.LOCKED" (click)="unlockUser(user)">
{{ 'USER.PAGES.UNLOCK' | translate }}
</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>
<cnsl-info-row topContent *ngIf="user" [user]="user" [loginPolicy]="loginPolicy"></cnsl-info-row>
</cnsl-top-view>
<cnsl-info-row topContent [user]="user" [loginPolicy]="(loginPolicy$ | async) ?? undefined"></cnsl-info-row>
</cnsl-top-view>
<div *ngIf="loading" class="max-width-container">
<div class="sp-wrapper">
<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 *ngIf="userQuery.state === 'loading'" class="max-width-container">
<div class="sp-wrapper">
<mat-progress-spinner diameter="25" color="primary" mode="indeterminate"></mat-progress-spinner>
</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 { 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 { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
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 { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
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 { UserGrantContext } from 'src/app/modules/user-grants/user-grants-datasource';
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 { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component';
import { ResendEmailDialogComponent } from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component';
import {
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 { Observable } from 'rxjs';
import { LanguagesService } from '../../../../services/languages.service';
import { LanguagesService } from 'src/app/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 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 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({
selector: 'cnsl-user-detail',
templateUrl: './user-detail.component.html',
styleUrls: ['./user-detail.component.scss'],
})
export class UserDetailComponent implements OnInit {
public user!: User.AsObject;
public metadata: Metadata.AsObject[] = [];
public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE];
public user$: Observable<UserQuery>;
public genders: Gender[] = [Gender.MALE, Gender.FEMALE, Gender.DIVERSE];
public ChangeType: any = ChangeType;
public loading: boolean = true;
public loadingMetadata: boolean = true;
public UserState: any = UserState;
public UserState = UserState;
public copied: string = '';
public USERGRANTCONTEXT: UserGrantContext = UserGrantContext.USER;
@@ -60,32 +98,27 @@ export class UserDetailComponent implements OnInit {
public refreshChanges$: EventEmitter<void> = new EventEmitter();
public InfoSectionType: any = InfoSectionType;
public error: string = '';
public settingsList: SidenavSetting[] = [GENERAL, GRANTS, MEMBERSHIPS, METADATA];
public currentSetting: string | undefined = 'general';
public loginPolicy?: LoginPolicy.AsObject;
public currentSetting$: Observable<string | undefined>;
public settingsList$: Observable<SidenavSetting[]>;
public metadata$: Observable<MetadataQuery>;
public loginPolicy$: Observable<LoginPolicy>;
public refreshMetadata$ = new Subject<true>();
constructor(
public translate: TranslateService,
private route: ActivatedRoute,
private readonly route: ActivatedRoute,
private toast: ToastService,
public mgmtUserService: ManagementService,
private _location: Location,
private dialog: MatDialog,
private router: Router,
activatedRoute: ActivatedRoute,
private mediaMatcher: MediaMatcher,
public langSvc: LanguagesService,
private readonly userService: UserService,
private readonly newMgmtService: NewMgmtService,
public readonly mgmtService: ManagementService,
breadcrumbService: BreadcrumbService,
private readonly destroyRef: DestroyRef,
) {
activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => {
const { id } = params;
if (id) {
this.currentSetting = id;
}
});
breadcrumbService.setBreadcrumb([
new Breadcrumb({
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 small = this.mediaMatcher.matchMedia(mediaq).matches;
if (small) {
this.changeSelection(small);
}
this.mediaMatcher.matchMedia(mediaq).onchange = (small) => {
this.changeSelection(small.matches);
};
const matcher = this.mediaMatcher.matchMedia(mediaq);
const small$ = fromEvent(matcher, 'change', ({ matches }: MediaQueryListEvent) => matches).pipe(
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)),
);
}
private changeSelection(small: boolean): void {
if (small) {
this.currentSetting = undefined;
} else {
this.currentSetting = this.currentSetting === undefined ? 'general' : this.currentSetting;
}
}
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 async goToSetting(setting: string) {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: { id: setting },
queryParamsHandling: 'merge',
skipLocationChange: true,
});
}
public ngOnInit(): void {
this.refreshUser();
private getUserById(userId: string): Observable<UserQuery> {
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) => {
if (policy.policy) {
this.loginPolicy = policy.policy;
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.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, {
data: {
confirmKey: 'ACTIONS.CHANGE',
@@ -157,43 +254,45 @@ export class UserDetailComponent implements OnInit {
labelKey: 'ACTIONS.NEWVALUE',
titleKey: 'USER.PROFILE.CHANGEUSERNAME_TITLE',
descriptionKey: 'USER.PROFILE.CHANGEUSERNAME_DESC',
value: this.user.userName,
value: user.username,
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp: { value: string }) => {
if (resp.value && resp.value !== this.user.userName) {
this.mgmtUserService
.updateUserName(this.user.id, resp.value)
.then(() => {
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
this.refreshUser();
})
.catch((error) => {
this.toast.showError(error);
});
}
});
dialogRef
.afterClosed()
.pipe(
map(({ value }: { value?: string }) => value),
filter(Boolean),
filter((value) => user.username != value),
switchMap((username) => this.userService.updateUser({ userId: user.userId, username })),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true);
this.refreshChanges$.emit();
},
error: (error) => {
this.toast.showError(error);
},
});
}
public unlockUser(): void {
const req = new UnlockUserRequest();
req.setId(this.user.id);
this.mgmtUserService
.unlockUser(req)
public unlockUser(user: UserV2): void {
this.userService
.unlockUser(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.UNLOCKED', true);
this.refreshUser();
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public generateMachineSecret(): void {
this.mgmtUserService
.generateMachineSecret(this.user.id)
public generateMachineSecret(user: UserV2): void {
this.newMgmtService
.generateMachineSecret(user.userId)
.then((resp) => {
this.toast.showInfo('USER.TOAST.SECRETGENERATED', true);
this.dialog.open(MachineSecretDialogComponent, {
@@ -203,64 +302,41 @@ export class UserDetailComponent implements OnInit {
},
width: '400px',
});
this.refreshUser();
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public removeMachineSecret(): void {
this.mgmtUserService
.removeMachineSecret(this.user.id)
.then((resp) => {
public removeMachineSecret(user: UserV2): void {
this.newMgmtService
.removeMachineSecret(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.SECRETREMOVED', true);
this.refreshUser();
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public changeState(newState: UserState): void {
if (newState === UserState.USER_STATE_ACTIVE) {
this.mgmtUserService
.reactivateUser(this.user.id)
public changeState(user: UserV2, newState: UserState): void {
if (newState === UserState.ACTIVE) {
this.userService
.reactivateUser(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.REACTIVATED', true);
this.user.state = newState;
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
} else if (newState === UserState.USER_STATE_INACTIVE) {
this.mgmtUserService
.deactivateUser(this.user.id)
} else if (newState === UserState.INACTIVE) {
this.userService
.deactivateUser(user.userId)
.then(() => {
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();
})
.catch((error) => {
@@ -269,32 +345,48 @@ export class UserDetailComponent implements OnInit {
}
}
public saveMachine(machineData: Machine.AsObject): void {
if (this.user.machine) {
this.user.machine.name = machineData.name;
this.user.machine.description = machineData.description;
this.user.machine.accessTokenType = machineData.accessTokenType;
this.mgmtUserService
.updateMachine(
this.user.id,
this.user.machine.name,
this.user.machine.description,
this.user.machine.accessTokenType,
)
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public saveProfile(user: UserV2, profile: HumanProfile): void {
this.userService
.updateUser({
userId: user.userId,
profile: {
givenName: profile.givenName,
familyName: profile.familyName,
nickName: profile.nickName,
displayName: profile.displayName,
preferredLanguage: profile.preferredLanguage,
gender: profile.gender,
},
})
.then(() => {
this.toast.showInfo('USER.TOAST.SAVED', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
public resendEmailVerification(): void {
this.mgmtUserService
.resendHumanEmailVerification(this.user.id)
public saveMachine(user: UserV2, form: ObservedValueOf<DetailFormMachineComponent['submitData']>): void {
this.newMgmtService
.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(() => {
this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true);
this.refreshChanges$.emit();
@@ -304,9 +396,9 @@ export class UserDetailComponent implements OnInit {
});
}
public resendPhoneVerification(): void {
this.mgmtUserService
.resendHumanPhoneVerification(this.user.id)
public resendPhoneVerification(user: UserV2): void {
this.newMgmtService
.resendHumanPhoneVerification(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true);
this.refreshChanges$.emit();
@@ -316,79 +408,25 @@ export class UserDetailComponent implements OnInit {
});
}
public deletePhone(): void {
this.mgmtUserService
.removeHumanPhone(this.user.id)
public deletePhone(user: UserV2): void {
this.userService
.removePhone(user.userId)
.then(() => {
this.toast.showInfo('USER.TOAST.PHONEREMOVED', true);
if (this.user.human) {
this.user.human.phone = new Phone().setPhone('').toObject();
this.refreshUser();
}
this.refreshChanges$.emit();
})
.catch((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 {
this._location.back();
}
public sendSetPasswordNotification(): void {
this.mgmtUserService
.sendHumanResetPasswordNotification(this.user.id, SendHumanResetPasswordNotificationRequest.Type.TYPE_EMAIL)
public sendSetPasswordNotification(user: UserV2): void {
this.newMgmtService
.sendHumanResetPasswordNotification(user.userId, SendHumanResetPasswordNotificationRequest_Type.EMAIL)
.then(() => {
this.toast.showInfo('USER.TOAST.PASSWORDNOTIFICATIONSENT', true);
this.refreshChanges$.emit();
@@ -398,143 +436,189 @@ export class UserDetailComponent implements OnInit {
});
}
public deleteUser(): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.DIALOG.DELETE_TITLE',
descriptionKey: 'USER.DIALOG.DELETE_DESCRIPTION',
},
public deleteUser(user: UserV2): void {
const data = {
confirmKey: 'ACTIONS.DELETE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'USER.DIALOG.DELETE_TITLE',
descriptionKey: 'USER.DIALOG.DELETE_DESCRIPTION',
};
const dialogRef = this.dialog.open<WarnDialogComponent, typeof data, boolean>(WarnDialogComponent, {
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtUserService
.removeUser(this.user.id)
.then(() => {
const params: Params = {
deferredReload: true,
type: this.user.human ? 'humans' : 'machines',
};
this.router.navigate(['/users'], { queryParams: params });
this.toast.showInfo('USER.TOAST.DELETED', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
});
dialogRef
.afterClosed()
.pipe(
filter(Boolean),
switchMap(() => this.userService.deleteUser(user.userId)),
)
.subscribe({
next: () => {
const params: Params = {
deferredReload: true,
type: user.type.case === 'human' ? 'humans' : 'machines',
};
this.router.navigate(['/users'], { queryParams: params }).then();
this.toast.showInfo('USER.TOAST.DELETED', true);
},
error: (error) => this.toast.showError(error),
});
}
public resendInitEmail(): void {
const dialogRef = this.dialog.open(ResendEmailDialogComponent, {
width: '400px',
data: {
email: this.user.human?.email?.email ?? '',
public resendInitEmail(user: UserV2): void {
const dialogRef = this.dialog.open<ResendEmailDialogComponent, ResendEmailDialogData, ResendEmailDialogResult>(
ResendEmailDialogComponent,
{
width: '400px',
data: {
email: user.type.case === 'human' ? (user.type.value.email?.email ?? '') : '',
},
},
});
);
dialogRef.afterClosed().subscribe((resp) => {
if (resp.send && this.user.id) {
this.mgmtUserService
.resendHumanInitialization(this.user.id, resp.email ?? '')
.then(() => {
this.toast.showInfo('USER.TOAST.INITEMAILSENT', true);
this.refreshChanges$.emit();
})
.catch((error) => {
this.toast.showError(error);
});
}
});
dialogRef
.afterClosed()
.pipe(
filter((resp): resp is { send: true; email: string } => !!resp?.send && !!user.userId),
switchMap(({ email }) => this.newMgmtService.resendHumanInitialization(user.userId, email)),
)
.subscribe({
next: () => {
this.toast.showInfo('USER.TOAST.INITEMAILSENT', true);
this.refreshChanges$.emit();
},
error: (error) => this.toast.showError(error),
});
}
public openEditDialog(type: EditDialogType): void {
public openEditDialog(user: UserWithHumanType, type: EditDialogType): void {
switch (type) {
case EditDialogType.PHONE:
const dialogRefPhone = this.dialog.open(EditDialogComponent, {
data: {
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;
this.openEditPhoneDialog(user);
return;
case EditDialogType.EMAIL:
const dialogRefEmail = this.dialog.open(EditDialogComponent, {
data: {
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;
this.openEditEmailDialog(user);
return;
}
}
public loadMetadata(id: string): Promise<any> | void {
this.loadingMetadata = true;
return this.mgmtUserService
.listUserMetadata(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('utf-8'),
};
});
})
.catch((error) => {
this.loadingMetadata = false;
this.toast.showError(error);
private openEditEmailDialog(user: UserWithHumanType) {
const data: EditDialogData = {
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: user.type.value?.email?.email,
type: EditDialogType.EMAIL,
} as const;
const dialogRefEmail = this.dialog.open<EditDialogComponent, EditDialogData, EditDialogResult>(EditDialogComponent, {
data,
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 {
if (this.user) {
const setFcn = (key: string, value: string): Promise<any> =>
this.mgmtUserService.setUserMetadata(key, Buffer.from(value).toString('base64'), this.user.id);
const removeFcn = (key: string): Promise<any> => this.mgmtUserService.removeUserMetadata(key, this.user.id);
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' },
);
const dialogRef = this.dialog.open(MetadataDialogComponent, {
data: {
metadata: this.metadata,
setFcn: setFcn,
removeFcn: removeFcn,
dialogRefPhone
.afterClosed()
.pipe(
map((resp) => formatPhone(resp?.value)),
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(() => {
this.loadMetadata(this.user.id);
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 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
card-actions
mat-icon-button
(click)="getMFAs()"
(click)="refresh$.next(true)"
class="icon-button"
matTooltip="{{ 'ACTIONS.REFRESH' | translate }}"
>
@@ -10,26 +14,26 @@
</button>
<cnsl-refresh-table
[hideRefresh]="true"
[loading]="loading$ | async"
(refreshed)="getMFAs()"
[dataSize]="dataSource.data.length"
[loading]="mfaQuery.state === 'loading'"
(refreshed)="refresh$.next(true)"
[dataSize]="mfaQuery.value.data.length"
>
<table class="table" mat-table [dataSource]="dataSource">
<table class="table" mat-table [dataSource]="mfaQuery.value">
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.TABLETYPE' | translate }}</th>
<td mat-cell *matCellDef="let mfa">
<span *ngIf="mfa.otp !== undefined">TOTP (Time-based One-Time Password)</span>
<span *ngIf="mfa.u2f !== undefined">U2F (Universal 2nd Factor)</span>
<span *ngIf="mfa.otpSms !== undefined">One-Time Password SMS</span>
<span *ngIf="mfa.otpEmail !== undefined">One-Time Password Email</span>
<span *ngIf="mfa.type.case === 'otp'">TOTP (Time-based One-Time Password)</span>
<span *ngIf="mfa.type.case === 'u2f'">U2F (Universal 2nd Factor)</span>
<span *ngIf="mfa.type.case === 'otpSms'">One-Time Password SMS</span>
<span *ngIf="mfa.type.case === 'otpEmail'">One-Time Password Email</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MFA.NAME' | translate }}</th>
<td mat-cell *matCellDef="let mfa">
<span *ngIf="mfa?.u2f?.name" class="centered">
{{ mfa.u2f.name }}
<span *ngIf="mfa.type.case === 'u2f'" class="centered">
{{ mfa.type.value.name }}
</span>
</td>
</ng-container>
@@ -40,8 +44,8 @@
<span
class="state"
[ngClass]="{
active: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_READY,
inactive: mfa.state === AuthFactorState.AUTH_FACTOR_STATE_NOT_READY,
active: mfa.state === AuthFactorState.READY,
inactive: mfa.state === AuthFactorState.NOT_READY,
}"
>
{{ 'USER.MFA.STATE.' + mfa.state | translate }}
@@ -58,7 +62,7 @@
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
mat-icon-button
(click)="deleteMFA(mfa)"
(click)="deleteMFA(mfaQuery.user, mfa)"
>
<i class="las la-trash"></i>
</button>
@@ -69,13 +73,13 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</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>
<span>{{ 'USER.MFA.EMPTY' | translate }}</span>
</div>
</cnsl-refresh-table>
<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>
</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 { MatSort } from '@angular/material/sort';
import { MatTable, MatTableDataSource } from '@angular/material/table';
import { BehaviorSubject, Observable } from 'rxjs';
import { MatTableDataSource } from '@angular/material/table';
import { combineLatestWith, defer, EMPTY, Observable, ReplaySubject, Subject, switchMap } from 'rxjs';
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 { 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 {
name: string;
verified: boolean;
}
type MFAQuery =
| { state: 'success'; value: MatTableDataSource<AuthFactor>; user: User }
| { state: 'loading'; value: MatTableDataSource<AuthFactor>; user: User };
@Component({
selector: 'cnsl-user-mfa',
templateUrl: './user-mfa.component.html',
styleUrls: ['./user-mfa.component.scss'],
})
export class UserMfaComponent implements OnInit, OnDestroy {
public displayedColumns: string[] = ['type', 'name', 'state', 'actions'];
@Input() public user!: User.AsObject;
public mfaSubject: BehaviorSubject<AuthFactor.AsObject[]> = new BehaviorSubject<AuthFactor.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
export class UserMfaComponent {
@Input({ required: true }) public set user(user: User) {
this.user$.next(user);
}
@ViewChild(MatTable) public table!: MatTable<AuthFactor.AsObject>;
@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(
private mgmtUserService: ManagementService,
private dialog: MatDialog,
private toast: ToastService,
) {}
public ngOnInit(): void {
this.getMFAs();
private readonly dialog: MatDialog,
private readonly toast: ToastService,
private readonly userService: UserService,
) {
this.mfaQuery$ = this.user$.pipe(
combineLatestWith(this.refresh$.pipe(startWith(true))),
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 {
this.mfaSubject.complete();
this.loadingSubject.complete();
private listAuthenticationFactors(user: User): Observable<MFAQuery> {
return defer(() => this.userService.listAuthenticationFactors({ userId: user.userId })).pipe(
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 {
this.mgmtUserService
.listHumanMultiFactors(this.user.id)
.then((mfas) => {
this.dataSource = new MatTableDataSource(mfas.resultList);
this.dataSource.sort = this.sort;
})
.catch((error) => {
this.error = error.message;
});
private async removeTOTP(user: User) {
await this.userService.removeTOTP(user.userId);
return ['USER.TOAST.OTPREMOVED', 'otp'] as const;
}
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, {
data: {
confirmKey: 'ACTIONS.DELETE',
@@ -70,70 +109,35 @@ export class UserMfaComponent implements OnInit, OnDestroy {
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
if (factor.otp) {
this.mgmtUserService
.removeHumanMultiFactorOTP(this.user.id)
.then(() => {
this.toast.showInfo('USER.TOAST.OTPREMOVED', true);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.otp);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
})
.catch((error) => {
this.toast.showError(error);
});
} else if (factor.u2f) {
this.mgmtUserService
.removeHumanAuthFactorU2F(this.user.id, factor.u2f.id)
.then(() => {
this.toast.showInfo('USER.TOAST.U2FREMOVED', true);
const index = this.dataSource.data.findIndex((mfa) => !!mfa.u2f);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.getMFAs();
})
.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);
});
}
}
});
dialogRef
.afterClosed()
.pipe(
filter(Boolean),
switchMap(() => {
switch (factor.type.case) {
case 'otp':
return this.removeTOTP(user);
case 'u2f':
return this.removeU2F(user, factor.type.value.id);
case 'otpEmail':
return this.removeOTPEmail(user);
case 'otpSms':
return this.removeOTPSMS(user);
default:
throw new Error('Unknown MFA type');
}
}),
)
.subscribe({
next: ([translation, caseId]) => {
this.toast.showInfo(translation, true);
const index = this.dataSource.data.findIndex((mfa) => mfa.type.case === caseId);
if (index > -1) {
this.dataSource.data.splice(index, 1);
}
this.refresh$.next(true);
},
error: (error) => this.toast.showError(error),
});
}
}

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
GetInstanceFeaturesRequest,
GetInstanceFeaturesResponse,
ResetInstanceFeaturesRequest,
SetInstanceFeaturesRequest,
SetInstanceFeaturesResponse,
} from '../proto/generated/zitadel/feature/v2beta/instance_pb';
import {
GetOrganizationFeaturesRequest,
GetOrganizationFeaturesResponse,
} from '../proto/generated/zitadel/feature/v2beta/organization_pb';
import { GetUserFeaturesRequest, GetUserFeaturesResponse } from '../proto/generated/zitadel/feature/v2beta/user_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({
providedIn: 'root',

View File

@@ -1,8 +1,22 @@
import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { OAuthService } from 'angular-oauth2-oidc';
import { BehaviorSubject, forkJoin, from, Observable, of, Subject } from 'rxjs';
import { catchError, distinctUntilChanged, filter, finalize, map, switchMap, timeout, withLatestFrom } from 'rxjs/operators';
import {
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 {
AddMyAuthFactorOTPEmailRequest,
@@ -110,41 +124,17 @@ const ORG_LIMIT = 10;
})
export class GrpcAuthService {
private _activeOrgChanged: Subject<Org.AsObject | undefined> = new Subject();
public user!: Observable<User.AsObject | undefined>;
public userSubject: BehaviorSubject<User.AsObject | undefined> = new BehaviorSubject<User.AsObject | undefined>(undefined);
public user: Observable<User.AsObject | undefined>;
private triggerPermissionsRefresh: Subject<void> = new Subject();
public zitadelPermissions$: Observable<string[]> = this.triggerPermissionsRefresh.pipe(
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 zitadelPermissions: Observable<string[]>;
public labelpolicy$!: Observable<LabelPolicy.AsObject>;
public labelpolicy: BehaviorSubject<LabelPolicy.AsObject | undefined> = new BehaviorSubject<
LabelPolicy.AsObject | undefined
>(undefined);
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<
PrivacyPolicy.AsObject | 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[]>([]);
private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {};
@@ -155,74 +145,63 @@ export class GrpcAuthService {
private oauthService: OAuthService,
private storage: StorageService,
) {
this.zitadelPermissions$.subscribe(this.zitadelPermissions);
this.labelpolicy$ = this.activeOrgChanged.pipe(
switchMap((org) => {
this.labelPolicyLoading$.next(true);
return from(this.getMyLabelPolicy(org ? org.id : ''));
}),
tap(() => this.labelPolicyLoading$.next(true)),
switchMap((org) => this.getMyLabelPolicy(org ? org.id : '')),
tap(() => this.labelPolicyLoading$.next(false)),
finalize(() => this.labelPolicyLoading$.next(false)),
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(
switchMap((org) => {
this.privacyPolicyLoading$.next(true);
return from(this.getMyPrivacyPolicy(org ? org.id : ''));
}),
switchMap((org) => this.getMyPrivacyPolicy(org ? org.id : '')),
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([
of(this.oauthService.getAccessToken()),
defer(() => of(this.oauthService.getAccessToken())),
this.oauthService.events.pipe(
filter((e) => e.type === 'token_received'),
timeout(this.oauthService.waitForTokenInMsec || 0),
catchError((_) => of(null)), // timeout is not an error
timeout(this.oauthService.waitForTokenInMsec ?? 0),
catchError((err) => {
if (err instanceof TimeoutError) {
return of(null);
}
throw err;
}), // timeout is not an error
map((_) => this.oauthService.getAccessToken()),
),
]).pipe(
filter((token) => (token ? true : false)),
distinctUntilChanged(),
switchMap(() => {
return from(
this.getMyUser().then((resp) => {
return resp.user;
}),
);
}),
finalize(() => {
this.loadPermissions();
}),
filter(([_, token]) => !!token),
distinctUntilKeyChanged(1),
switchMap(() => this.getMyUser().then((resp) => resp.user)),
startWith(undefined),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.user.subscribe(this.userSubject);
this.activeOrgChanged.subscribe(() => {
this.loadPermissions();
});
this.zitadelPermissions = this.user.pipe(
combineLatestWith(this.activeOrgChanged),
// ignore errors from observables
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(
@@ -309,7 +288,7 @@ export class GrpcAuthService {
}
public get activeOrgChanged(): Observable<Org.AsObject | undefined> {
return this._activeOrgChanged;
return this._activeOrgChanged.asObservable();
}
public setActiveOrg(org: Org.AsObject): void {
@@ -328,18 +307,15 @@ export class GrpcAuthService {
* @param roles roles of the user
*/
public isAllowed(roles: string[] | RegExp[], requiresAll: boolean = false): Observable<boolean> {
if (roles && roles.length > 0) {
return this.fetchedZitadelPermissions.pipe(
withLatestFrom(this.zitadelPermissions),
filter(([hL, p]) => {
return hL === true && !!p.length;
}),
map(([_, zroles]) => this.hasRoles(zroles, roles, requiresAll)),
distinctUntilChanged(),
);
} else {
if (!roles?.length) {
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[],
requiresAll: boolean = false,
): Observable<T[]> {
return this.fetchedZitadelPermissions.pipe(
withLatestFrom(this.zitadelPermissions),
filter(([hL, p]) => {
return hL === true && !!p.length;
}),
map(([_, zroles]) => {
return objects.filter((obj) => {
return this.zitadelPermissions.pipe(
filter((permissions) => !!permissions.length),
map((permissions) =>
objects.filter((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());
}
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> {
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> {
if (orgIdForCache && 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> {
if (orgIdForCache && 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 { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
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 { AuthServiceClient } from '../proto/generated/zitadel/AuthServiceClientPb';
@@ -12,13 +11,18 @@ import { fallbackLanguage, supportedLanguagesRegexp } from '../utils/language';
import { AuthenticationService } from './authentication.service';
import { EnvironmentService } from './environment.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 { I18nInterceptor } from './interceptors/i18n.interceptor';
import { OrgInterceptor } from './interceptors/org.interceptor';
import { StorageService } from './storage.service';
import { FeatureServiceClient } from '../proto/generated/zitadel/feature/v2beta/Feature_serviceServiceClientPb';
import { GrpcAuthService } from './grpc-auth.service';
import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb';
//@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({
providedIn: 'root',
@@ -28,15 +32,20 @@ export class GrpcService {
public mgmt!: ManagementServiceClient;
public admin!: AdminServiceClient;
public feature!: FeatureServiceClient;
public user!: UserServiceClient;
public userNew!: ReturnType<typeof createUserServiceClient>;
public mgmtNew!: ReturnType<typeof createManagementServiceClient>;
public authNew!: ReturnType<typeof createAuthServiceClient>;
constructor(
private envService: EnvironmentService,
private platformLocation: PlatformLocation,
private authenticationService: AuthenticationService,
private storageService: StorageService,
private dialog: MatDialog,
private translate: TranslateService,
private exhaustedService: ExhaustedService,
private readonly envService: EnvironmentService,
private readonly platformLocation: PlatformLocation,
private readonly authenticationService: AuthenticationService,
private readonly storageService: StorageService,
private readonly translate: TranslateService,
private readonly exhaustedService: ExhaustedService,
private readonly authInterceptor: AuthInterceptor,
private readonly authInterceptorProvider: AuthInterceptorProvider,
) {}
public loadAppEnvironment(): Promise<any> {
@@ -44,66 +53,79 @@ export class GrpcService {
const browserLanguage = this.translate.getBrowserLang();
const language = browserLanguage?.match(supportedLanguagesRegexp) ? browserLanguage : fallbackLanguage;
return this.translate
.use(language || this.translate.defaultLang)
.pipe(
switchMap(() => this.envService.env),
tap((env) => {
if (!env?.api || !env?.issuer) {
return;
}
const interceptors = {
unaryInterceptors: [
new ExhaustedGrpcInterceptor(this.exhaustedService, this.envService),
new OrgInterceptor(this.storageService),
new AuthInterceptor(this.authenticationService, this.storageService, this.dialog),
new I18nInterceptor(this.translate),
],
};
const init = this.translate.use(language || this.translate.defaultLang).pipe(
switchMap(() => this.envService.env),
tap((env) => {
if (!env?.api || !env?.issuer) {
return;
}
const interceptors = {
unaryInterceptors: [
new ExhaustedGrpcInterceptor(this.exhaustedService, this.envService),
new OrgInterceptor(this.storageService),
this.authInterceptor,
new I18nInterceptor(this.translate),
],
};
this.auth = new AuthServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.mgmt = new ManagementServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.admin = new AdminServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.feature = new FeatureServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.auth = new AuthServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.mgmt = new ManagementServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.admin = new AdminServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.feature = new FeatureServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
this.user = new UserServiceClient(
env.api,
null,
// @ts-ignore
interceptors,
);
const authConfig: AuthConfig = {
scope: 'openid profile email',
responseType: 'code',
oidc: true,
clientId: env.clientid,
issuer: env.issuer,
redirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'auth/callback',
postLogoutRedirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'signedout',
requireHttps: false,
};
const transport = createGrpcWebTransport({
baseUrl: env.api,
interceptors: [NewConnectWebAuthInterceptor(this.authInterceptorProvider)],
});
this.userNew = createUserServiceClient(transport);
this.mgmtNew = createManagementServiceClient(transport);
this.authNew = createAuthServiceClient(transport);
this.authenticationService.initConfig(authConfig);
}),
catchError((err) => {
console.error('Failed to load environment from assets', err);
return throwError(() => err);
}),
)
.toPromise();
const authConfig: AuthConfig = {
scope: 'openid profile email',
responseType: 'code',
oidc: true,
clientId: env.clientid,
issuer: env.issuer,
redirectUri: window.location.origin + this.platformLocation.getBaseHrefFromDOM() + 'auth/callback',
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 { Request, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { Subject } from 'rxjs';
import { debounceTime, filter, first, map, take, tap } from 'rxjs/operators';
import { Request, RpcError, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { firstValueFrom, identity, lastValueFrom, Observable, Subject } from 'rxjs';
import { debounceTime, filter, map } from 'rxjs/operators';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { AuthenticationService } from '../authentication.service';
import { StorageService } from '../storage.service';
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 bearerPrefix = 'Bearer';
const accessTokenStorageKey = 'access_token';
@Injectable({ providedIn: 'root' })
/**
* Set the authentication token
*/
export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryInterceptor<TReq, TResp> {
export class AuthInterceptorProvider {
public triggerDialog: Subject<boolean> = new Subject();
constructor(
private authenticationService: AuthenticationService,
private storageService: StorageService,
private dialog: MatDialog,
private destroyRef: DestroyRef,
) {
this.triggerDialog.pipe(debounceTime(1000)).subscribe(() => {
this.openDialog();
});
this.triggerDialog.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => this.openDialog());
}
public async intercept(request: Request<TReq, TResp>, invoker: any): Promise<UnaryResponse<TReq, TResp>> {
await this.authenticationService.authenticationChanged
.pipe(
filter((authed) => !!authed),
first(),
)
.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);
});
getToken(): Observable<string> {
return this.authenticationService.authenticationChanged.pipe(
filter(identity),
map(() => this.storageService.getItem(accessTokenStorageKey)),
map((token) => `${bearerPrefix} ${token}`),
);
}
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, {
data: {
confirmKey: 'ACTIONS.LOGIN',
@@ -64,19 +58,47 @@ export class AuthInterceptor<TReq = unknown, TResp = unknown> implements UnaryIn
width: '400px',
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((resp) => {
if (resp) {
const idToken = this.authenticationService.getIdToken();
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
id_token_hint: idToken,
},
};
this.authenticationService.authenticate(configWithPrompt, true);
}
});
const resp = await lastValueFrom(dialogRef.afterClosed());
if (!resp) {
return;
}
const idToken = this.authenticationService.getIdToken();
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
id_token_hint: idToken,
},
};
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 { Request, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { Request, RpcError, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web';
import { EnvironmentService } from '../environment.service';
import { ExhaustedService } from '../exhausted.service';
import { lastValueFrom } from 'rxjs';
/**
* 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>,
invoker: (request: Request<TReq, TResp>) => Promise<UnaryResponse<TReq, TResp>>,
): Promise<UnaryResponse<TReq, TResp>> {
return invoker(request).catch((error: any) => {
if (error.code === StatusCode.RESOURCE_EXHAUSTED) {
return this.exhaustedSvc
.showExhaustedDialog(this.envSvc.env)
.toPromise()
.then(() => {
throw error;
});
try {
return await invoker(request);
} catch (error: any) {
if (error instanceof RpcError && error.code === StatusCode.RESOURCE_EXHAUSTED) {
await lastValueFrom(this.exhaustedSvc.showExhaustedDialog(this.envSvc.env));
}
throw error;
});
}
}
}

View File

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

View File

@@ -1,5 +1,5 @@
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 { 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> {
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 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}`;
}
return invoker(request)
.then((response: any) => {
return response;
})
.catch((error: any) => {
if (error.code === 7 && error.message.startsWith("Organisation doesn't exist")) {
this.storageService.removeItem(StorageKey.organization, StorageLocation.session);
}
return Promise.reject(error);
});
try {
return await invoker(request);
} catch (error: any) {
if (
error instanceof RpcError &&
error.code === StatusCode.PERMISSION_DENIED &&
error.message.startsWith("Organisation doesn't exist")
) {
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';
export function formatPhone(phone: string): { phone: string; country: CountryCode } | null {
export function formatPhone(phone?: string): { phone: string; country: CountryCode } | null {
const defaultCountry = 'US';
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": "Електронна поща",
"PHONE": "Телефонен номер",
"PHONE_HINT": "Използвайте символа +, последван от кода на държавата, на която се обаждате, или изберете държавата от падащото меню и накрая въведете телефонния номер",
"PHONE_VERIFIED": "Телефонният номер е потвърден",
"SEND_SMS": "Изпрати SMS за потвърждение",
"SEND_EMAIL": "Изпрати имейл",
"USERNAME": "Потребителско име",
"CHANGEUSERNAME": "променям",
"CHANGEUSERNAME_TITLE": "Промяна на потребителското име",
@@ -948,6 +951,14 @@
"5": "Спряно",
"6": "Първоначално"
},
"STATEV2": {
"0": "неизвестен",
"1": "Активен",
"2": "Неактивен",
"3": "Изтрито",
"4": "Заключено",
"5": "Първоначално"
},
"SEARCH": {
"ADDITIONAL": "Име за вход (текуща организация)",
"ADDITIONAL-EXTERNAL": "Име за вход (външна организация)"
@@ -1485,7 +1496,9 @@
"ENABLED": "„Активирано“ се наследява",
"DISABLED": "„Деактивирано“ се наследява"
},
"RESET": "Задай всички на наследено"
"RESET": "Задай всички на наследено",
"CONSOLEUSEV2USERAPI": "Използвайте V2 API в конзолата за създаване на потребител",
"CONSOLEUSEV2USERAPI_DESCRIPTION": "Когато този флаг е активиран, конзолата използва V2 User API за създаване на нови потребители. С V2 API новосъздадените потребители започват без начален статус."
},
"DIALOG": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Telefonní číslo ověřeno",
"SEND_SMS": "Odeslat ověřovací SMS",
"SEND_EMAIL": "Odeslat E-mail",
"USERNAME": "Uživatelské jméno",
"CHANGEUSERNAME": "upravit",
"CHANGEUSERNAME_TITLE": "Změna uživatelského jména",
@@ -949,6 +952,14 @@
"5": "Pozastavený",
"6": "Počáteční"
},
"STATEV2": {
"0": "Neznámý",
"1": "Aktivní",
"2": "Neaktivní",
"3": "Smazaný",
"4": "Uzamčený",
"5": "Počáteční"
},
"SEARCH": {
"ADDITIONAL": "Přihlašovací jméno (současná organizace)",
"ADDITIONAL-EXTERNAL": "Přihlašovací jméno (externí organizace)"
@@ -1486,7 +1497,9 @@
"ENABLED": "„Povoleno“ 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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-Mail",
"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_VERIFIED": "Telefonnummer verifiziert",
"SEND_SMS": "Bestätigungs-SMS senden",
"SEND_EMAIL": "E-Mail senden",
"USERNAME": "Benutzername",
"CHANGEUSERNAME": "bearbeiten",
"CHANGEUSERNAME_TITLE": "Benutzername ändern",
@@ -949,6 +952,14 @@
"5": "Suspendiert",
"6": "Initialisiert"
},
"STATEV2": {
"0": "Unbekannt",
"1": "Aktiv",
"2": "Inaktiv",
"3": "Gelöscht",
"4": "Gesperrt",
"5": "Initialisiert"
},
"SEARCH": {
"ADDITIONAL": "Benutzer Name (eigene Organisation)",
"ADDITIONAL-EXTERNAL": "Loginname (externe Organisation)"
@@ -1486,7 +1497,9 @@
"ENABLED": "„Aktiviert“ 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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Phone Number Verified",
"SEND_SMS": "Send Verification SMS",
"SEND_EMAIL": "Send E-Mail",
"USERNAME": "User Name",
"CHANGEUSERNAME": "modify",
"CHANGEUSERNAME_TITLE": "Change username",
@@ -949,6 +952,14 @@
"5": "Suspended",
"6": "Initial"
},
"STATEV2": {
"0": "Unknown",
"1": "Active",
"2": "Inactive",
"3": "Deleted",
"4": "Locked",
"5": "Initial"
},
"SEARCH": {
"ADDITIONAL": "Loginname (current organization)",
"ADDITIONAL-EXTERNAL": "Loginname (external organization)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Enabled\" 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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Email",
"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_VERIFIED": "Número de teléfono verificado",
"SEND_SMS": "Enviar SMS de verificación",
"SEND_EMAIL": "Enviar correo electrónico",
"USERNAME": "Nombre de usuario",
"CHANGEUSERNAME": "modificar",
"CHANGEUSERNAME_TITLE": "Cambiar nombre de usuario",
@@ -949,6 +952,14 @@
"5": "Suspendido",
"6": "Inicial"
},
"STATEV2": {
"0": "Desconocido",
"1": "Activo",
"2": "Inactivo",
"3": "Borrado",
"4": "Bloqueado",
"5": "Inicial"
},
"SEARCH": {
"ADDITIONAL": "Nombre de inicio de sesión (organización actual)",
"ADDITIONAL-EXTERNAL": "Nombre de inicio de sesión (organización externa)"
@@ -1487,7 +1498,9 @@
"ENABLED": "\"Habilitado\" 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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "Courriel",
"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_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",
"CHANGEUSERNAME": "modifier",
"CHANGEUSERNAME_TITLE": "Modifier le nom d'utilisateur",
@@ -949,6 +952,14 @@
"5": "Suspendu",
"6": "Initial"
},
"STATEV2": {
"0": "Inconnu",
"1": "Actif",
"2": "Inactif",
"3": "Supprimé",
"4": "Verrouillé",
"5": "Initial"
},
"SEARCH": {
"ADDITIONAL": "Nom de connexion (organisation actuelle)",
"ADDITIONAL-EXTERNAL": "Nom de connexion (organisation externe)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Activé\" 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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail",
"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_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",
"CHANGEUSERNAME": "módosít",
"CHANGEUSERNAME_TITLE": "Felhasználónév megváltoztatása",
@@ -949,6 +952,14 @@
"5": "Felfüggesztett",
"6": "Kezdeti"
},
"STATEV2": {
"0": "Ismeretlen",
"1": "Aktív",
"2": "Inaktív",
"3": "Törölt",
"4": "Zárolt",
"5": "Kezdeti"
},
"SEARCH": {
"ADDITIONAL": "Belépési név (jelenlegi szervezet)",
"ADDITIONAL-EXTERNAL": "Belépési név (külső szervezet)"
@@ -1484,7 +1495,9 @@
"ENABLED": "\"Engedélyezve\" ö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": {
"RESET": {

View File

@@ -749,6 +749,9 @@
"EMAIL": "E-mail",
"PHONE": "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",
"CHANGEUSERNAME": "memodifikasi",
"CHANGEUSERNAME_TITLE": "Ubah nama pengguna",
@@ -878,6 +881,14 @@
"5": "Tergantung",
"6": "Awal"
},
"STATEV2": {
"0": "Tidak dikenal",
"1": "Aktif",
"2": "Tidak aktif",
"3": "Dihapus",
"4": "Terkunci",
"5": "Awal"
},
"SEARCH": {
"ADDITIONAL": "Nama login (organisasi saat ini)",
"ADDITIONAL-EXTERNAL": "Nama login (organisasi eksternal)"
@@ -1354,7 +1365,9 @@
"ENABLED": "\"Diaktifkan\" 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": {
"RESET": {

View File

@@ -808,6 +808,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Numero di telefono verificato",
"SEND_SMS": "Invia SMS di verifica",
"SEND_EMAIL": "Invia E-mail",
"USERNAME": "Nome utente",
"CHANGEUSERNAME": "cambia",
"CHANGEUSERNAME_TITLE": "Cambia nome utente",
@@ -948,6 +951,14 @@
"5": "Sospeso",
"6": "Initializzato"
},
"STATEV2": {
"0": "Sconosciuto",
"1": "Attivo",
"2": "Inattivo",
"3": "Rimosso",
"4": "Bloccato",
"5": "Initializzato"
},
"SEARCH": {
"ADDITIONAL": "Nome (organizzazione corrente)",
"ADDITIONAL-EXTERNAL": "Loginname (organizzazione esterna)"
@@ -1486,7 +1497,9 @@
"ENABLED": "\"Abilitato\" 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": {
"RESET": {

View File

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

View File

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

View File

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

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Telefoonnummer geverifieerd",
"SEND_SMS": "Verificatie-SMS verzenden",
"SEND_EMAIL": "E-mail verzenden",
"USERNAME": "Gebruikersnaam",
"CHANGEUSERNAME": "wijzigen",
"CHANGEUSERNAME_TITLE": "Gebruikersnaam wijzigen",
@@ -949,6 +952,14 @@
"5": "Opgeschort",
"6": "Initieel"
},
"STATEV2": {
"0": "Onbekend",
"1": "Actief",
"2": "Inactief",
"3": "Verwijderd",
"4": "Vergrendeld",
"5": "Initieel"
},
"SEARCH": {
"ADDITIONAL": "Loginnaam (huidige organisatie)",
"ADDITIONAL-EXTERNAL": "Loginnaam (externe organisatie)"
@@ -1484,7 +1495,9 @@
"ENABLED": "\"Ingeschakeld\" 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": {
"RESET": {

View File

@@ -808,6 +808,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Numer telefonu zweryfikowany",
"SEND_SMS": "Wyślij SMS weryfikacyjny",
"SEND_EMAIL": "Wyślij E-mail",
"USERNAME": "Nazwa użytkownika",
"CHANGEUSERNAME": "modyfikuj",
"CHANGEUSERNAME_TITLE": "Zmień nazwę użytkownika",
@@ -948,6 +951,14 @@
"5": "Zawieszony",
"6": "Początkowy"
},
"STATEV2": {
"0": "Nieznany",
"1": "Aktywny",
"2": "Nieaktywny",
"3": "Usunięty",
"4": "Zablokowany",
"5": "Początkowy"
},
"SEARCH": {
"ADDITIONAL": "Nazwa użytkownika (obecna organizacja)",
"ADDITIONAL-EXTERNAL": "Nazwa użytkownika (organizacja zewnętrzna)"
@@ -1485,7 +1496,9 @@
"ENABLED": "„Włą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": {
"RESET": {

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-mail",
"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_VERIFIED": "Número de telefone verificado",
"SEND_SMS": "Enviar SMS de verificação",
"SEND_EMAIL": "Enviar E-mail",
"USERNAME": "Nome de Usuário",
"CHANGEUSERNAME": "modificar",
"CHANGEUSERNAME_TITLE": "Alterar nome de usuário",
@@ -949,6 +952,14 @@
"5": "Suspenso",
"6": "Inicial"
},
"STATEV2": {
"0": "Desconhecido",
"1": "Ativo",
"2": "Inativo",
"3": "Excluído",
"4": "Bloqueado",
"5": "Inicial"
},
"SEARCH": {
"ADDITIONAL": "Nome de usuário (organização atual)",
"ADDITIONAL-EXTERNAL": "Nome de usuário (organização externa)"
@@ -1487,7 +1498,9 @@
"ENABLED": "\"Habilitado\" é 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": {
"RESET": {

View File

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

View File

@@ -809,6 +809,9 @@
"EMAIL": "E-post",
"PHONE": "Telefonnummer",
"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",
"CHANGEUSERNAME": "ändra",
"CHANGEUSERNAME_TITLE": "Ändra användarnamn",
@@ -949,6 +952,14 @@
"5": "Suspenderad",
"6": "Initial"
},
"STATEV2": {
"0": "Okänd",
"1": "Aktiv",
"2": "Inaktiv",
"3": "Raderad",
"4": "Låst",
"5": "Initial"
},
"SEARCH": {
"ADDITIONAL": "Inloggningsnamn (nuvarande organisation)",
"ADDITIONAL-EXTERNAL": "Inloggningsnamn (extern organisation)"
@@ -1490,7 +1501,9 @@
"ENABLED": "\"Aktiverad\" ä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": {
"RESET": {

View File

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

View File

@@ -1501,11 +1501,31 @@
"@bufbuild/buf-win32-arm64" "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":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
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":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@ctrl/ngx-codemirror/-/ngx-codemirror-6.1.0.tgz#9324a56e4b709be9c515364d21e05e1d7589f009"
@@ -3496,6 +3516,25 @@
js-yaml "^3.10.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":
version "0.0.6"
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"
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:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"