chore!: Introduce ZITADEL v3 (#9645)

This PR summarizes multiple changes specifically only available with
ZITADEL v3:

- feat: Web Keys management
(https://github.com/zitadel/zitadel/pull/9526)
- fix(cmd): ensure proper working of mirror
(https://github.com/zitadel/zitadel/pull/9509)
- feat(Authz): system user support for permission check v2
(https://github.com/zitadel/zitadel/pull/9640)
- chore(license): change from Apache to AGPL
(https://github.com/zitadel/zitadel/pull/9597)
- feat(console): list v2 sessions
(https://github.com/zitadel/zitadel/pull/9539)
- fix(console): add loginV2 feature flag
(https://github.com/zitadel/zitadel/pull/9682)
- fix(feature flags): allow reading "own" flags
(https://github.com/zitadel/zitadel/pull/9649)
- feat(console): add Actions V2 UI
(https://github.com/zitadel/zitadel/pull/9591)

BREAKING CHANGE
- feat(webkey): migrate to v2beta API
(https://github.com/zitadel/zitadel/pull/9445)
- chore!: remove CockroachDB Support
(https://github.com/zitadel/zitadel/pull/9444)
- feat(actions): migrate to v2beta API
(https://github.com/zitadel/zitadel/pull/9489)

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
Co-authored-by: Ramon <mail@conblem.me>
Co-authored-by: Elio Bischof <elio@zitadel.com>
Co-authored-by: Kenta Yamaguchi <56732734+KEY60228@users.noreply.github.com>
Co-authored-by: Harsha Reddy <harsha.reddy@klaviyo.com>
Co-authored-by: Livio Spring <livio@zitadel.com>
Co-authored-by: Max Peintner <max@caos.ch>
Co-authored-by: Iraq <66622793+kkrime@users.noreply.github.com>
Co-authored-by: Florian Forster <florian@zitadel.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Max Peintner <peintnerm@gmail.com>
This commit is contained in:
Fabienne Bühler
2025-04-02 16:53:06 +02:00
committed by GitHub
parent d14a23ae7e
commit 07ce3b6905
559 changed files with 14578 additions and 7622 deletions

View File

@@ -32,7 +32,7 @@
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@ngx-translate/core": "^15.0.0",
"@zitadel/client": "^1.0.7",
"@zitadel/proto": "^1.0.4",
"@zitadel/proto": "1.0.5-sha-4118a9d",
"angular-oauth2-oidc": "^15.0.1",
"angularx-qrcode": "^16.0.2",
"buffer": "^6.0.3",
@@ -65,6 +65,7 @@
"@angular/compiler-cli": "^16.2.5",
"@angular/language-service": "^18.2.4",
"@bufbuild/buf": "^1.41.0",
"@netlify/framework-info": "^9.8.13",
"@types/file-saver": "^2.0.7",
"@types/google-protobuf": "^3.15.3",
"@types/jasmine": "~5.1.4",
@@ -86,7 +87,6 @@
"karma-jasmine-html-reporter": "^2.1.0",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "5.1",
"@netlify/framework-info": "^9.8.13"
"typescript": "5.1"
}
}

View File

@@ -1,29 +1,29 @@
<div class="feature-row" *ngIf="$any(toggleStates)[toggleStateKey]">
<span>{{ 'SETTING.FEATURES.' + toggleStateKey.toUpperCase() | translate }}</span>
<div class="feature-row" *ngIf="toggleState$ | async as toggleState">
<span>{{ 'SETTING.FEATURES.' + (toggleStateKey | uppercase) | translate }}</span>
<div class="row">
<mat-button-toggle-group
class="theme-toggle"
class="buttongroup"
[(ngModel)]="$any(toggleStates)[toggleStateKey].state"
(change)="onToggleChange()"
[(ngModel)]="toggleState.enabled"
(change)="toggleChange.emit(toggleState)"
name="displayview"
>
<mat-button-toggle [value]="ToggleState.DISABLED">
<mat-button-toggle [value]="false">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
<div
*ngIf="!enabled && isInherited"
*ngIf="!toggleState.enabled && (isInherited$ | async)"
class="current-dot"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
></div>
</div>
</mat-button-toggle>
<mat-button-toggle [value]="ToggleState.ENABLED">
<mat-button-toggle [value]="true">
<div class="toggle-row">
<span>{{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
<div
*ngIf="enabled && isInherited"
*ngIf="toggleState.enabled && (isInherited$ | async)"
class="current-dot"
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
></div>
@@ -34,7 +34,7 @@
<ng-content></ng-content>
<cnsl-info-section
class="feature-info"
*ngIf="'SETTING.FEATURES.' + toggleStateKey.toUpperCase() + '_DESCRIPTION' | translate as i18nDescription"
*ngIf="'SETTING.FEATURES.' + (toggleStateKey | uppercase) + '_DESCRIPTION' | translate as i18nDescription"
>{{ i18nDescription }}</cnsl-info-section
>
</div>

View File

@@ -1,16 +1,14 @@
import { CommonModule } from '@angular/common';
import { AsyncPipe, NgIf, UpperCasePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CopyToClipboardModule } from '../../directives/copy-to-clipboard/copy-to-clipboard.module';
import { CopyRowComponent } from '../copy-row/copy-row.component';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { ToggleState, ToggleStateKeys, ToggleStates } from '../features/features.component';
import { ToggleStateKeys, ToggleStates } from '../features/features.component';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { FormsModule } from '@angular/forms';
import { GetInstanceFeaturesResponse } from '@zitadel/proto/zitadel/feature/v2/instance_pb';
import { FeatureFlag, Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { ReplaySubject } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
standalone: true,
@@ -19,37 +17,29 @@ import { FeatureFlag, Source } from '@zitadel/proto/zitadel/feature/v2/feature_p
styleUrls: ['./feature-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
TranslateModule,
MatButtonModule,
InfoSectionModule,
MatTooltipModule,
CopyToClipboardModule,
CopyRowComponent,
MatButtonToggleModule,
UpperCasePipe,
TranslateModule,
FormsModule,
MatTooltipModule,
InfoSectionModule,
AsyncPipe,
NgIf,
],
})
export class FeatureToggleComponent {
@Input() featureData: Partial<GetInstanceFeaturesResponse> = {};
@Input() toggleStates: Partial<ToggleStates> = {};
@Input() toggleStateKey: string = '';
@Output() toggleChange = new EventEmitter<void>();
protected ToggleState = ToggleState;
protected Source = Source;
get isInherited(): boolean {
const source = this.featureData[this.toggleStateKey as ToggleStateKeys]?.source;
return source == Source.SYSTEM || source == Source.UNSPECIFIED;
export class FeatureToggleComponent<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]> {
@Input({ required: true }) toggleStateKey!: TKey;
@Input({ required: true })
set toggleState(toggleState: TValue) {
// we copy the toggleState so we can mutate it
this.toggleState$.next(structuredClone(toggleState));
}
get enabled() {
// TODO: remove casting as not all features are a FeatureFlag
return (this.featureData[this.toggleStateKey as ToggleStateKeys] as FeatureFlag)?.enabled;
}
@Output() readonly toggleChange = new EventEmitter<TValue>();
onToggleChange() {
this.toggleChange.emit();
}
protected readonly Source = Source;
protected readonly toggleState$ = new ReplaySubject<TValue>(1);
protected readonly isInherited$ = this.toggleState$.pipe(
map(({ source }) => source == Source.SYSTEM || source == Source.UNSPECIFIED),
);
}

View File

@@ -0,0 +1,27 @@
<cnsl-feature-toggle
*ngIf="toggleState$ | async as toggleState"
toggleStateKey="loginV2"
[toggleState]="toggleState"
(toggleChange)="toggleState$.next($event); !$event.enabled && toggleChanged.emit($event)"
>
<cnsl-form-field *ngIf="toggleState.enabled">
<cnsl-label>{{ 'SETTING.FEATURES.LOGINV2_BASEURI' | translate }}</cnsl-label>
<input cnslInput [formControl]="baseUri" />
<button
matTooltip="{{ 'ACTIONS.SAVE' | translate }}"
mat-icon-button
[disabled]="baseUri.invalid"
color="primary"
type="submit"
(click)="
toggleChanged.emit({
source: toggleState.source,
enabled: toggleState.enabled,
baseUri: baseUri.value,
})
"
>
<i class="las la-save"></i>
</button>
</cnsl-form-field>
</cnsl-feature-toggle>

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core';
import { FeatureToggleComponent } from '../feature-toggle.component';
import { ToggleStates } from 'src/app/components/features/features.component';
import { distinctUntilKeyChanged, ReplaySubject } from 'rxjs';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { AsyncPipe, NgIf } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { InputModule } from 'src/app/modules/input/input.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { MatButtonModule } from '@angular/material/button';
import { TranslateModule } from '@ngx-translate/core';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({
standalone: true,
selector: 'cnsl-login-v2-feature-toggle',
templateUrl: './login-v2-feature-toggle.component.html',
imports: [
FeatureToggleComponent,
AsyncPipe,
NgIf,
ReactiveFormsModule,
InputModule,
HasRolePipeModule,
MatButtonModule,
TranslateModule,
MatTooltipModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginV2FeatureToggleComponent {
@Input({ required: true })
set toggleState(toggleState: ToggleStates['loginV2']) {
this.toggleState$.next(toggleState);
}
@Output()
public toggleChanged = new EventEmitter<ToggleStates['loginV2']>();
protected readonly toggleState$ = new ReplaySubject<ToggleStates['loginV2']>(1);
protected readonly baseUri = new FormControl('', { nonNullable: true, validators: [Validators.required] });
constructor(destroyRef: DestroyRef) {
this.toggleState$.pipe(distinctUntilKeyChanged('baseUri'), takeUntilDestroyed(destroyRef)).subscribe(({ baseUri }) => {
this.baseUri.setValue(baseUri);
});
}
}

View File

@@ -13,26 +13,20 @@
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['iam.restrictions.write']">
<button color="warn" (click)="resetSettings()" mat-stroked-button>
<button color="warn" (click)="resetFeatures()" mat-stroked-button>
{{ 'SETTING.FEATURES.RESET' | translate }}
</button>
</ng-template>
<cnsl-card *ngIf="toggleStates && featureData">
<cnsl-card *ngIf="toggleStates$ | async as toggleStates">
<div class="features">
<cnsl-feature-toggle
*ngFor="let key of toggleStateKeys"
[featureData]="featureData"
[toggleStates]="toggleStates"
*ngFor="let key of FEATURE_KEYS"
[toggleStateKey]="key"
(toggleChange)="validateAndSave()"
[toggleState]="toggleStates[key]"
(toggleChange)="saveFeatures(key, $event)"
></cnsl-feature-toggle>
<cnsl-login-v2-feature-toggle [toggleState]="toggleStates.loginV2" (toggleChanged)="saveFeatures('loginV2', $event)" />
</div>
</cnsl-card>
</div>
<ng-template #sourceLabel let-source="source" let-last="last">
<span class="state" *ngIf="source === Source.SOURCE_SYSTEM">
{{ 'SETTING.FEATURES.SOURCE.' + source | translate }}
</span>
</ng-template>

View File

@@ -19,16 +19,14 @@ import {
GetInstanceFeaturesResponse,
SetInstanceFeaturesRequestSchema,
} from '@zitadel/proto/zitadel/feature/v2/instance_pb';
import { FeatureFlag, Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { MessageInitShape } from '@bufbuild/protobuf';
import { firstValueFrom, Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-feature-toggle/login-v2-feature-toggle.component';
export enum ToggleState {
ENABLED = 'ENABLED',
DISABLED = 'DISABLED',
}
// TODO: to add a new feature, add the key here and in the FEATURE_KEYS array
const FEATURE_KEYS: ToggleStateKeys[] = [
// to add a new feature, add the key here and in the FEATURE_KEYS array
const FEATURE_KEYS = [
'actions',
'consoleUseV2UserApi',
'debugOidcParentError',
@@ -36,23 +34,24 @@ const FEATURE_KEYS: ToggleStateKeys[] = [
'enableBackChannelLogout',
// 'improvedPerformance',
'loginDefaultOrg',
// 'loginV2',
'oidcLegacyIntrospection',
'oidcSingleV1SessionTermination',
'oidcTokenExchange',
'oidcTriggerIntrospectionProjections',
'permissionCheckV2',
'userSchema',
// 'webKey',
];
type FeatureState = { source: Source; state: ToggleState };
export type ToggleStateKeys = Exclude<keyof GetInstanceFeaturesResponse, 'details' | '$typeName' | '$unknown'>;
'webKey',
] as const;
export type ToggleState = { source: Source; enabled: boolean };
export type ToggleStates = {
[key in ToggleStateKeys]: FeatureState;
[key in (typeof FEATURE_KEYS)[number]]: ToggleState;
} & {
loginV2: ToggleState & { baseUri: string };
};
export type ToggleStateKeys = keyof ToggleStates;
@Component({
imports: [
CommonModule,
@@ -68,6 +67,7 @@ export type ToggleStates = {
MatTooltipModule,
HasRoleModule,
FeatureToggleComponent,
LoginV2FeatureToggleComponent,
],
standalone: true,
selector: 'cnsl-features',
@@ -75,16 +75,15 @@ export type ToggleStates = {
styleUrls: ['./features.component.scss'],
})
export class FeaturesComponent {
protected featureData: GetInstanceFeaturesResponse | undefined;
protected toggleStates: ToggleStates | undefined;
protected Source: any = Source;
protected ToggleState: any = ToggleState;
private readonly refresh$ = new ReplaySubject<true>(1);
protected readonly toggleStates$: Observable<ToggleStates>;
protected readonly Source = Source;
protected readonly FEATURE_KEYS = FEATURE_KEYS;
constructor(
private featureService: NewFeatureService,
private breadcrumbService: BreadcrumbService,
private toast: ToastService,
private readonly featureService: NewFeatureService,
private readonly breadcrumbService: BreadcrumbService,
private readonly toast: ToastService,
) {
const breadcrumbs = [
new Breadcrumb({
@@ -95,74 +94,84 @@ export class FeaturesComponent {
];
this.breadcrumbService.setBreadcrumb(breadcrumbs);
this.getFeatures();
this.toggleStates$ = this.getToggleStates().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
public validateAndSave() {
const req: MessageInitShape<typeof SetInstanceFeaturesRequestSchema> = {
actions: this.toggleStates?.actions?.state === ToggleState.ENABLED,
consoleUseV2UserApi: this.toggleStates?.consoleUseV2UserApi?.state === ToggleState.ENABLED,
debugOidcParentError: this.toggleStates?.debugOidcParentError?.state === ToggleState.ENABLED,
disableUserTokenEvent: this.toggleStates?.disableUserTokenEvent?.state === ToggleState.ENABLED,
enableBackChannelLogout: this.toggleStates?.enableBackChannelLogout?.state === ToggleState.ENABLED,
loginDefaultOrg: this.toggleStates?.loginDefaultOrg?.state === ToggleState.ENABLED,
oidcLegacyIntrospection: this.toggleStates?.oidcLegacyIntrospection?.state === ToggleState.ENABLED,
oidcSingleV1SessionTermination: this.toggleStates?.oidcSingleV1SessionTermination?.state === ToggleState.ENABLED,
oidcTokenExchange: this.toggleStates?.oidcTokenExchange?.state === ToggleState.ENABLED,
oidcTriggerIntrospectionProjections:
this.toggleStates?.oidcTriggerIntrospectionProjections?.state === ToggleState.ENABLED,
permissionCheckV2: this.toggleStates?.permissionCheckV2?.state === ToggleState.ENABLED,
userSchema: this.toggleStates?.userSchema?.state === ToggleState.ENABLED,
// webKey: this.toggleStates?.webKey?.state === ToggleState.ENABLED,
};
this.featureService
.setInstanceFeatures(req)
.then(() => {
this.toast.showInfo('POLICY.TOAST.SET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
private getFeatures() {
this.featureService.getInstanceFeatures().then((instanceFeaturesResponse) => {
this.featureData = instanceFeaturesResponse;
this.toggleStates = this.createToggleStates(this.featureData);
});
private getToggleStates() {
return this.refresh$.pipe(
startWith(true),
switchMap(async () => {
try {
return await this.featureService.getInstanceFeatures();
} catch (error) {
this.toast.showError(error);
return undefined;
}
}),
filter(Boolean),
map((res) => this.createToggleStates(res)),
);
}
private createToggleStates(featureData: GetInstanceFeaturesResponse): ToggleStates {
const toggleStates: Partial<ToggleStates> = {};
FEATURE_KEYS.forEach((key) => {
// TODO: Fix this type cast as not all keys are present as FeatureFlag
const feature = featureData[key] as unknown as FeatureFlag;
toggleStates[key] = {
source: feature?.source || Source.SYSTEM,
state: !!feature?.enabled ? ToggleState.ENABLED : ToggleState.DISABLED,
};
});
return toggleStates as ToggleStates;
return FEATURE_KEYS.reduce(
(acc, key) => {
const feature = featureData[key];
acc[key] = {
source: feature?.source ?? Source.SYSTEM,
enabled: !!feature?.enabled,
};
return acc;
},
{
// to add special feature flags they have to be mapped here
loginV2: {
source: featureData.loginV2?.source ?? Source.SYSTEM,
enabled: !!featureData.loginV2?.required,
baseUri: featureData.loginV2?.baseUri ?? '',
},
} as ToggleStates,
);
}
public resetSettings(): void {
this.featureService
.resetInstanceFeatures()
.then(() => {
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
setTimeout(() => {
this.getFeatures();
}, 1000);
})
.catch((error) => {
this.toast.showError(error);
});
public async saveFeatures<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]>(key: TKey, value: TValue) {
const toggleStates = { ...(await firstValueFrom(this.toggleStates$)), [key]: value };
const req = FEATURE_KEYS.reduce<MessageInitShape<typeof SetInstanceFeaturesRequestSchema>>((acc, key) => {
acc[key] = toggleStates[key].enabled;
return acc;
}, {});
// to save special flags they have to be handled here
req.loginV2 = {
required: toggleStates.loginV2.enabled,
baseUri: toggleStates.loginV2.baseUri,
};
try {
await this.featureService.setInstanceFeatures(req);
// needed because of eventual consistency
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
this.toast.showInfo('POLICY.TOAST.SET', true);
} catch (error) {
this.toast.showError(error);
}
}
public get toggleStateKeys() {
return Object.keys(this.toggleStates ?? {});
public async resetFeatures() {
try {
await this.featureService.resetInstanceFeatures();
// needed because of eventual consistency
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
} catch (error) {
this.toast.showError(error);
}
}
}

View File

@@ -0,0 +1,16 @@
import { Directive, Input } from '@angular/core';
import { DataSource } from '@angular/cdk/collections';
import { MatCellDef } from '@angular/material/table';
import { CdkCellDef } from '@angular/cdk/table';
@Directive({
selector: '[cnslCellDef]',
providers: [{ provide: CdkCellDef, useExisting: TypeSafeCellDefDirective }],
})
export class TypeSafeCellDefDirective<T> extends MatCellDef {
@Input({ required: true }) cnslCellDefDataSource!: DataSource<T>;
static ngTemplateContextGuard<T>(_dir: TypeSafeCellDefDirective<T>, _ctx: any): _ctx is { $implicit: T; index: number } {
return true;
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TypeSafeCellDefDirective } from './type-safe-cell-def.directive';
@NgModule({
declarations: [TypeSafeCellDefDirective],
imports: [CommonModule],
exports: [TypeSafeCellDefDirective],
})
export class TypeSafeCellDefModule {}

View File

@@ -1,7 +1,6 @@
<div class="accounts-card">
<div class="accounts-card" *ngIf="user$ | async as user">
<cnsl-avatar
(click)="editUserProfile()"
*ngIf="user"
class="avatar"
[ngClass]="{ 'iam-user': iamuser }"
[forColor]="user.preferredLoginName"
@@ -16,8 +15,8 @@
<button (click)="editUserProfile()" mat-stroked-button>{{ 'USER.EDITACCOUNT' | translate }}</button>
<div class="l-accounts">
<mat-progress-bar *ngIf="loadingUsers" color="primary" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let session of sessions" (click)="selectAccount(session.loginName)">
<mat-progress-bar *ngIf="(sessions$ | async) === null" color="primary" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let session of sessions$ | async" (click)="selectAccount(session.loginName)">
<cnsl-avatar
*ngIf="session && session.displayName"
class="small-avatar"
@@ -31,7 +30,7 @@
<div class="col">
<span class="user-title">{{ session.displayName ? session.displayName : session.userName }} </span>
<span class="loginname">{{ session.loginName }}</span>
<span class="state inactive" *ngIf="session.authState === UserState.USER_STATE_INACTIVE">{{
<span class="state inactive" *ngIf="$any(session.authState) === UserState.USER_STATE_INACTIVE">{{
'USER.STATE.' + session.authState | translate
}}</span>
</div>

View File

@@ -1,64 +1,156 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Router } from '@angular/router';
import { AuthConfig } from 'angular-oauth2-oidc';
import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb';
import { SessionState as V1SessionState, 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';
import { SessionService } from 'src/app/services/session.service';
import {
catchError,
defer,
from,
map,
mergeMap,
Observable,
of,
ReplaySubject,
shareReplay,
switchMap,
timeout,
TimeoutError,
toArray,
} from 'rxjs';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { ToastService } from 'src/app/services/toast.service';
import { SessionState as V2SessionState } from '@zitadel/proto/zitadel/user_pb';
import { filter, withLatestFrom } from 'rxjs/operators';
interface V1AndV2Session {
displayName: string;
avatarUrl: string;
loginName: string;
userName: string;
authState: V1SessionState | V2SessionState;
}
@Component({
selector: 'cnsl-accounts-card',
templateUrl: './accounts-card.component.html',
styleUrls: ['./accounts-card.component.scss'],
})
export class AccountsCardComponent implements OnInit {
@Input() public user?: User.AsObject;
@Input() public iamuser: boolean | null = false;
@Output() public closedCard: EventEmitter<void> = new EventEmitter();
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,
private userService: GrpcAuthService,
) {
this.userService
.listMyUserSessions()
.then((sessions) => {
this.sessions = sessions.resultList.filter((user) => user.loginName !== this.user?.preferredLoginName);
this.loadingUsers = false;
})
.catch(() => {
this.loadingUsers = false;
});
export class AccountsCardComponent {
@Input({ required: true })
public set user(user: User.AsObject) {
this.user$.next(user);
}
ngOnInit(): void {
this.loadingUsers = true;
@Input() public iamuser: boolean | null = false;
@Output() public closedCard = new EventEmitter<void>();
protected readonly user$ = new ReplaySubject<User.AsObject>(1);
protected readonly UserState = UserState;
private readonly labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined });
protected readonly sessions$: Observable<V1AndV2Session[] | undefined>;
constructor(
protected readonly authService: AuthenticationService,
private readonly router: Router,
private readonly userService: GrpcAuthService,
private readonly sessionService: SessionService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
) {
this.sessions$ = this.getSessions().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
private getUseLoginV2() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map(({ loginV2 }) => loginV2?.required ?? false),
timeout(1000),
catchError((err) => {
if (!(err instanceof TimeoutError)) {
this.toast.showError(err);
}
return of(false);
}),
);
}
private getSessions(): Observable<V1AndV2Session[]> {
const useLoginV2$ = this.getUseLoginV2();
return useLoginV2$.pipe(
switchMap((useLoginV2) => {
if (useLoginV2) {
return this.getV2Sessions();
} else {
return this.getV1Sessions();
}
}),
catchError((err) => {
this.toast.showError(err);
return of([]);
}),
);
}
private getV1Sessions(): Observable<V1AndV2Session[]> {
return defer(() => this.userService.listMyUserSessions()).pipe(
mergeMap(({ resultList }) => from(resultList)),
withLatestFrom(this.user$),
filter(([{ loginName }, user]) => loginName !== user.preferredLoginName),
map(([s]) => ({
displayName: s.displayName,
avatarUrl: s.avatarUrl,
loginName: s.loginName,
authState: s.authState,
userName: s.userName,
})),
toArray(),
);
}
private getV2Sessions(): Observable<V1AndV2Session[]> {
return defer(() =>
this.sessionService.listSessions({
queries: [
{
query: {
case: 'userAgentQuery',
value: {},
},
},
],
}),
).pipe(
mergeMap(({ sessions }) => from(sessions)),
withLatestFrom(this.user$),
filter(([s, user]) => s.factors?.user?.loginName !== user.preferredLoginName),
map(([s]) => ({
displayName: s.factors?.user?.displayName ?? '',
avatarUrl: '',
loginName: s.factors?.user?.loginName ?? '',
authState: V2SessionState.ACTIVE,
userName: s.factors?.user?.loginName ?? '',
})),
toArray(),
);
}
public editUserProfile(): void {
this.router.navigate(['users/me']);
this.router.navigate(['users/me']).then();
this.closedCard.emit();
}
public closeCard(element: HTMLElement): void {
if (!element.classList.contains('dontcloseonclick')) {
this.closedCard.emit();
}
}
public selectAccount(loginHint: string): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
login_hint: loginHint,
},
};
this.authService.authenticate(configWithPrompt);
this.authService.authenticate(configWithPrompt).then();
}
public selectNewAccount(): void {
@@ -67,7 +159,7 @@ export class AccountsCardComponent implements OnInit {
prompt: 'login',
} as any,
};
this.authService.authenticate(configWithPrompt);
this.authService.authenticate(configWithPrompt).then();
}
public logout(): void {

View File

@@ -0,0 +1,75 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(dataSource$ | async) === null">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table *ngIf="dataSource$ | async as dataSource" mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<ng-container matColumnDef="condition">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.CONDITION' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<span *ngIf="row.condition?.conditionType?.value">
{{ row?.condition | condition }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.TYPE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ 'ACTIONSTWO.EXECUTION.TYPES.' + row?.condition?.conditionType?.case | translate }}
</td>
</ng-container>
<ng-container matColumnDef="target">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.TARGET' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<div class="target-key">
<cnsl-project-role-chip *ngFor="let target of filteredTargetTypes(row.targets) | async" [roleName]="target.name"
>{{ target.name }}
</cnsl-project-role-chip>
<cnsl-project-role-chip
*ngFor="let condition of filteredIncludeConditions(row.targets)"
[roleName]="condition | condition"
>
<mat-icon class="icon">refresh</mat-icon>
{{ condition | condition }}
</cnsl-project-role-chip>
</div>
</td>
</ng-container>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'ACTIONSTWO.EXECUTION.TABLE.CREATIONDATE' | translate }}
</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<span class="no-break">{{ row.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="$event.stopPropagation(); delete.emit(row)"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['condition', 'type', 'target', 'creationDate', 'actions']"></tr>
<tr
class="highlight pointer"
mat-row
*matRowDef="let row; columns: ['condition', 'type', 'target', 'creationDate', 'actions']"
></tr>
</table>
</div>
</cnsl-refresh-table>

View File

@@ -0,0 +1,12 @@
.target-key {
display: flex;
white-space: nowrap;
}
.icon {
font-size: 14px;
height: 14px;
width: 14px;
margin-right: 0.5rem;
margin-left: -0.5rem;
}

View File

@@ -0,0 +1,60 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Observable, ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { Condition, Execution, ExecutionTargetType } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
@Component({
selector: 'cnsl-actions-two-actions-table',
templateUrl: './actions-two-actions-table.component.html',
styleUrls: ['./actions-two-actions-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoActionsTableComponent {
@Output()
public readonly refresh = new EventEmitter<void>();
@Output()
public readonly delete = new EventEmitter<Execution>();
@Input({ required: true })
public set executions(executions: Execution[] | null) {
this.executions$.next(executions);
}
@Input({ required: true })
public set targets(targets: Target[] | null) {
this.targets$.next(targets);
}
@Output()
public readonly selected = new EventEmitter<Execution>();
private readonly executions$ = new ReplaySubject<Execution[] | null>(1);
private readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource$ = this.executions$.pipe(
filter(Boolean),
map((keys) => new MatTableDataSource(keys)),
);
protected filteredTargetTypes(targets: ExecutionTargetType[]): Observable<Target[]> {
const targetIds = targets
.map((t) => t.type)
.filter((t): t is Extract<ExecutionTargetType['type'], { case: 'target' }> => t.case === 'target')
.map((t) => t.value);
return this.targets$.pipe(
filter(Boolean),
map((alltargets) => alltargets!.filter((target) => targetIds.includes(target.id))),
);
}
protected filteredIncludeConditions(targets: ExecutionTargetType[]): Condition[] {
return targets
.map((t) => t.type)
.filter((t): t is Extract<ExecutionTargetType['type'], { case: 'include' }> => t.case === 'include')
.map(({ value }) => value);
}
}

View File

@@ -0,0 +1,14 @@
<h2>{{ 'ACTIONSTWO.EXECUTION.TITLE' | translate }}</h2>
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}</p>
<cnsl-actions-two-actions-table
(refresh)="refresh.next(true)"
(delete)="deleteExecution($event)"
(selected)="openDialog($event)"
[executions]="executions$ | async"
[targets]="targets$ | async"
>
<button color="primary" mat-raised-button (click)="openDialog()">
{{ 'ACTIONS.CREATE' | translate }}
</button>
</cnsl-actions-two-actions-table>

View File

@@ -0,0 +1,143 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from '@angular/core';
import { ActionService } from 'src/app/services/action.service';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { defer, firstValueFrom, Observable, of, shareReplay, Subject, TimeoutError } from 'rxjs';
import { catchError, filter, map, startWith, switchMap, tap, timeout } from 'rxjs/operators';
import { ToastService } from 'src/app/services/toast.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { ORGANIZATIONS } from '../../settings-list/settings';
import { ActionTwoAddActionDialogComponent } from '../actions-two-add-action/actions-two-add-action-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { MessageInitShape } from '@bufbuild/protobuf';
import { Execution, ExecutionSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { Value } from 'google-protobuf/google/protobuf/struct_pb';
@Component({
selector: 'cnsl-actions-two-actions',
templateUrl: './actions-two-actions.component.html',
styleUrls: ['./actions-two-actions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoActionsComponent implements OnInit {
protected readonly refresh = new Subject<true>();
private readonly actionsEnabled$: Observable<boolean>;
protected readonly executions$: Observable<Execution[]>;
protected readonly targets$: Observable<Target[]>;
constructor(
private readonly actionService: ActionService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly dialog: MatDialog,
) {
this.actionsEnabled$ = this.getActionsEnabled$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executions$ = this.getExecutions$(this.actionsEnabled$);
this.targets$ = this.getTargets$(this.actionsEnabled$);
}
ngOnInit(): void {
// this also preloads
this.actionsEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (enabled) => {
if (enabled) {
return;
}
await this.router.navigate([], {
relativeTo: this.route,
queryParams: {
id: ORGANIZATIONS.id,
},
queryParamsHandling: 'merge',
});
});
}
private getExecutions$(actionsEnabled$: Observable<boolean>) {
return this.refresh.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listExecutions({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
this.toast.showError(err);
}
return [];
}),
);
}
private getTargets$(actionsEnabled$: Observable<boolean>) {
return this.refresh.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listTargets({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
this.toast.showError(err);
}
return [];
}),
);
}
private getActionsEnabled$() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map(({ actions }) => actions?.enabled ?? false),
timeout(1000),
catchError((err) => {
if (!(err instanceof TimeoutError)) {
this.toast.showError(err);
}
return of(false);
}),
);
}
public openDialog(execution?: Execution): void {
const ref = this.dialog.open<ActionTwoAddActionDialogComponent>(ActionTwoAddActionDialogComponent, {
width: '400px',
data: execution
? {
execution: execution,
}
: {},
});
ref.afterClosed().subscribe((request?: MessageInitShape<typeof SetExecutionRequestSchema>) => {
if (request) {
this.actionService
.setExecution(request)
.then(() => {
setTimeout(() => {
this.refresh.next(true);
}, 1000);
})
.catch((error) => {
console.error(error);
this.toast.showError(error);
});
}
});
}
public async deleteExecution(execution: Execution) {
const deleteReq: MessageInitShape<typeof SetExecutionRequestSchema> = {
condition: execution.condition,
targets: [],
};
await this.actionService.setExecution(deleteReq);
await new Promise((res) => setTimeout(res, 1000));
this.refresh.next(true);
}
}

View File

@@ -0,0 +1,116 @@
<form *ngIf="form$ | async as form" [formGroup]="form.form" class="form-grid" (ngSubmit)="submit(form)">
<ng-container *ngIf="form.case === 'request' || form.case === 'response'">
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.REQ_RESP_DESCRIPTION' | translate }}</p>
<div class="emailVerified">
<mat-checkbox [formControl]="form.form.controls.all">
<div class="execution-condition-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.DESCRIPTION' | translate
}}</span>
</div>
</mat-checkbox>
</div>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.DESCRIPTION' | translate }}</cnsl-label>
<input
cnslInput
type="text"
placeholder=""
[formControl]="form.form.controls.service"
[matAutocomplete]="autoservice"
/>
<mat-autocomplete #autoservice="matAutocomplete">
<mat-option *ngIf="(executionServices$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let service of executionServices$ | async" [value]="service">
<span>{{ service }}</span>
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" [formControl]="form.form.controls.method" [matAutocomplete]="automethod" />
<mat-autocomplete #automethod="matAutocomplete">
<mat-option *ngIf="(executionMethods$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let method of executionMethods$ | async" [value]="method">
<span>{{ method }}</span>
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
</ng-container>
<ng-container *ngIf="form.case === 'function'">
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.TITLE' | translate }}</cnsl-label>
<input
cnslInput
type="text"
placeholder=""
[formControl]="form.form.controls.name"
[matAutocomplete]="autofunctionname"
/>
<mat-autocomplete #autofunctionname="matAutocomplete">
<mat-option *ngIf="(executionFunctions$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let function of executionFunctions$ | async" [value]="function">
<span>{{ function }}</span>
</mat-option>
</mat-autocomplete>
<span class="name-hint cnsl-secondary-text" cnslHint>{{
'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.DESCRIPTION' | translate
}}</span>
</cnsl-form-field>
</ng-container>
<ng-container *ngIf="form.case === 'event'">
<div class="emailVerified">
<mat-checkbox [formControl]="form.form.controls.all">
<div class="execution-condition-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.DESCRIPTION' | translate
}}</span>
</div>
</mat-checkbox>
</div>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_GROUP.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_GROUP.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" #nameInput [formControl]="form.form.controls.group" />
</cnsl-form-field>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" #nameInput [formControl]="form.form.controls.event" />
</cnsl-form-field>
</ng-container>
<div class="actions">
<button mat-stroked-button (click)="back.emit()">
{{ 'ACTIONS.BACK' | translate }}
</button>
<button [disabled]="form.form.invalid" color="primary" mat-raised-button type="submit">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</form>

View File

@@ -0,0 +1,21 @@
.execution-condition-text {
display: flex;
flex-direction: column;
.description {
font-size: 0.9rem;
}
}
.condition-description {
margin-bottom: 0;
}
.name-hint {
font-size: 12px;
}
.actions {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActionsTwoAddActionConditionComponent } from './actions-two-add-action-condition.component';
describe('ActionsTwoAddActionConditionComponent', () => {
let component: ActionsTwoAddActionConditionComponent;
let fixture: ComponentFixture<ActionsTwoAddActionConditionComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ActionsTwoAddActionConditionComponent],
});
fixture = TestBed.createComponent(ActionsTwoAddActionConditionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,343 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { InputModule } from 'src/app/modules/input/input.module';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
} from '@angular/forms';
import {
Observable,
catchError,
defer,
map,
of,
shareReplay,
ReplaySubject,
ObservedValueOf,
switchMap,
combineLatestWith,
OperatorFunction,
} from 'rxjs';
import { MatRadioModule } from '@angular/material/radio';
import { ActionService } from 'src/app/services/action.service';
import { ToastService } from 'src/app/services/toast.service';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { atLeastOneFieldValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
import { Message } from '@bufbuild/protobuf';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Condition } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { startWith } from 'rxjs/operators';
export type ConditionType = NonNullable<Condition['conditionType']['case']>;
export type ConditionTypeValue<T extends ConditionType> = Omit<
NonNullable<Extract<Condition['conditionType'], { case: T }>['value']>,
// we remove the message keys so $typeName is not required
keyof Message
>;
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'cnsl-actions-two-add-action-condition',
templateUrl: './actions-two-add-action-condition.component.html',
styleUrls: ['./actions-two-add-action-condition.component.scss'],
imports: [
TranslateModule,
MatRadioModule,
RouterModule,
ReactiveFormsModule,
InputModule,
MatAutocompleteModule,
MatCheckboxModule,
FormsModule,
CommonModule,
MatButtonModule,
MatProgressSpinnerModule,
],
})
export class ActionsTwoAddActionConditionComponent<T extends ConditionType = ConditionType> {
@Input({ required: true }) public set conditionType(conditionType: T) {
this.conditionType$.next(conditionType);
}
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<ConditionTypeValue<T>>();
private readonly conditionType$ = new ReplaySubject<T>(1);
protected readonly form$: ReturnType<typeof this.buildForm>;
protected readonly executionServices$: Observable<string[]>;
protected readonly executionMethods$: Observable<string[]>;
protected readonly executionFunctions$: Observable<string[]>;
constructor(
private readonly fb: FormBuilder,
private readonly actionService: ActionService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
) {
this.form$ = this.buildForm().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executionServices$ = this.listExecutionServices(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executionMethods$ = this.listExecutionMethods(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executionFunctions$ = this.listExecutionFunctions(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
public buildForm() {
return this.conditionType$.pipe(
switchMap((conditionType) => {
if (conditionType === 'event') {
return this.buildEventForm();
}
if (conditionType === 'function') {
return this.buildFunctionForm();
}
return this.buildRequestOrResponseForm(conditionType);
}),
);
}
private buildRequestOrResponseForm<T extends 'request' | 'response'>(requestOrResponse: T) {
const formFactory = () => ({
case: requestOrResponse,
form: this.fb.group(
{
all: new FormControl<boolean>(false, { nonNullable: true }),
service: new FormControl<string>('', { nonNullable: true }),
method: new FormControl<string>('', { nonNullable: true }),
},
{
validators: atLeastOneFieldValidator(['all', 'service', 'method']),
},
),
});
return new Observable<ReturnType<typeof formFactory>>((obs) => {
const form = formFactory();
obs.next(form);
const { all, service, method } = form.form.controls;
return all.valueChanges
.pipe(
map(() => all.value),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((all) => {
this.toggleFormControl(service, !all);
this.toggleFormControl(method, !all);
});
});
}
public buildFunctionForm() {
return of({
case: 'function' as const,
form: this.fb.group({
name: new FormControl<string>('', { nonNullable: true, validators: [requiredValidator] }),
}),
});
}
public buildEventForm() {
const formFactory = () => ({
case: 'event' as const,
form: this.fb.group({
all: new FormControl<boolean>(false, { nonNullable: true }),
group: new FormControl<string>('', { nonNullable: true }),
event: new FormControl<string>('', { nonNullable: true }),
}),
});
return new Observable<ReturnType<typeof formFactory>>((obs) => {
const form = formFactory();
obs.next(form);
const { all, group, event } = form.form.controls;
return all.valueChanges
.pipe(
map(() => all.value),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((all) => {
this.toggleFormControl(group, !all);
this.toggleFormControl(event, !all);
});
});
}
private toggleFormControl(control: FormControl, enabled: boolean) {
if (enabled) {
control.enable();
} else {
control.disable();
}
}
private listExecutionServices(form$: typeof this.form$) {
return defer(() => this.actionService.listExecutionServices({})).pipe(
map(({ services }) => services),
this.formFilter(form$, (form) => {
if ('service' in form.form.controls) {
return form.form.controls.service;
}
return undefined;
}),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
}
private listExecutionFunctions(form$: typeof this.form$) {
return defer(() => this.actionService.listExecutionFunctions({})).pipe(
map(({ functions }) => functions),
this.formFilter(form$, (form) => {
if (form.case !== 'function') {
return undefined;
}
return form.form.controls.name;
}),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
}
private listExecutionMethods(form$: typeof this.form$) {
return defer(() => this.actionService.listExecutionMethods({})).pipe(
map(({ methods }) => methods),
this.formFilter(form$, (form) => {
if ('method' in form.form.controls) {
return form.form.controls.method;
}
return undefined;
}),
// we also filter by service name
this.formFilter(form$, (form) => {
if ('service' in form.form.controls) {
return form.form.controls.service;
}
return undefined;
}),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
}
private formFilter(
form$: typeof this.form$,
getter: (form: ObservedValueOf<typeof this.form$>) => FormControl<string> | undefined,
): OperatorFunction<string[], string[]> {
const filterValue$ = form$.pipe(
map(getter),
switchMap((control) => {
if (!control) {
return of('');
}
return control.valueChanges.pipe(
startWith(control.value),
map((value) => value.toLowerCase()),
);
}),
);
return (obs) =>
obs.pipe(
combineLatestWith(filterValue$),
map(([values, filterValue]) => values.filter((v) => v.toLowerCase().includes(filterValue))),
);
}
protected submit(form: ObservedValueOf<typeof this.form$>) {
if (form.case === 'request' || form.case === 'response') {
(this as unknown as ActionsTwoAddActionConditionComponent<'request' | 'response'>).submitRequestOrResponse(form);
} else if (form.case === 'event') {
(this as unknown as ActionsTwoAddActionConditionComponent<'event'>).submitEvent(form);
} else if (form.case === 'function') {
(this as unknown as ActionsTwoAddActionConditionComponent<'function'>).submitFunction(form);
}
}
private submitRequestOrResponse(
this: ActionsTwoAddActionConditionComponent<'request' | 'response'>,
{ form }: ObservedValueOf<ReturnType<typeof this.buildRequestOrResponseForm>>,
) {
const { all, service, method } = form.getRawValue();
if (all) {
this.continue.emit({
condition: {
case: 'all',
value: true,
},
});
} else if (method) {
this.continue.emit({
condition: {
case: 'method',
value: method,
},
});
} else if (service) {
this.continue.emit({
condition: {
case: 'service',
value: service,
},
});
}
}
private submitEvent(
this: ActionsTwoAddActionConditionComponent<'event'>,
{ form }: ObservedValueOf<ReturnType<typeof this.buildEventForm>>,
) {
const { all, event, group } = form.getRawValue();
if (all) {
this.continue.emit({
condition: {
case: 'all',
value: true,
},
});
} else if (event) {
this.continue.emit({
condition: {
case: 'event',
value: event,
},
});
} else if (group) {
this.continue.emit({
condition: {
case: 'group',
value: group,
},
});
}
}
private submitFunction(
this: ActionsTwoAddActionConditionComponent<'function'>,
{ form }: ObservedValueOf<ReturnType<typeof this.buildFunctionForm>>,
) {
const { name } = form.getRawValue();
this.continue.emit({
name,
});
}
}

View File

@@ -0,0 +1,28 @@
<h2 *ngIf="!data.execution" mat-dialog-title>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CREATE_TITLE' | translate }}</h2>
<h2 *ngIf="data.execution" mat-dialog-title>{{ 'ACTIONSTWO.EXECUTION.DIALOG.UPDATE_TITLE' | translate }}</h2>
<mat-dialog-content>
<div class="framework-change-block" [ngSwitch]="page()">
<cnsl-actions-two-add-action-type
*ngSwitchCase="Page.Type"
[initialValue]="typeSignal()"
(back)="back()"
(continue)="typeSignal.set($event); continue()"
></cnsl-actions-two-add-action-type>
<cnsl-actions-two-add-action-condition
*ngSwitchCase="Page.Condition"
[conditionType]="typeSignal()"
(back)="back()"
(continue)="conditionSignal.set($event); continue()"
></cnsl-actions-two-add-action-condition>
<cnsl-actions-two-add-action-target
*ngSwitchCase="Page.Target"
(back)="back()"
[hideBackButton]="!!data.execution"
(continue)="targetSignal.set($event); continue()"
[selectedCondition]="data.execution?.condition"
></cnsl-actions-two-add-action-target>
</div>
</mat-dialog-content>

View File

@@ -0,0 +1,18 @@
.framework-change-block {
display: flex;
flex-direction: column;
align-items: stretch;
}
.actions {
display: flex;
justify-content: space-between;
}
.hide {
visibility: hidden;
}
.show {
visibility: visible;
}

View File

@@ -0,0 +1,102 @@
import { Component, computed, effect, Inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ActionsTwoAddActionTypeComponent } from './actions-two-add-action-type/actions-two-add-action-type.component';
import { MessageInitShape } from '@bufbuild/protobuf';
import {
ActionsTwoAddActionConditionComponent,
ConditionType,
ConditionTypeValue,
} from './actions-two-add-action-condition/actions-two-add-action-condition.component';
import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target/actions-two-add-action-target.component';
import { CommonModule } from '@angular/common';
import { Execution, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { Subject } from 'rxjs';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
enum Page {
Type,
Condition,
Target,
}
@Component({
selector: 'cnsl-actions-two-add-action-dialog',
templateUrl: './actions-two-add-action-dialog.component.html',
styleUrls: ['./actions-two-add-action-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
TranslateModule,
ActionsTwoAddActionTypeComponent,
ActionsTwoAddActionConditionComponent,
ActionsTwoAddActionTargetComponent,
],
})
export class ActionTwoAddActionDialogComponent {
public Page = Page;
public page = signal<Page | undefined>(Page.Type);
public typeSignal = signal<ConditionType>('request');
public conditionSignal = signal<ConditionTypeValue<ConditionType> | undefined>(undefined); // TODO: fix this type
public targetSignal = signal<Array<MessageInitShape<typeof ExecutionTargetTypeSchema>> | undefined>(undefined);
public continueSubject = new Subject<void>();
public request = computed<MessageInitShape<typeof SetExecutionRequestSchema>>(() => {
return {
condition: {
conditionType: {
case: this.typeSignal(),
value: this.conditionSignal() as any, // TODO: fix this type
},
},
targets: this.targetSignal(),
};
});
constructor(
public dialogRef: MatDialogRef<ActionTwoAddActionDialogComponent, MessageInitShape<typeof SetExecutionRequestSchema>>,
@Inject(MAT_DIALOG_DATA) protected readonly data: { execution?: Execution },
) {
if (data?.execution) {
this.typeSignal.set(data.execution.condition?.conditionType.case ?? 'request');
this.conditionSignal.set((data.execution.condition?.conditionType as any)?.value ?? undefined);
this.targetSignal.set(data.execution.targets ?? []);
this.page.set(Page.Target); // Set the initial page based on the provided execution data
}
effect(() => {
const currentPage = this.page();
if (currentPage === Page.Target) {
this.continueSubject.next(); // Trigger the Subject to request condition form when the page changes to "Target"
}
});
}
public continue() {
const currentPage = this.page();
if (currentPage === Page.Type) {
this.page.set(Page.Condition);
} else if (currentPage === Page.Condition) {
this.page.set(Page.Target);
} else {
this.dialogRef.close(this.request());
}
}
public back() {
const currentPage = this.page();
if (currentPage === Page.Target) {
this.page.set(Page.Condition);
} else if (currentPage === Page.Condition) {
this.page.set(Page.Type);
} else {
this.dialogRef.close();
}
}
}

View File

@@ -0,0 +1,38 @@
<form *ngIf="targetForm" class="form-grid" [formGroup]="targetForm" (ngSubmit)="submit()">
<p class="target-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.DESCRIPTION' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.TARGET.DESCRIPTION' | translate }}</cnsl-label>
<mat-select formControlName="target">
<mat-option *ngIf="(executionTargets$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let target of executionTargets$ | async" [value]="target">
{{ target.name }}
</mat-option>
</mat-select>
</cnsl-form-field>
<!-- <cnsl-form-field class="full-width">-->
<!-- <cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.CONDITIONS.DESCRIPTION' | translate }}</cnsl-label>-->
<!-- <mat-select [multiple]="true" formControlName="executionConditions">-->
<!-- <mat-option *ngIf="(executionConditions$ | async) === null" class="is-loading">-->
<!-- <mat-spinner diameter="30"></mat-spinner>-->
<!-- </mat-option>-->
<!-- <mat-option *ngFor="let condition of executionConditions$ | async" [value]="condition">-->
<!-- <span>{{ condition | condition }}</span>-->
<!-- </mat-option>-->
<!-- </mat-select>-->
<!-- </cnsl-form-field>-->
<div class="actions">
<button *ngIf="!hideBackButton" mat-stroked-button (click)="back.emit()">
{{ 'ACTIONS.BACK' | translate }}
</button>
<span class="fill-space"></span>
<button color="primary" [disabled]="targetForm.invalid" mat-raised-button type="submit">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</form>

View File

@@ -0,0 +1,12 @@
.target-description {
margin-bottom: 0;
}
.actions {
display: flex;
justify-content: space-between;
.fill-space {
font: 1;
}
}

View File

@@ -0,0 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target.component';
describe('ActionsTwoAddActionTargetComponent', () => {
let component: ActionsTwoAddActionTargetComponent;
let fixture: ComponentFixture<ActionsTwoAddActionTargetComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ActionsTwoAddActionTargetComponent],
});
fixture = TestBed.createComponent(ActionsTwoAddActionTargetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,155 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable, catchError, defer, map, of, shareReplay, ReplaySubject, combineLatestWith } from 'rxjs';
import { MatRadioModule } from '@angular/material/radio';
import { ActionService } from 'src/app/services/action.service';
import { ToastService } from 'src/app/services/toast.service';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { InputModule } from 'src/app/modules/input/input.module';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MessageInitShape } from '@bufbuild/protobuf';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { Condition, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { MatSelectModule } from '@angular/material/select';
import { atLeastOneFieldValidator } from 'src/app/modules/form-field/validators/validators';
import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module';
export type TargetInit = NonNullable<
NonNullable<MessageInitShape<typeof SetExecutionRequestSchema>['targets']>
>[number]['type'];
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'cnsl-actions-two-add-action-target',
templateUrl: './actions-two-add-action-target.component.html',
styleUrls: ['./actions-two-add-action-target.component.scss'],
imports: [
TranslateModule,
MatRadioModule,
RouterModule,
ReactiveFormsModule,
InputModule,
MatAutocompleteModule,
FormsModule,
ActionConditionPipeModule,
CommonModule,
MatButtonModule,
MatProgressSpinnerModule,
MatSelectModule,
],
})
export class ActionsTwoAddActionTargetComponent {
protected readonly targetForm = this.buildActionTargetForm();
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<MessageInitShape<typeof ExecutionTargetTypeSchema>[]>();
@Input() public hideBackButton = false;
@Input() set selectedCondition(selectedCondition: Condition | undefined) {
this.selectedCondition$.next(selectedCondition);
}
private readonly selectedCondition$ = new ReplaySubject<Condition | undefined>(1);
protected readonly executionTargets$: Observable<Target[]>;
protected readonly executionConditions$: Observable<Condition[]>;
constructor(
private readonly fb: FormBuilder,
private readonly actionService: ActionService,
private readonly toast: ToastService,
) {
this.executionTargets$ = this.listExecutionTargets().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executionConditions$ = this.listExecutionConditions().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
private buildActionTargetForm() {
return this.fb.group(
{
target: new FormControl<Target | null>(null, { validators: [] }),
executionConditions: new FormControl<Condition[]>([], { validators: [] }),
},
{
validators: atLeastOneFieldValidator(['target', 'executionConditions']),
},
);
}
private listExecutionTargets() {
return defer(() => this.actionService.listTargets({})).pipe(
map(({ result }) => result.filter(this.targetHasDetailsAndConfig)),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
}
private listExecutionConditions(): Observable<Condition[]> {
const selectedConditionJson$ = this.selectedCondition$.pipe(map((c) => JSON.stringify(c)));
return defer(() => this.actionService.listExecutions({})).pipe(
combineLatestWith(selectedConditionJson$),
map(([executions, selectedConditionJson]) =>
executions.result.map((e) => e?.condition).filter(this.conditionIsDefinedAndNotCurrentOne(selectedConditionJson)),
),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
}
private conditionIsDefinedAndNotCurrentOne(selectedConditionJson?: string) {
return (condition?: Condition): condition is Condition => {
if (!condition) {
// condition is undefined so it is not of type Condition
return false;
}
if (!selectedConditionJson) {
// condition is defined, and we don't have a selectedCondition so we can return all conditions
return true;
}
// we only return conditions that are not the same as the selectedCondition
return JSON.stringify(condition) !== selectedConditionJson;
};
}
private targetHasDetailsAndConfig(target: Target): target is Target {
return !!target.id && !!target.id;
}
protected submit() {
const { target, executionConditions } = this.targetForm.getRawValue();
let valueToEmit: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = target
? [
{
type: {
case: 'target',
value: target.id,
},
},
]
: [];
const includeConditions: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = executionConditions
? executionConditions.map((condition) => ({
type: {
case: 'include',
value: condition,
},
}))
: [];
valueToEmit = [...valueToEmit, ...includeConditions];
this.continue.emit(valueToEmit);
}
}

View File

@@ -0,0 +1,49 @@
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.DESCRIPTION' | translate }}</p>
<form class="form-grid" [formGroup]="typeForm" (ngSubmit)="submit()">
<div class="executionType">
<mat-radio-group class="execution-radio-group" aria-label="Select an option" formControlName="executionType">
<mat-radio-button class="execution-radio-button" [value]="'request'">
<div class="execution-type-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.REQUEST.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.REQUEST.DESCRIPTION' | translate
}}</span>
</div>
</mat-radio-button>
<mat-radio-button class="execution-radio-button" [value]="'response'"
><div class="execution-type-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.RESPONSE.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.RESPONSE.DESCRIPTION' | translate
}}</span>
</div></mat-radio-button
>
<mat-radio-button class="execution-radio-button" [value]="'event'"
><div class="execution-type-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.EVENTS.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.EVENTS.DESCRIPTION' | translate
}}</span>
</div></mat-radio-button
>
<mat-radio-button class="execution-radio-button" [value]="'function'"
><div class="execution-type-text">
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.FUNCTIONS.TITLE' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.FUNCTIONS.DESCRIPTION' | translate
}}</span>
</div></mat-radio-button
>
</mat-radio-group>
</div>
<div class="actions">
<button mat-stroked-button (click)="back.emit()">
{{ 'ACTIONS.CANCEL' | translate }}
</button>
<button color="primary" mat-raised-button type="submit">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</form>

View File

@@ -0,0 +1,20 @@
.execution-radio-group {
.execution-radio-button {
display: block;
margin-bottom: 1rem;
.execution-type-text {
display: flex;
flex-direction: column;
.description {
font-size: 0.9rem;
}
}
}
}
.actions {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActionsTwoAddActionTypeComponent } from './actions-two-add-action-type.component';
describe('ActionsTwoAddActionTypeComponent', () => {
let component: ActionsTwoAddActionTypeComponent;
let fixture: ComponentFixture<ActionsTwoAddActionTypeComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ActionsTwoAddActionTypeComponent],
});
fixture = TestBed.createComponent(ActionsTwoAddActionTypeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,53 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable, Subject, map, of, startWith, switchMap, tap } from 'rxjs';
import { MatRadioModule } from '@angular/material/radio';
import { ConditionType } from '../actions-two-add-action-condition/actions-two-add-action-condition.component';
// export enum ExecutionType {
// REQUEST = 'request',
// RESPONSE = 'response',
// EVENTS = 'event',
// FUNCTIONS = 'function',
// }
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'cnsl-actions-two-add-action-type',
templateUrl: './actions-two-add-action-type.component.html',
styleUrls: ['./actions-two-add-action-type.component.scss'],
imports: [TranslateModule, MatRadioModule, RouterModule, ReactiveFormsModule, FormsModule, CommonModule, MatButtonModule],
})
export class ActionsTwoAddActionTypeComponent {
protected readonly typeForm: ReturnType<typeof this.buildActionTypeForm> = this.buildActionTypeForm();
@Output() public readonly typeChanges$: Observable<ConditionType>;
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<ConditionType>();
@Input() public set initialValue(type: ConditionType) {
this.typeForm.get('executionType')!.setValue(type);
}
constructor(private readonly fb: FormBuilder) {
this.typeChanges$ = this.typeForm.get('executionType')!.valueChanges.pipe(
startWith(this.typeForm.get('executionType')!.value), // Emit the initial value
);
}
public buildActionTypeForm() {
return this.fb.group({
executionType: new FormControl<ConditionType>('request', {
nonNullable: true,
}),
});
}
public submit() {
this.continue.emit(this.typeForm.get('executionType')!.value);
}
}

View File

@@ -0,0 +1,65 @@
<h2 mat-dialog-title>{{ 'ACTIONSTWO.TARGET.CREATE.TITLE' | translate }}</h2>
<mat-dialog-content>
<p class="target-description">{{ 'ACTIONSTWO.TARGET.CREATE.DESCRIPTION' | translate }}</p>
<form *ngIf="targetForm" class="form-grid" [formGroup]="targetForm" (ngSubmit)="closeWithResult()">
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.TARGET.CREATE.NAME' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" formControlName="name" />
<span class="name-hint cnsl-secondary-text" cnslHint>{{
'ACTIONSTWO.TARGET.CREATE.NAME_DESCRIPTION' | translate
}}</span>
</cnsl-form-field>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.TARGET.CREATE.ENDPOINT' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" formControlName="endpoint" />
<span class="name-hint cnsl-secondary-text" cnslHint>{{
'ACTIONSTWO.TARGET.CREATE.ENDPOINT_DESCRIPTION' | translate
}}</span>
</cnsl-form-field>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.TARGET.CREATE.TYPE' | translate }}</cnsl-label>
<mat-select [formControl]="targetForm.controls.type" name="type">
<mat-option *ngFor="let type of targetTypes" [value]="type">
{{ 'ACTIONSTWO.TARGET.CREATE.TYPES.' + type | translate }}
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.TARGET.CREATE.TIMEOUT' | translate }}</cnsl-label>
<input cnslInput type="number" placeholder="10" [formControl]="targetForm.controls.timeout" />
<span class="name-hint cnsl-secondary-text" cnslHint>{{
'ACTIONSTWO.TARGET.CREATE.TIMEOUT_DESCRIPTION' | translate
}}</span>
</cnsl-form-field>
<mat-checkbox
*ngIf="targetForm.controls.type.value === 'restWebhook' || targetForm.controls.type.value === 'restCall'"
class="target-checkbox"
[formControl]="targetForm.controls.interruptOnError"
>
<div class="target-condition-text">
<span>{{ 'ACTIONSTWO.TARGET.CREATE.INTERRUPT_ON_ERROR' | translate }}</span>
<span class="description cnsl-secondary-text">{{
'ACTIONSTWO.TARGET.CREATE.INTERRUPT_ON_ERROR_DESCRIPTION' | translate
}}</span>
<span [style.color]="'var(--warn)'" class="description cnsl-secondary-text"
>{{ 'ACTIONSTWO.TARGET.CREATE.INTERRUPT_ON_ERROR_WARNING' | translate }}
</span>
</div>
</mat-checkbox>
</form>
</mat-dialog-content>
<div>
<mat-dialog-actions class="actions">
<button mat-stroked-button mat-dialog-close>
{{ 'ACTIONS.CANCEL' | translate }}
</button>
<button color="primary" [disabled]="targetForm.invalid" mat-raised-button (click)="closeWithResult()" cdkFocusInitial>
{{ 'ACTIONS.CREATE' | translate }}
</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,25 @@
.target-checkbox {
margin-bottom: 1rem;
.target-condition-text {
display: flex;
flex-direction: column;
.description {
font-size: 13px;
}
}
}
.target-description {
margin-bottom: 0;
}
.actions {
display: flex;
justify-content: space-between;
}
.name-hint {
font-size: 12px;
}

View File

@@ -0,0 +1,115 @@
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { InputModule } from '../../input/input.module';
import { requiredValidator } from '../../form-field/validators/validators';
import { MessageInitShape } from '@bufbuild/protobuf';
import { DurationSchema } from '@bufbuild/protobuf/wkt';
import { MatSelectModule } from '@angular/material/select';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import {
CreateTargetRequestSchema,
UpdateTargetRequestSchema,
} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
type TargetTypes = ActionTwoAddTargetDialogComponent['targetTypes'][number];
@Component({
selector: 'cnsl-actions-two-add-target-dialog',
templateUrl: './actions-two-add-target-dialog.component.html',
styleUrls: ['./actions-two-add-target-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
ReactiveFormsModule,
TranslateModule,
InputModule,
MatCheckboxModule,
MatSelectModule,
],
})
export class ActionTwoAddTargetDialogComponent {
protected readonly targetTypes = ['restCall', 'restWebhook', 'restAsync'] as const;
protected readonly targetForm: ReturnType<typeof this.buildTargetForm>;
constructor(
private fb: FormBuilder,
public dialogRef: MatDialogRef<
ActionTwoAddTargetDialogComponent,
MessageInitShape<typeof CreateTargetRequestSchema | typeof UpdateTargetRequestSchema>
>,
@Inject(MAT_DIALOG_DATA) private readonly data: { target?: Target },
) {
this.targetForm = this.buildTargetForm();
if (!data?.target) {
return;
}
this.targetForm.patchValue({
name: data.target.name,
endpoint: data.target.endpoint,
timeout: Number(data.target.timeout?.seconds),
type: this.data.target?.targetType?.case ?? 'restWebhook',
interruptOnError:
data.target.targetType.case === 'restWebhook' || data.target.targetType.case === 'restCall'
? data.target.targetType.value.interruptOnError
: false,
});
}
public buildTargetForm() {
return this.fb.group({
name: new FormControl<string>('', { nonNullable: true, validators: [requiredValidator] }),
type: new FormControl<TargetTypes>('restWebhook', {
nonNullable: true,
validators: [requiredValidator],
}),
endpoint: new FormControl<string>('', { nonNullable: true, validators: [requiredValidator] }),
timeout: new FormControl<number>(10, { nonNullable: true, validators: [requiredValidator] }),
interruptOnError: new FormControl<boolean>(false, { nonNullable: true }),
});
}
public closeWithResult() {
if (this.targetForm.invalid) {
return;
}
const { type, name, endpoint, timeout, interruptOnError } = this.targetForm.getRawValue();
const timeoutDuration: MessageInitShape<typeof DurationSchema> = {
seconds: BigInt(timeout),
nanos: 0,
};
const targetType: Extract<MessageInitShape<typeof CreateTargetRequestSchema>['targetType'], { case: TargetTypes }> =
type === 'restWebhook'
? { case: type, value: { interruptOnError } }
: type === 'restCall'
? { case: type, value: { interruptOnError } }
: { case: 'restAsync', value: {} };
const baseReq = {
name,
endpoint,
timeout: timeoutDuration,
targetType,
};
this.dialogRef.close(
this.data.target
? {
...baseReq,
id: this.data.target.id,
}
: baseReq,
);
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ActionsTwoActionsComponent } from './actions-two-actions/actions-two-actions.component';
const routes: Routes = [
{
path: '',
component: ActionsTwoActionsComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ActionsTwoRoutingModule {}

View File

@@ -0,0 +1,82 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(dataSource$ | async) === null">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table *ngIf="dataSource$ | async as dataSource" mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<!-- <ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef>{{ 'APP.PAGES.STATE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; let i = index; dataSource: dataSource">
<span
class="state"
[ngClass]="{
active: i === 0,
neutral: i === 1,
}"
[ngSwitch]="i"
>
<ng-container *ngSwitchCase="0">{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.ACTIVE' | translate }}</ng-container>
<ng-container *ngSwitchCase="1">{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.NEXT' | translate }}</ng-container>
<ng-container *ngSwitchDefault>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.FUTURE' | translate }}</ng-container>
</span>
</td>
</ng-container> -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.TARGET.TABLE.ID' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.id }}
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.TARGET.TABLE.NAME' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<div class="target-key">
<cnsl-project-role-chip [roleName]="row.name">{{ row.name }}</cnsl-project-role-chip>
</div>
</td>
</ng-container>
<ng-container matColumnDef="endpoint">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.TARGET.TABLE.ENDPOINT' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.endpoint }}
</td>
</ng-container>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'ACTIONSTWO.TARGET.TABLE.CREATIONDATE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<span class="no-break">{{ row.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
<!-- <ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'PROJECT.TYPE.TITLE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.key.case | uppercase }}
</td>
</ng-container> -->
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="$event.stopPropagation(); delete.emit(row)"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['name', 'endpoint', 'creationDate', 'actions']"></tr>
<tr
class="highlight pointer"
(click)="selected.emit(row)"
mat-row
*matRowDef="let row; columns: ['name', 'endpoint', 'creationDate', 'actions']"
></tr>
</table>
</div>
</cnsl-refresh-table>

View File

@@ -0,0 +1,4 @@
.target-key {
display: flex;
white-space: nowrap;
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
@Component({
selector: 'cnsl-actions-two-targets-table',
templateUrl: './actions-two-targets-table.component.html',
styleUrls: ['./actions-two-targets-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoTargetsTableComponent {
@Output()
public readonly refresh = new EventEmitter<void>();
@Output()
public readonly delete = new EventEmitter<Target>();
@Input({ required: true })
public set targets(targets: Target[] | null) {
this.targets$.next(targets);
}
@Output()
public readonly selected = new EventEmitter<Target>();
private readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource$ = this.targets$.pipe(
filter(Boolean),
map((keys) => new MatTableDataSource(keys)),
);
}

View File

@@ -0,0 +1,13 @@
<h2>{{ 'ACTIONSTWO.TARGET.TITLE' | translate }}</h2>
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.TARGET.DESCRIPTION' | translate }}</p>
<cnsl-actions-two-targets-table
(refresh)="refresh$.next(true)"
(delete)="deleteTarget($event)"
(selected)="openDialog($event)"
[targets]="targets$ | async"
>
<button color="primary" mat-raised-button (click)="openDialog()">
{{ 'ACTIONS.CREATE' | translate }}
</button>
</cnsl-actions-two-targets-table>

View File

@@ -0,0 +1,121 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from '@angular/core';
import { defer, firstValueFrom, Observable, of, ReplaySubject, shareReplay, Subject, TimeoutError } from 'rxjs';
import { ActionService } from 'src/app/services/action.service';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { ToastService } from 'src/app/services/toast.service';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ORGANIZATIONS } from '../../settings-list/settings';
import { catchError, filter, map, startWith, switchMap, timeout } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { ActionTwoAddTargetDialogComponent } from '../actions-two-add-target/actions-two-add-target-dialog.component';
import { MessageInitShape } from '@bufbuild/protobuf';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import {
CreateTargetRequestSchema,
UpdateTargetRequestSchema,
} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
@Component({
selector: 'cnsl-actions-two-targets',
templateUrl: './actions-two-targets.component.html',
styleUrls: ['./actions-two-targets.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoTargetsComponent implements OnInit {
private readonly actionsEnabled$: Observable<boolean>;
protected readonly targets$: Observable<Target[]>;
protected readonly refresh$ = new ReplaySubject<true>(1);
constructor(
private readonly actionService: ActionService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly dialog: MatDialog,
) {
this.actionsEnabled$ = this.getActionsEnabled$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.targets$ = this.getTargets$(this.actionsEnabled$);
}
ngOnInit(): void {
// this also preloads
this.actionsEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (enabled) => {
if (enabled) {
return;
}
await this.router.navigate([], {
relativeTo: this.route,
queryParams: {
id: ORGANIZATIONS.id,
},
queryParamsHandling: 'merge',
});
});
}
private getTargets$(actionsEnabled$: Observable<boolean>) {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listTargets({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
this.toast.showError(err);
}
return [];
}),
);
}
private getActionsEnabled$() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map(({ actions }) => actions?.enabled ?? false),
timeout(1000),
catchError((err) => {
if (!(err instanceof TimeoutError)) {
this.toast.showError(err);
}
return of(false);
}),
);
}
public async deleteTarget(target: Target) {
await this.actionService.deleteTarget({ id: target.id });
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
}
public openDialog(target?: Target): void {
const ref = this.dialog.open<
ActionTwoAddTargetDialogComponent,
{ target?: Target },
MessageInitShape<typeof UpdateTargetRequestSchema | typeof CreateTargetRequestSchema>
>(ActionTwoAddTargetDialogComponent, {
width: '550px',
data: {
target: target,
},
});
ref
.afterClosed()
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
.subscribe(async (dialogResponse) => {
if ('id' in dialogResponse) {
await this.actionService.updateTarget(dialogResponse);
} else {
await this.actionService.createTarget(dialogResponse);
}
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
});
}
}

View File

@@ -0,0 +1,53 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActionsTwoActionsComponent } from './actions-two-actions/actions-two-actions.component';
import { ActionsTwoTargetsComponent } from './actions-two-targets/actions-two-targets.component';
import { ActionsTwoRoutingModule } from './actions-two-routing.module';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { ActionsTwoTargetsTableComponent } from './actions-two-targets/actions-two-targets-table/actions-two-targets-table.component';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TableActionsModule } from '../table-actions/table-actions.module';
import { RefreshTableModule } from '../refresh-table/refresh-table.module';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ActionKeysModule } from '../action-keys/action-keys.module';
import { ActionsTwoActionsTableComponent } from './actions-two-actions/actions-two-actions-table/actions-two-actions-table.component';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { TypeSafeCellDefModule } from 'src/app/directives/type-safe-cell-def/type-safe-cell-def.module';
import { ProjectRoleChipModule } from '../project-role-chip/project-role-chip.module';
import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
@NgModule({
declarations: [
ActionsTwoActionsComponent,
ActionsTwoTargetsComponent,
ActionsTwoTargetsTableComponent,
ActionsTwoActionsTableComponent,
],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
TableActionsModule,
TimestampToDatePipeModule,
ActionsTwoRoutingModule,
LocalizedDatePipeModule,
ReactiveFormsModule,
TranslateModule,
MatTableModule,
MatTooltipModule,
MatSelectModule,
RefreshTableModule,
ActionKeysModule,
MatIconModule,
TypeSafeCellDefModule,
ProjectRoleChipModule,
ActionConditionPipeModule,
],
exports: [ActionsTwoActionsComponent, ActionsTwoTargetsComponent, ActionsTwoTargetsTableComponent],
})
export default class ActionsTwoModule {}

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>

View File

@@ -6,12 +6,7 @@
</div>
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.IAM_EVENTS.DESCRIPTION' | translate }}</p>
<cnsl-refresh-table
[hideRefresh]="true"
(refreshed)="refresh()"
[dataSize]="dataSource.data.length"
[loading]="_loading | async"
>
<cnsl-refresh-table [hideRefresh]="true" (refreshed)="refresh()" [loading]="_loading | async">
<div actions>
<cnsl-filter-events (requestChanged)="filterChanged($event)"></cnsl-filter-events>
</div>

View File

@@ -2,7 +2,7 @@
<p class="failed-events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.IAM_FAILED_EVENTS.DESCRIPTION' | translate }}</p>
<div class="table-wrapper">
<cnsl-refresh-table (refreshed)="loadEvents()" [dataSize]="eventDataSource.data.length" [loading]="loading$ | async">
<cnsl-refresh-table (refreshed)="loadEvents()" [loading]="loading$ | async">
<table [dataSource]="eventDataSource" mat-table class="table" aria-label="Elements">
<ng-container matColumnDef="viewName">
<th mat-header-cell *matHeaderCellDef>{{ 'IAM.FAILEDEVENTS.VIEWNAME' | translate }}</th>

View File

@@ -24,6 +24,17 @@ export function requiredValidator(c: AbstractControl): ValidationErrors | null {
return i18nErr(Validators.required(c), 'ERRORS.REQUIRED');
}
export function atLeastOneFieldValidator(fields: string[]): ValidatorFn {
return (formGroup: AbstractControl): ValidationErrors | null => {
const isValid = fields.some((field) => {
const control = formGroup.get(field);
return control && control.value;
});
return isValid ? null : { atLeastOneRequired: true }; // Return an error if none are set
};
}
export function minArrayLengthValidator(minArrLength: number): ValidatorFn {
return (c: AbstractControl): ValidationErrors | null => {
return arrayLengthValidator(c, minArrLength, 'ERRORS.ATLEASTONE');

View File

@@ -15,7 +15,7 @@ import { ActionKeysType } from '../action-keys/action-keys.component';
})
export class HeaderComponent {
@Input() public isDarkTheme: boolean = true;
@Input() public user?: User.AsObject;
@Input({ required: true }) public user!: User.AsObject;
public showOrgContext: boolean = false;
@Input() public org!: Org.AsObject;

View File

@@ -1,7 +1,7 @@
<h2>{{ 'DESCRIPTIONS.SETTINGS.IAM_VIEWS.TITLE' | translate }}</h2>
<p class="views-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.IAM_VIEWS.DESCRIPTION' | translate }}</p>
<cnsl-refresh-table (refreshed)="loadViews()" [dataSize]="dataSource.data.length" [loading]="loading$ | async">
<cnsl-refresh-table (refreshed)="loadViews()" [loading]="loading$ | async">
<table [dataSource]="dataSource" mat-table class="table views-table" aria-label="Views" matSort>
<ng-container matColumnDef="viewName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'IAM.VIEWS.VIEWNAME' | translate }}</th>

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[emitRefreshOnPreviousRoutes]="['/instance/idp/create']"
[timestamp]="idpResult?.details?.viewTimestamp"
[selection]="selection"

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
*ngIf="dataSource"
(refreshed)="changePage()"
[dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp"
[selection]="selection"
[loading]="dataSource.loading$ | async"

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
*ngIf="dataSource"
(refreshed)="changePage()"
[dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp"
[hideRefresh]="true"
[selection]="selection"

View File

@@ -1,12 +1,7 @@
<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
*ngIf="dataSource$ | async as dataSource"
[loading]="loading"
(refreshed)="refresh.emit()"
[dataSize]="dataSource.data.length"
>
<cnsl-refresh-table *ngIf="dataSource$ | async as dataSource" [loading]="loading" (refreshed)="refresh.emit()">
<button actions [disabled]="disabled" mat-raised-button color="primary" class="edit" (click)="editClicked.emit()">
{{ 'ACTIONS.EDIT' | translate }}
</button>

View File

@@ -1,9 +1,4 @@
<cnsl-refresh-table
[hideRefresh]="true"
(refreshed)="refresh()"
[dataSize]="dataSource.data.length"
[loading]="loading$ | async"
>
<cnsl-refresh-table [hideRefresh]="true" (refreshed)="refresh()" [loading]="loading$ | async">
<cnsl-filter-org actions (filterChanged)="applySearchQuery($any($event))" (filterOpen)="filterOpen = $event">
</cnsl-filter-org>

View File

@@ -36,7 +36,6 @@ export class OrgTableComponent {
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
public activeOrg!: Org.AsObject;
public OrgListSearchKey: any = OrgListSearchKey;
public initialLimit: number = 20;
public timestamp: Timestamp.AsObject | undefined = undefined;
public totalResult: number = 0;

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>

View File

@@ -0,0 +1,60 @@
<cnsl-card
[title]="'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.TITLE' | translate"
[description]="'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.DESCRIPTION' | translate"
>
<cnsl-form-field>
<cnsl-label>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.KEY_TYPE' | translate }}</cnsl-label>
<mat-select [value]="keyType()" (valueChange)="keyType.set($event)">
<mat-option value="rsa">RSA</mat-option>
<mat-option value="ecdsa">ECDSA</mat-option>
<mat-option value="ed25519">ED25519</mat-option>
</mat-select>
</cnsl-form-field>
<ng-container [ngSwitch]="keyType()">
<form *ngSwitchCase="'rsa'" [formGroup]="rsaForm" (ngSubmit)="ngSubmit.emit(rsaForm.getRawValue())">
<cnsl-form-field>
<cnsl-label>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.BITS' | translate }}</cnsl-label>
<mat-select formControlName="bits">
<ng-container *ngFor="let bit of RSABits | keyvalue">
<mat-option *ngIf="Number(bit.key) as key" [value]="key">{{
$any(bit.value).replace('RSA_BITS_', '')
}}</mat-option>
</ng-container>
</mat-select>
</cnsl-form-field>
<cnsl-form-field>
<cnsl-label>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.HASHER' | translate }}</cnsl-label>
<mat-select formControlName="hasher">
<ng-container *ngFor="let hasher of RSAHasher | keyvalue">
<mat-option *ngIf="Number(hasher.key) as key" [value]="key">{{
$any(hasher.value).replace('RSA_HASHER_', '')
}}</mat-option>
</ng-container>
</mat-select>
</cnsl-form-field>
<ng-container *ngTemplateOutlet="submitButton; context: { form: rsaForm }" />
</form>
<form *ngSwitchCase="'ecdsa'" [formGroup]="ecdsaForm" (ngSubmit)="ngSubmit.emit(ecdsaForm.getRawValue())">
<cnsl-form-field>
<cnsl-label>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.CREATE.CURVE' | translate }}</cnsl-label>
<mat-select formControlName="curve">
<ng-container *ngFor="let curve of ECDSACurve | keyvalue">
<mat-option *ngIf="Number(curve.key) as key" [value]="key">{{
$any(curve.value).replace('ECDSA_CURVE_', '')
}}</mat-option>
</ng-container>
</mat-select>
</cnsl-form-field>
<ng-container *ngTemplateOutlet="submitButton; context: { form: ecdsaForm }" />
</form>
<form *ngSwitchCase="'ed25519'" (submit)="emitEd25519($event)">
<ng-container *ngTemplateOutlet="submitButton" />
</form>
<ng-template #submitButton let-form="form">
<button [disabled]="(loading$ | async) || form?.invalid" mat-raised-button color="primary" type="submit">
<mat-spinner diameter="20" *ngIf="loading$ | async"></mat-spinner>
<span *ngIf="(loading$ | async) === false || (loading$ | async) === null">{{ 'ACTIONS.CREATE' | translate }}</span>
</button>
</ng-template>
</ng-container>
</cnsl-card>

View File

@@ -0,0 +1,60 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal, WritableSignal } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { WebKey } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
import { ReplaySubject } from 'rxjs';
import { RSAHasher, RSABits, ECDSACurve } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
type RawValue<T extends FormGroup> = ReturnType<T['getRawValue']>;
@Component({
selector: 'cnsl-oidc-webkeys-create',
templateUrl: './oidc-webkeys-create.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OidcWebKeysCreateComponent {
protected readonly keyType: WritableSignal<NonNullable<WebKey['key']['case']>> = signal('rsa');
protected readonly RSAHasher = RSAHasher;
protected readonly RSABits = RSABits;
protected readonly ECDSACurve = ECDSACurve;
protected readonly Number = Number;
protected readonly rsaForm = this.buildRsaForm();
protected readonly ecdsaForm = this.buildEcdsaForm();
protected readonly loading$ = new ReplaySubject<boolean>();
@Output()
public readonly ngSubmit = new EventEmitter<RawValue<typeof this.rsaForm> | RawValue<typeof this.ecdsaForm> | void>();
@Input()
public set loading(loading: boolean) {
this.loading$.next(loading);
}
constructor(private readonly fb: FormBuilder) {}
private buildRsaForm() {
return this.fb.group({
bits: new FormControl<RSABits>(RSABits.RSA_BITS_2048, {
nonNullable: true,
validators: [Validators.required],
}),
hasher: new FormControl<RSAHasher>(RSAHasher.RSA_HASHER_SHA256, {
nonNullable: true,
validators: [Validators.required],
}),
});
}
private buildEcdsaForm() {
return this.fb.group({
curve: new FormControl<ECDSACurve>(ECDSACurve.ECDSA_CURVE_P256, {
nonNullable: true,
validators: [Validators.required],
}),
});
}
protected emitEd25519(event: SubmitEvent) {
event.preventDefault();
this.ngSubmit.emit();
}
}

View File

@@ -0,0 +1,40 @@
<cnsl-card
[title]="'DESCRIPTIONS.SETTINGS.WEB_KEYS.PREVIOUS_TABLE.TITLE' | translate"
[description]="'DESCRIPTIONS.SETTINGS.WEB_KEYS.PREVIOUS_TABLE.DESCRIPTION' | translate"
>
<cnsl-refresh-table [hideRefresh]="true" [loading]="(dataSource$ | async) === null">
<div class="table-wrapper">
<table
*ngIf="dataSource$ | async as dataSource"
mat-table
class="table"
aria-label="Elements"
[dataSource]="dataSource"
>
<ng-container matColumnDef="timestamp">
<th mat-header-cell *matHeaderCellDef>
{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.PREVIOUS_TABLE.DEACTIVATED_ON' | translate }}
</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.changeDate | timestampToDate | localizedDate: 'EEE dd. MMM, HH:mm' }}
</td>
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'APP.PAGES.ID' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.id }}
</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'PROJECT.TYPE.TITLE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.key.case | uppercase }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['timestamp', 'id', 'type']"></tr>
<tr mat-row *matRowDef="let row; columns: ['timestamp', 'id', 'type']"></tr>
</table>
</div>
</cnsl-refresh-table>
</cnsl-card>

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { WebKey } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
@Component({
selector: 'cnsl-oidc-webkeys-inactive-table',
templateUrl: './oidc-webkeys-inactive-table.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OidcWebKeysInactiveTableComponent {
@Input({ required: true })
public set InactiveWebKeys(webKeys: WebKey[] | null) {
this.inactiveWebKeys$.next(webKeys);
}
private inactiveWebKeys$ = new ReplaySubject<WebKey[] | null>(1);
protected dataSource$ = this.inactiveWebKeys$.pipe(
filter(Boolean),
map((webKeys) => new MatTableDataSource(webKeys)),
);
}

View File

@@ -0,0 +1,57 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(dataSource$ | async) === null">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table *ngIf="dataSource$ | async as dataSource" mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef>{{ 'APP.PAGES.STATE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; let i = index; dataSource: dataSource">
<span
class="state"
[ngClass]="{
active: i === 0,
neutral: i === 1,
}"
[ngSwitch]="i"
>
<ng-container *ngSwitchCase="0">{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.ACTIVE' | translate }}</ng-container>
<ng-container *ngSwitchCase="1">{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.NEXT' | translate }}</ng-container>
<ng-container *ngSwitchDefault>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.FUTURE' | translate }}</ng-container>
</span>
</td>
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'APP.PAGES.ID' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.id }}
</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'PROJECT.TYPE.TITLE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ row.key.case | uppercase }}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<cnsl-table-actions *ngIf="row.state === State.INITIAL">
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="delete.emit(row)"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['state', 'id', 'type', 'actions']"></tr>
<tr mat-row *matRowDef="let row; columns: ['state', 'id', 'type', 'actions']"></tr>
</table>
</div>
</cnsl-refresh-table>

View File

@@ -0,0 +1,4 @@
.state.next {
color: #0e6245;
border: 1px solid #0e6245;
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { State, WebKey } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
@Component({
selector: 'cnsl-oidc-webkeys-table',
templateUrl: './oidc-webkeys-table.component.html',
styleUrls: ['./oidc-webkeys-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OidcWebKeysTableComponent {
@Output()
public readonly refresh = new EventEmitter<void>();
@Output()
public readonly delete = new EventEmitter<WebKey>();
@Input({ required: true })
public set webKeys(webKeys: WebKey[] | null) {
this.webKeys$.next(webKeys);
}
private readonly webKeys$ = new ReplaySubject<WebKey[] | null>(1);
protected readonly dataSource$ = this.webKeys$.pipe(
filter(Boolean),
map((keys) => new MatTableDataSource(keys)),
);
protected readonly State = State;
}

View File

@@ -0,0 +1,19 @@
<h2>{{ 'SETTINGS.LIST.WEB_KEYS' | translate }}</h2>
<p>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.DESCRIPTION' | translate }}</p>
<h3>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.TITLE' | translate }}</h3>
<p>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.DESCRIPTION' | translate }}</p>
<p>{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.NOTE' | translate }}</p>
<cnsl-oidc-webkeys-table (refresh)="refresh.next(true)" (delete)="deleteWebKey($event)" [webKeys]="webKeys$ | async">
<button
*ngIf="nextWebKeyCandidate$ | async as nextWebKeyCandidate"
[disabled]="activateLoading()"
mat-raised-button
color="primary"
(click)="activateWebKey(nextWebKeyCandidate)"
>
<mat-spinner diameter="20" *ngIf="activateLoading()"></mat-spinner>
<span *ngIf="!activateLoading()">{{ 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.ACTIVATE' | translate }}</span>
</button>
</cnsl-oidc-webkeys-table>
<cnsl-oidc-webkeys-create [loading]="createLoading()" (ngSubmit)="createWebKey($event)" />
<cnsl-oidc-webkeys-inactive-table [InactiveWebKeys]="inactiveWebKeys$ | async" />

View File

@@ -0,0 +1,217 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, signal } from '@angular/core';
import { WebKeysService } from 'src/app/services/webkeys.service';
import { defer, EMPTY, firstValueFrom, Observable, ObservedValueOf, of, shareReplay, Subject, switchMap } from 'rxjs';
import { catchError, map, startWith, withLatestFrom } from 'rxjs/operators';
import { ToastService } from 'src/app/services/toast.service';
import { MessageInitShape } from '@bufbuild/protobuf';
import { OidcWebKeysCreateComponent } from './oidc-webkeys-create/oidc-webkeys-create.component';
import { TimestampToDatePipe } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date.pipe';
import { MatDialog } from '@angular/material/dialog';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { State, WebKey } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
import { CreateWebKeyRequestSchema } from '@zitadel/proto/zitadel/webkey/v2beta/webkey_service_pb';
import { RSAHasher, RSABits, ECDSACurve } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { ActivatedRoute, Router } from '@angular/router';
const CACHE_WARNING_MS = 5 * 60 * 1000; // 5 minutes
@Component({
selector: 'cnsl-oidc-webkeys',
templateUrl: './oidc-webkeys.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OidcWebKeysComponent implements OnInit {
protected readonly refresh = new Subject<true>();
protected readonly webKeysEnabled$: Observable<boolean>;
protected readonly webKeys$: Observable<WebKey[]>;
protected readonly inactiveWebKeys$: Observable<WebKey[]>;
protected readonly nextWebKeyCandidate$: Observable<WebKey | undefined>;
protected readonly activateLoading = signal(false);
protected readonly createLoading = signal(false);
constructor(
private readonly webKeysService: WebKeysService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
private readonly timestampToDatePipe: TimestampToDatePipe,
private readonly dialog: MatDialog,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly route: ActivatedRoute,
) {
this.webKeysEnabled$ = this.getWebKeysEnabled().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const webKeys$ = this.getWebKeys(this.webKeysEnabled$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.webKeys$ = webKeys$.pipe(map((webKeys) => webKeys.filter((webKey) => webKey.state !== State.INACTIVE)));
this.inactiveWebKeys$ = webKeys$.pipe(map((webKeys) => webKeys.filter((webKey) => webKey.state === State.INACTIVE)));
this.nextWebKeyCandidate$ = this.getNextWebKeyCandidate(this.webKeys$);
}
ngOnInit(): void {
// redirect away from this page if web keys are not enabled
// this also preloads the web keys enabled state
this.webKeysEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (webKeysEnabled) => {
if (webKeysEnabled) {
return;
}
await this.router.navigate([], {
relativeTo: this.route,
queryParamsHandling: 'merge',
queryParams: {
id: null,
},
});
});
}
private getWebKeysEnabled() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map((features) => features.webKey?.enabled ?? false),
catchError((err) => {
this.toast.showError(err);
return of(false);
}),
);
}
private getWebKeys(webKeysEnabled$: Observable<boolean>) {
return this.refresh.pipe(
startWith(true),
switchMap(() => {
return this.webKeysService.ListWebKeys();
}),
map(({ webKeys }) => webKeys),
catchError(async (err) => {
const webKeysEnabled = await firstValueFrom(webKeysEnabled$);
// suppress errors if web keys are not enabled
if (!webKeysEnabled) {
return [];
}
this.toast.showError(err);
return [];
}),
);
}
private getNextWebKeyCandidate(webKeys$: Observable<WebKey[]>) {
return webKeys$.pipe(
map((webKeys) => {
if (webKeys.length < 2) {
return undefined;
}
const [webKey, nextWebKey] = webKeys;
if (webKey.state !== State.ACTIVE) {
return undefined;
}
if (nextWebKey.state !== State.INITIAL) {
return undefined;
}
return nextWebKey;
}),
);
}
protected async createWebKey(event: ObservedValueOf<OidcWebKeysCreateComponent['ngSubmit']>) {
try {
this.createLoading.set(true);
const req = !event
? this.createEd25519()
: 'curve' in event
? this.createEcdsa(event.curve)
: this.createRsa(event.bits, event.hasher);
await this.webKeysService.CreateWebKey(req);
this.refresh.next(true);
} catch (error) {
this.toast.showError(error);
} finally {
this.createLoading.set(false);
}
}
private createEd25519(): MessageInitShape<typeof CreateWebKeyRequestSchema> {
return {
key: {
case: 'ed25519',
value: {},
},
};
}
private createEcdsa(curve: ECDSACurve): MessageInitShape<typeof CreateWebKeyRequestSchema> {
return {
key: {
case: 'ecdsa',
value: {
curve,
},
},
};
}
private createRsa(bits: RSABits, hasher: RSAHasher): MessageInitShape<typeof CreateWebKeyRequestSchema> {
return {
key: {
case: 'rsa',
value: {
bits,
hasher,
},
},
};
}
protected async deleteWebKey(row: WebKey) {
try {
await this.webKeysService.DeleteWebKey(row.id);
this.refresh.next(true);
} catch (err) {
this.toast.showError(err);
}
}
protected async activateWebKey(nextWebKey: WebKey) {
try {
this.activateLoading.set(true);
const creationDate = this.timestampToDatePipe.transform(nextWebKey.creationDate);
if (!creationDate) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('Invalid creation date');
}
const diffToCurrentTime = Date.now() - creationDate.getTime();
if (diffToCurrentTime < CACHE_WARNING_MS && !(await this.openCacheWarnDialog())) {
return;
}
await this.webKeysService.ActivateWebKey(nextWebKey.id);
this.refresh.next(true);
} catch (error) {
this.toast.showError(error);
} finally {
this.activateLoading.set(false);
}
}
private openCacheWarnDialog() {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.ACTIVATE',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'Web Key is less then 5 min old',
descriptionKey: 'DESCRIPTIONS.SETTINGS.WEB_KEYS.TABLE.NOTE',
},
width: '400px',
});
const obs = dialogRef.afterClosed().pipe(map(Boolean), takeUntilDestroyed(this.destroyRef));
return firstValueFrom(obs);
}
}

View File

@@ -0,0 +1,58 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { OidcWebKeysComponent } from './oidc-webkeys.component';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTableModule } from '@angular/material/table';
import { MatMenuModule } from '@angular/material/menu';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
import { MatButtonModule } from '@angular/material/button';
import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module';
import { MatIconModule } from '@angular/material/icon';
import { FormFieldModule } from 'src/app/modules/form-field/form-field.module';
import { MatSelectModule } from '@angular/material/select';
import { ReactiveFormsModule } from '@angular/forms';
import { CardModule } from 'src/app/modules/card/card.module';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { OidcWebKeysCreateComponent } from './oidc-webkeys-create/oidc-webkeys-create.component';
import { OidcWebKeysTableComponent } from './oidc-webkeys-table/oidc-webkeys-table.component';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { OidcWebKeysInactiveTableComponent } from './oidc-webkeys-inactive-table/oidc-webkeys-inactive-table.component';
import { TypeSafeCellDefDirective } from './type-safe-cell-def.directive';
import { TimestampToDatePipe } from '../../../pipes/timestamp-to-date-pipe/timestamp-to-date.pipe';
import { MatTooltipModule } from '@angular/material/tooltip';
@NgModule({
declarations: [
OidcWebKeysComponent,
OidcWebKeysCreateComponent,
OidcWebKeysTableComponent,
OidcWebKeysInactiveTableComponent,
TypeSafeCellDefDirective,
],
providers: [TimestampToDatePipe],
imports: [
CommonModule,
TranslateModule,
RefreshTableModule,
MatCheckboxModule,
MatTableModule,
MatMenuModule,
TableActionsModule,
MatButtonModule,
ActionKeysModule,
MatIconModule,
FormFieldModule,
MatSelectModule,
ReactiveFormsModule,
CardModule,
MatProgressSpinnerModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
MatTooltipModule,
],
exports: [OidcWebKeysComponent],
})
export class OidcWebkeysModule {}

View File

@@ -0,0 +1,16 @@
import { Directive, Input } from '@angular/core';
import { DataSource } from '@angular/cdk/collections';
import { MatCellDef } from '@angular/material/table';
import { CdkCellDef } from '@angular/cdk/table';
@Directive({
selector: '[cnslCellDef]',
providers: [{ provide: CdkCellDef, useExisting: TypeSafeCellDefDirective }],
})
export class TypeSafeCellDefDirective<T> extends MatCellDef {
@Input({ required: true }) cnslCellDefDataSource!: DataSource<T>;
static ngTemplateContextGuard<T>(_dir: TypeSafeCellDefDirective<T>, _ctx: any): _ctx is { $implicit: T; index: number } {
return true;
}
}

View File

@@ -2,7 +2,6 @@
[showSelectionActionButton]="showSelectionActionButton"
*ngIf="projectId"
(refreshed)="refreshPage()"
[dataSize]="dataSource.totalResult"
[emitRefreshOnPreviousRoutes]="['/projects/' + projectId + '/roles/create']"
[selection]="selection"
[loading]="dataSource.loading$ | async"

View File

@@ -29,7 +29,6 @@ const rotate = animation([
export class RefreshTableComponent implements OnInit {
@Input() public selection: SelectionModel<any> = new SelectionModel<any>(true, []);
@Input() public timestamp: Timestamp.AsObject | ConnectTimestamp | undefined = undefined;
@Input() public dataSize: number = 0;
@Input() public emitRefreshAfterTimeoutInMs: number = 0;
@Input() public loading: boolean | null = false;
@Input() public emitRefreshOnPreviousRoutes: string[] = [];

View File

@@ -1,82 +1,85 @@
<cnsl-sidenav
*ngIf="currentSetting"
[title]="title"
[description]="description"
[indented]="true"
[(ngModel)]="currentSetting"
[settingsList]="settingsList"
queryParam="id"
>
<ng-container *ngIf="currentSetting === 'organizations'">
<cnsl-sidenav [indented]="true" [setting]="setting()" (settingChange)="setting.set($event)" [settingsList]="settingsList">
<ng-container *ngIf="setting()?.id === 'organizations'">
<h2>{{ 'ORG.PAGES.LIST' | translate }}</h2>
<p class="org-desc cnsl-secondary-text">{{ 'ORG.PAGES.LISTDESCRIPTION' | translate }}</p>
<cnsl-org-table></cnsl-org-table>
</ng-container>
<ng-container *ngIf="currentSetting === 'features'">
<ng-container *ngIf="setting()?.id === 'features'">
<cnsl-features></cnsl-features>
</ng-container>
<ng-container *ngIf="currentSetting === 'complexity'">
<ng-container *ngIf="setting()?.id === 'complexity'">
<cnsl-password-complexity-policy [serviceType]="serviceType"></cnsl-password-complexity-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'age'">
<ng-container *ngIf="setting()?.id === 'age'">
<cnsl-password-age-policy [serviceType]="serviceType"></cnsl-password-age-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'lockout'">
<ng-container *ngIf="setting()?.id === 'lockout'">
<cnsl-password-lockout-policy [serviceType]="serviceType"></cnsl-password-lockout-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'login'">
<ng-container *ngIf="setting()?.id === 'login'">
<cnsl-login-policy [serviceType]="serviceType"></cnsl-login-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'verified_domains'">
<ng-container *ngIf="setting()?.id === 'verified_domains'">
<cnsl-domains></cnsl-domains>
</ng-container>
<ng-container *ngIf="currentSetting === 'domain' && (['iam.policy.write'] | hasRole | async) === true">
<ng-container *ngIf="setting()?.id === 'domain' && (['iam.policy.write'] | hasRole | async) === true">
<cnsl-domain-policy [serviceType]="serviceType"></cnsl-domain-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'idp'">
<ng-container *ngIf="setting()?.id === 'idp'">
<cnsl-idp-settings [serviceType]="serviceType"></cnsl-idp-settings>
</ng-container>
<ng-container *ngIf="currentSetting === 'notifications'">
<ng-container *ngIf="setting()?.id === 'notifications'">
<cnsl-notification-policy [serviceType]="serviceType"></cnsl-notification-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'smtpprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'smtpprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-notification-smtp-provider [serviceType]="serviceType"></cnsl-notification-smtp-provider>
</ng-container>
<ng-container *ngIf="currentSetting === 'smsprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'smsprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-notification-sms-provider [serviceType]="serviceType"></cnsl-notification-sms-provider>
</ng-container>
<ng-container *ngIf="currentSetting === 'oidc' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'oidc' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-oidc-configuration></cnsl-oidc-configuration>
</ng-container>
<ng-container *ngIf="currentSetting === 'secrets' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'webkeys' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-oidc-webkeys />
</ng-container>
<ng-container *ngIf="setting()?.id === 'secrets' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-secret-generator></cnsl-secret-generator>
</ng-container>
<ng-container *ngIf="currentSetting === 'security' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'security' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-security-policy></cnsl-security-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'branding'">
<ng-container *ngIf="setting()?.id === 'branding'">
<cnsl-private-labeling-policy [serviceType]="serviceType"></cnsl-private-labeling-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'messagetexts'">
<ng-container *ngIf="setting()?.id === 'messagetexts'">
<cnsl-message-texts [serviceType]="serviceType"></cnsl-message-texts>
</ng-container>
<ng-container *ngIf="currentSetting === 'logintexts'">
<ng-container *ngIf="setting()?.id === 'logintexts'">
<cnsl-login-texts [serviceType]="serviceType"></cnsl-login-texts>
</ng-container>
<ng-container *ngIf="currentSetting === 'privacypolicy'">
<ng-container *ngIf="setting()?.id === 'privacypolicy'">
<cnsl-privacy-policy [serviceType]="serviceType"></cnsl-privacy-policy>
</ng-container>
<ng-container *ngIf="currentSetting === 'languages' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'languages' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-language-settings></cnsl-language-settings>
</ng-container>
<ng-container *ngIf="currentSetting === 'views' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'views' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-iam-views></cnsl-iam-views>
</ng-container>
<ng-container *ngIf="currentSetting === 'events' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'events' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-events></cnsl-events>
</ng-container>
<ng-container *ngIf="currentSetting === 'failedevents' && serviceType === PolicyComponentServiceType.ADMIN">
<ng-container *ngIf="setting()?.id === 'failedevents' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-iam-failed-events></cnsl-iam-failed-events>
</ng-container>
<!-- todo: figure out permissions -->
<ng-container *ngIf="setting()?.id === 'actions'">
<cnsl-actions-two-actions />
</ng-container>
<ng-container *ngIf="setting()?.id === 'actions_targets'">
<cnsl-actions-two-targets />
</ng-container>
<ng-content></ng-content>
</cnsl-sidenav>

View File

@@ -1,4 +1,4 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { Component, effect, Input, OnInit, signal } from '@angular/core';
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
import { SidenavSetting } from '../sidenav/sidenav.component';
@@ -8,28 +8,40 @@ import { SidenavSetting } from '../sidenav/sidenav.component';
templateUrl: './settings-list.component.html',
styleUrls: ['./settings-list.component.scss'],
})
export class SettingsListComponent implements OnChanges, OnInit {
@Input() public title: string = '';
@Input() public description: string = '';
@Input() public serviceType!: PolicyComponentServiceType;
@Input() public selectedId: string | undefined = undefined;
@Input() public settingsList: SidenavSetting[] = [];
public currentSetting: string | undefined = '';
public PolicyComponentServiceType: any = PolicyComponentServiceType;
constructor() {}
export class SettingsListComponent implements OnInit {
@Input({ required: true }) public serviceType!: PolicyComponentServiceType;
@Input() public set selectedId(selectedId: string) {
this.selectedId$.set(selectedId);
}
@Input({ required: true }) public settingsList: SidenavSetting[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (this.settingsList && this.settingsList.length && changes['selectedId']?.currentValue) {
this.currentSetting =
this.settingsList && this.settingsList.find((l) => l.id === changes['selectedId'].currentValue)
? changes['selectedId'].currentValue
: '';
}
protected setting = signal<SidenavSetting | null>(null);
private selectedId$ = signal<string | undefined>(undefined);
protected PolicyComponentServiceType: any = PolicyComponentServiceType;
constructor() {
effect(
() => {
const selectedId = this.selectedId$();
if (!selectedId) {
return;
}
const setting = this.settingsList.find(({ id }) => id === selectedId);
if (!setting) {
return;
}
this.setting.set(setting);
},
{ allowSignalWrites: true },
);
}
ngOnInit(): void {
if (!this.currentSetting) {
this.currentSetting = this.settingsList ? this.settingsList[0].id : '';
const firstSetting = this.settingsList[0];
if (!firstSetting || this.setting()) {
return;
}
this.setting.set(firstSetting);
}
}

View File

@@ -31,9 +31,13 @@ import { OrgTableModule } from '../org-table/org-table.module';
import { NotificationSMTPProviderModule } from '../policies/notification-smtp-provider/notification-smtp-provider.module';
import { FeaturesComponent } from 'src/app/components/features/features.component';
import OrgListModule from 'src/app/pages/org-list/org-list.module';
import ActionsTwoModule from '../actions-two/actions-two.module';
import { provideRouter } from '@angular/router';
import { OidcWebkeysModule } from '../policies/oidc-webkeys/oidc-webkeys.module';
@NgModule({
declarations: [SettingsListComponent],
providers: [provideRouter([])],
imports: [
CommonModule,
FormsModule,
@@ -62,10 +66,12 @@ import OrgListModule from 'src/app/pages/org-list/org-list.module';
NotificationSMTPProviderModule,
NotificationSMSProviderModule,
OIDCConfigurationModule,
OidcWebkeysModule,
SecretGeneratorModule,
FailedEventsModule,
IamViewsModule,
EventsModule,
ActionsTwoModule,
],
exports: [SettingsListComponent],
})

View File

@@ -35,6 +35,14 @@ export const OIDC: SidenavSetting = {
},
};
export const WEBKEYS: SidenavSetting = {
id: 'webkeys',
i18nKey: 'SETTINGS.LIST.WEB_KEYS',
requiredRoles: {
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
},
};
export const SECRETS: SidenavSetting = {
id: 'secrets',
i18nKey: 'SETTINGS.LIST.SECRETS',
@@ -214,3 +222,23 @@ export const BRANDING: SidenavSetting = {
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
},
};
export const ACTIONS: SidenavSetting = {
id: 'actions',
i18nKey: 'SETTINGS.LIST.ACTIONS',
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
requiredRoles: {
// todo: figure out roles
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
},
};
export const ACTIONS_TARGETS: SidenavSetting = {
id: 'actions_targets',
i18nKey: 'SETTINGS.LIST.TARGETS',
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
requiredRoles: {
// todo: figure out roles
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
},
};

View File

@@ -1,12 +1,9 @@
<div class="sidenav-container">
<div class="sidenav-settings-list" [ngClass]="{ indented: indented }">
<div class="sidenav-sticky-rel">
<h1 *ngIf="title">{{ title }}</h1>
<p *ngIf="description" class="cnsl-secondary-text">{{ description }}</p>
<button
*ngIf="currentSetting"
(click)="value = ''"
*ngIf="setting$()"
(click)="setting$.set(null)"
class="sidenav-setting-list-element mob-only"
[ngClass]="{ active: true }"
>
@@ -14,37 +11,25 @@
<span>{{ 'USER.SETTINGS.TITLE' | translate }}</span>
</button>
<ng-container *ngFor="let setting of settingsList; index as i">
<ng-container>
<span
class="sidenav-setting-group hide-on-mobile"
[ngClass]="{ show: !currentSetting }"
*ngIf="
(setting.groupI18nKey && i > 0 && setting.groupI18nKey !== settingsList[i - 1].groupI18nKey) ||
(i === 0 && setting.groupI18nKey)
"
>{{ setting.groupI18nKey | translate }}</span
>
<ng-container *ngFor="let setting of settingsList; index as i; trackBy: trackSettings">
<span
class="sidenav-setting-group hide-on-mobile"
*ngIf="
(setting.groupI18nKey && i > 0 && setting.groupI18nKey !== settingsList[i - 1].groupI18nKey) ||
(i === 0 && setting.groupI18nKey)
"
>{{ setting.groupI18nKey | translate }}</span
>
<button
(click)="value = setting.id"
class="sidenav-setting-list-element hide-on-mobile"
[ngClass]="{ active: currentSetting === setting.id, show: !currentSetting }"
[attr.data-e2e]="'sidenav-element-' + setting.id"
>
<span>{{ setting.i18nKey | translate }}</span>
<mat-icon *ngIf="setting.showWarn" class="warn-icon" svgIcon="mdi_shield_alert"></mat-icon>
</button>
</ng-container>
<ng-template #btn>
<button
(click)="value = setting.id"
class="sidenav-setting-list-element hide-on-mobile"
[ngClass]="{ active: currentSetting === setting.id, show: !currentSetting }"
>
<span>{{ setting.i18nKey | translate }}</span>
</button>
</ng-template>
<button
(click)="setting$.set(setting)"
class="sidenav-setting-list-element hide-on-mobile"
[ngClass]="{ active: setting$()?.id === setting.id }"
[attr.data-e2e]="'sidenav-element-' + setting.id"
>
<span>{{ setting.i18nKey | translate }}</span>
<mat-icon *ngIf="setting.showWarn" class="warn-icon" svgIcon="mdi_shield_alert"></mat-icon>
</button>
</ng-container>
</div>
</div>

View File

@@ -1,7 +1,5 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ChangeDetectionStrategy, Component, effect, EventEmitter, Input, Output, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
export interface SidenavSetting {
@@ -19,61 +17,61 @@ export interface SidenavSetting {
selector: 'cnsl-sidenav',
templateUrl: './sidenav.component.html',
styleUrls: ['./sidenav.component.scss'],
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SidenavComponent), multi: true }],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidenavComponent implements ControlValueAccessor {
@Input() public title: string = '';
@Input() public description: string = '';
export class SidenavComponent {
@Input() public navigate: boolean = true;
@Input() public indented: boolean = false;
@Input() public currentSetting?: string | undefined = undefined;
@Input() public settingsList: SidenavSetting[] = [];
@Input() public queryParam: string = '';
@Input({ required: true }) public settingsList: SidenavSetting[] = [];
@Input({ required: true })
public set setting(setting: SidenavSetting | null) {
if (!setting) {
return;
}
this.setting$.set(setting);
}
@Output()
public settingChange = new EventEmitter<SidenavSetting>();
protected readonly setting$ = signal<SidenavSetting | null>(null);
protected PolicyComponentServiceType = PolicyComponentServiceType;
public PolicyComponentServiceType: any = PolicyComponentServiceType;
constructor(
private router: Router,
private route: ActivatedRoute,
) {}
private readonly router: Router,
private readonly route: ActivatedRoute,
) {
effect(
() => {
const setting = this.setting$();
if (setting === null) {
return;
}
private onChange = (current: string | undefined) => {};
private onTouch = (current: string | undefined) => {};
this.settingChange.emit(setting);
@Input() get value(): string | undefined {
return this.currentSetting;
if (!this.navigate) {
return;
}
this.router
.navigate([], {
relativeTo: this.route,
queryParams: {
id: setting ? setting.id : undefined,
},
replaceUrl: true,
queryParamsHandling: 'merge',
skipLocationChange: false,
})
.then();
},
{ allowSignalWrites: true },
);
}
set value(setting: string | undefined) {
this.currentSetting = setting;
if (setting || setting === undefined || setting === '') {
this.onChange(setting);
this.onTouch(setting);
}
if (this.queryParam && setting) {
this.router
.navigate([], {
relativeTo: this.route,
queryParams: {
[this.queryParam]: setting,
},
replaceUrl: true,
queryParamsHandling: 'merge',
skipLocationChange: false,
})
.then();
}
}
public writeValue(value: any) {
this.value = value;
}
public registerOnChange(fn: any) {
this.onChange = fn;
}
public registerOnTouched(fn: any) {
this.onTouch = fn;
protected trackSettings(_: number, setting: SidenavSetting): string {
return setting.id;
}
}

View File

@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[emitRefreshOnPreviousRoutes]="[
'/instance/smtpprovider/aws-ses/create',
'/instance/smtpprovider/generic/create',

View File

@@ -4,7 +4,6 @@
[hideRefresh]="true"
[emitRefreshOnPreviousRoutes]="refreshOnPreviousRoutes"
[timestamp]="dataSource.viewTimestamp"
[dataSize]="dataSource.totalResult"
[selection]="selection"
>
<button

View File

@@ -2,7 +2,6 @@
[hideRefresh]="true"
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="actionsResult?.details?.viewTimestamp"
[selection]="selection"
>

View File

@@ -14,6 +14,12 @@ const routes: Routes = [
data: {
roles: ['iam.read'],
},
children: [
{
path: 'actions',
loadChildren: () => import('src/app/modules/actions-two/actions-two.module'),
},
],
},
{
path: 'members',

View File

@@ -40,12 +40,10 @@
<div class="max-width-container">
<div class="instance-settings-wrapper">
<ng-container *ngIf="settingsList | async as list">
<cnsl-settings-list
[selectedId]="id"
[serviceType]="PolicyComponentServiceType.ADMIN"
[settingsList]="list"
></cnsl-settings-list>
<ng-container *ngIf="settingsList$ | async as list">
<cnsl-settings-list [selectedId]="id" [serviceType]="PolicyComponentServiceType.ADMIN" [settingsList]="list">
<router-outlet />
</cnsl-settings-list>
</ng-container>
</div>
</div>

View File

@@ -1,8 +1,8 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, DestroyRef } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { BehaviorSubject, from, Observable, of, Subject } from 'rxjs';
import { catchError, finalize, map, takeUntil } from 'rxjs/operators';
import { BehaviorSubject, defer, from, Observable, of, shareReplay, TimeoutError } from 'rxjs';
import { catchError, finalize, map, timeout } from 'rxjs/operators';
import { CreationType, MemberCreateDialogComponent } from 'src/app/modules/add-member-dialog/member-create-dialog.component';
import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
import { InstanceDetail, State } from 'src/app/proto/generated/zitadel/instance_pb';
@@ -24,6 +24,7 @@ import {
MESSAGETEXTS,
NOTIFICATIONS,
OIDC,
WEBKEYS,
PRIVACYPOLICY,
SECRETS,
SECURITY,
@@ -34,28 +35,35 @@ import {
EVENTS,
ORGANIZATIONS,
FEATURESETTINGS,
} from '../../modules/settings-list/settings';
ACTIONS,
ACTIONS_TARGETS,
} from 'src/app/modules/settings-list/settings';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { EnvironmentService } from 'src/app/services/environment.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NewFeatureService } from '../../services/new-feature.service';
import { withLatestFromSynchronousFix } from '../../utils/withLatestFromSynchronousFix';
@Component({
selector: 'cnsl-instance',
templateUrl: './instance.component.html',
styleUrls: ['./instance.component.scss'],
})
export class InstanceComponent implements OnInit, OnDestroy {
public instance?: InstanceDetail.AsObject;
public PolicyComponentServiceType: any = PolicyComponentServiceType;
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
public totalMemberResult: number = 0;
public membersSubject: BehaviorSubject<Member.AsObject[]> = new BehaviorSubject<Member.AsObject[]>([]);
public State: any = State;
export class InstanceComponent {
protected instance?: InstanceDetail.AsObject;
protected readonly PolicyComponentServiceType = PolicyComponentServiceType;
private readonly loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
protected readonly loading$: Observable<boolean> = this.loadingSubject.asObservable();
protected totalMemberResult: number = 0;
protected readonly membersSubject: BehaviorSubject<Member.AsObject[]> = new BehaviorSubject<Member.AsObject[]>([]);
protected readonly State = State;
public id: string = '';
public defaultSettingsList: SidenavSetting[] = [
protected id: string = '';
protected readonly defaultSettingsList: SidenavSetting[] = [
ORGANIZATIONS,
FEATURESETTINGS,
ACTIONS,
ACTIONS_TARGETS,
// notifications
// { showWarn: true, ...NOTIFICATIONS },
NOTIFICATIONS,
@@ -67,7 +75,6 @@ export class InstanceComponent implements OnInit, OnDestroy {
COMPLEXITY,
AGE,
LOCKOUT,
DOMAIN,
// appearance
BRANDING,
@@ -81,23 +88,25 @@ export class InstanceComponent implements OnInit, OnDestroy {
PRIVACYPOLICY,
LANGUAGES,
OIDC,
WEBKEYS,
SECRETS,
SECURITY,
];
public settingsList: Observable<SidenavSetting[]> = of([]);
public customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal));
protected readonly settingsList$: Observable<SidenavSetting[]>;
protected readonly customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal));
private destroy$: Subject<void> = new Subject();
constructor(
public adminService: AdminService,
private dialog: MatDialog,
private toast: ToastService,
protected readonly adminService: AdminService,
private readonly dialog: MatDialog,
private readonly toast: ToastService,
breadcrumbService: BreadcrumbService,
private router: Router,
private authService: GrpcAuthService,
private envService: EnvironmentService,
private readonly router: Router,
private readonly authService: GrpcAuthService,
private readonly envService: EnvironmentService,
activatedRoute: ActivatedRoute,
private readonly destroyRef: DestroyRef,
private readonly featureService: NewFeatureService,
) {
this.loadMembers();
@@ -106,7 +115,6 @@ export class InstanceComponent implements OnInit, OnDestroy {
name: 'Instance',
routerLink: ['/instance'],
});
breadcrumbService.setBreadcrumb([instanceBread]);
this.adminService
@@ -120,12 +128,43 @@ export class InstanceComponent implements OnInit, OnDestroy {
this.toast.showError(error);
});
activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params: Params) => {
const { id } = params;
activatedRoute.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const id = params.get('id');
if (id) {
this.id = id;
}
});
this.settingsList$ = this.getSettingsList();
}
private getSettingsList(): Observable<SidenavSetting[]> {
const features$ = this.getFeatures().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const actionsEnabled$ = features$.pipe(map((features) => features?.actions?.enabled));
return this.authService
.isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.admin || [])
.pipe(
withLatestFromSynchronousFix(actionsEnabled$),
map(([settings, actionsEnabled]) =>
settings
.filter((setting) => actionsEnabled || setting.id !== ACTIONS.id)
.filter((setting) => actionsEnabled || setting.id !== ACTIONS_TARGETS.id),
),
);
}
private getFeatures() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
timeout(1000),
catchError((error) => {
if (!(error instanceof TimeoutError)) {
this.toast.showError(error);
}
return of(undefined);
}),
);
}
public loadMembers(): void {
@@ -185,18 +224,6 @@ export class InstanceComponent implements OnInit, OnDestroy {
}
public showDetail(): void {
this.router.navigate(['/instance', 'members']);
}
ngOnInit(): void {
this.settingsList = this.authService.isAllowedMapper(
this.defaultSettingsList,
(setting) => setting.requiredRoles.admin || [],
);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.router.navigate(['/instance', 'members']).then();
}
}

View File

@@ -1,6 +1,6 @@
<cnsl-top-view
title="{{ app?.name }}"
[hasActions]="isZitadel === false && (['project.app.write:' + projectId, 'project.app.write'] | hasRole | async)"
[hasActions]="!isZitadel && (['project.app.write:' + projectId, 'project.app.write'] | hasRole | async)"
docLink="https://zitadel.com/docs/guides/manage/console/projects"
[sub]="app?.oidcConfig ? ('APP.OIDC.APPTYPE.' + app?.oidcConfig?.appType | translate) : app?.apiConfig ? 'API' : 'SAML'"
[isActive]="app?.state === AppState.APP_STATE_ACTIVE"
@@ -10,43 +10,38 @@
<ng-template topActions cnslHasRole [hasRole]="['project.app.write:' + projectId, 'project.app.write']">
<div
class="regen-secret"
*ngIf="isZitadel === false && this.app?.oidcConfig && (currentAuthMethod === 'CODE' || currentAuthMethod === 'POST')"
*ngIf="!isZitadel && this.app?.oidcConfig && (currentAuthMethod === 'CODE' || currentAuthMethod === 'POST')"
>
<button type="button" [disabled]="!canWrite" mat-menu-item (click)="regenerateOIDCClientSecret()">
{{ 'APP.OIDC.REGENERATESECRET' | translate }}
</button>
</div>
<div class="regen-secret" *ngIf="isZitadel === false && this.app?.apiConfig && currentAuthMethod === 'BASIC'">
<div class="regen-secret" *ngIf="!isZitadel && this.app?.apiConfig && currentAuthMethod === 'BASIC'">
<button [disabled]="!canWrite" mat-menu-item (click)="regenerateAPIClientSecret()">
{{ 'APP.API.REGENERATESECRET' | translate }}
</button>
</div>
<button *ngIf="isZitadel === false" mat-menu-item (click)="openNameDialog()" aria-label="Edit project name">
<button *ngIf="!isZitadel" mat-menu-item (click)="openNameDialog()" aria-label="Edit project name">
{{ 'ACTIONS.RENAME' | translate }}
</button>
<button
mat-menu-item
*ngIf="isZitadel === false && app?.state !== AppState.APP_STATE_INACTIVE"
*ngIf="!isZitadel && app?.state !== AppState.APP_STATE_INACTIVE"
(click)="changeState(AppState.APP_STATE_INACTIVE)"
>
{{ 'ACTIONS.DEACTIVATE' | translate }}
</button>
<button
mat-menu-item
*ngIf="isZitadel === false && app?.state === AppState.APP_STATE_INACTIVE"
*ngIf="!isZitadel && app?.state === AppState.APP_STATE_INACTIVE"
(click)="changeState(AppState.APP_STATE_ACTIVE)"
>
{{ 'ACTIONS.REACTIVATE' | translate }}
</button>
<ng-template cnslHasRole [hasRole]="['project.app.delete:' + projectId, 'project.app.delete']">
<button
*ngIf="isZitadel === false"
mat-menu-item
matTooltip="{{ 'APP.PAGES.DELETE' | translate }}"
(click)="deleteApp()"
>
<button *ngIf="!isZitadel" mat-menu-item matTooltip="{{ 'APP.PAGES.DELETE' | translate }}" (click)="deleteApp()">
<span [style.color]="'var(--warn)'">{{ 'APP.PAGES.DELETE' | translate }}</span>
</button>
</ng-template>
@@ -80,8 +75,8 @@
<div class="app-main-content">
<cnsl-meta-layout>
<cnsl-sidenav [(ngModel)]="currentSetting" [settingsList]="settingsList">
<ng-container *ngIf="currentSetting === 'configuration'">
<cnsl-sidenav [(setting)]="currentSetting" [settingsList]="settingsList">
<ng-container *ngIf="currentSetting.id === 'configuration'">
<cnsl-card *ngIf="oidcForm && app?.oidcConfig" title=" {{ 'APP.OIDC.TITLE' | translate }}">
<div
class="current-auth-method"
@@ -274,7 +269,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'token'">
<ng-container *ngIf="currentSetting.id === 'token'">
<cnsl-card *ngIf="oidcTokenForm && app?.oidcConfig" title=" {{ 'APP.OIDC.TOKENSECTIONTITLE' | translate }}">
<form [formGroup]="oidcTokenForm" (ngSubmit)="saveOIDCApp()">
<cnsl-form-field class="app-formfield">
@@ -355,7 +350,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'redirect-uris'">
<ng-container *ngIf="currentSetting.id === 'redirect-uris'">
<cnsl-card title=" {{ 'APP.OIDC.REDIRECTSECTIONTITLE' | translate }}">
<cnsl-info-section *ngIf="appType?.value === OIDCAppType.OIDC_APP_TYPE_NATIVE">
<div class="dev-col">
@@ -435,7 +430,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'additional-origins'">
<ng-container *ngIf="currentSetting.id === 'additional-origins'">
<cnsl-card title=" {{ 'APP.ADDITIONALORIGINS' | translate }}">
<p class="app-desc cnsl-secondary-text">{{ 'APP.ADDITIONALORIGINSDESC' | translate }}</p>
<cnsl-additional-origins
@@ -456,7 +451,7 @@
</div>
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'urls'">
<ng-container *ngIf="currentSetting.id === 'urls'">
<cnsl-card title=" {{ 'APP.URLS' | translate }}">
<cnsl-info-section *ngIf="issuer$ | async as issuer">
<div
@@ -502,7 +497,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'configuration'">
<ng-container *ngIf="currentSetting.id === 'configuration'">
<cnsl-card
*ngIf="initialAuthMethod === 'PK_JWT' && projectId && app && app.id"
title="{{ 'USER.MACHINE.KEYSTITLE' | translate }}"

View File

@@ -73,7 +73,6 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public canWrite: boolean = false;
public errorMessage: string = '';
public removable: boolean = true;
public addOnBlur: boolean = true;
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
@@ -168,21 +167,20 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public isZitadel: boolean = false;
public docs!: GetOIDCInformationResponse.AsObject;
public OIDCAppType: any = OIDCAppType;
public OIDCAuthMethodType: any = OIDCAuthMethodType;
public APIAuthMethodType: any = APIAuthMethodType;
public OIDCTokenType: any = OIDCTokenType;
public OIDCGrantType: any = OIDCGrantType;
public OIDCAppType = OIDCAppType;
public OIDCAuthMethodType = OIDCAuthMethodType;
public APIAuthMethodType = APIAuthMethodType;
public OIDCTokenType = OIDCTokenType;
public OIDCGrantType = OIDCGrantType;
public ChangeType: any = ChangeType;
public ChangeType = ChangeType;
public requestRedirectValuesSubject$: Subject<void> = new Subject();
public copiedKey: any = '';
public InfoSectionType: any = InfoSectionType;
public InfoSectionType = InfoSectionType;
public copied: string = '';
public settingsList: SidenavSetting[] = [{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' }];
public currentSetting: string | undefined = this.settingsList[0].id;
public currentSetting = this.settingsList[0];
public isNew = signal<boolean>(false);
@@ -305,7 +303,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (projectId && appId) {
this.projectId = projectId;
this.appId = appId;
this.getData(projectId, appId);
this.getData(projectId, appId).then();
}
}
@@ -395,7 +393,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (this.initialAuthMethod === 'BASIC') {
this.settingsList = [{ id: 'urls', i18nKey: 'APP.URLS' }];
this.currentSetting = 'urls';
this.currentSetting = this.settingsList[0];
} else {
this.settingsList = [
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
@@ -742,13 +740,13 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (this.currentAuthMethod === 'BASIC') {
this.settingsList = [{ id: 'urls', i18nKey: 'APP.URLS' }];
this.currentSetting = 'urls';
this.currentSetting = this.settingsList[0];
} else {
this.settingsList = [
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
{ id: 'urls', i18nKey: 'APP.URLS' },
];
this.currentSetting = 'configuration';
this.currentSetting = this.settingsList[0];
}
}
this.toast.showInfo('APP.TOAST.APIUPDATED', true);

View File

@@ -2,7 +2,6 @@
[loading]="dataSource.loading$ | async"
[selection]="selection"
(refreshed)="refreshPage()"
[dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp"
>
<ng-template cnslHasRole [hasRole]="['project.app.write']" actions>

View File

@@ -66,8 +66,8 @@
<div class="max-width-container">
<cnsl-meta-layout>
<cnsl-sidenav [(ngModel)]="currentSetting" [settingsList]="settingsList" [queryParam]="'id'">
<ng-container *ngIf="currentSetting === 'general' && project">
<cnsl-sidenav [(setting)]="currentSetting" [settingsList]="settingsList">
<ng-container *ngIf="currentSetting.id === 'general' && project">
<ng-template cnslHasRole [hasRole]="['project.app.read:' + project.id, 'project.app.read$']">
<cnsl-application-grid
*ngIf="grid"
@@ -91,7 +91,7 @@
</cnsl-card>
</ng-template>
<ng-container *ngIf="isZitadel === false">
<ng-container *ngIf="!isZitadel">
<ng-template cnslHasRole [hasRole]="['project.role.read:' + project.id, 'project.role.read']">
<cnsl-card
id="roles"
@@ -160,7 +160,7 @@
</ng-container>
</ng-container>
<ng-container *ngIf="currentSetting === 'roles'">
<ng-container *ngIf="currentSetting.id === 'roles'">
<h2 class="section-h2">{{ 'PROJECT.ROLE.TITLE' | translate }}</h2>
<p class="desc cnsl-secondary-text">{{ 'PROJECT.ROLE.DESCRIPTION' | translate }}</p>
@@ -172,12 +172,12 @@
>
</cnsl-project-roles-table>
</ng-container>
<ng-container *ngIf="currentSetting === 'projectgrants'">
<ng-container *ngIf="currentSetting.id === 'projectgrants'">
<h2 class="section-h2">{{ 'PROJECT.GRANT.TITLE' | translate }}</h2>
<p class="desc cnsl-secondary-text">{{ 'PROJECT.GRANT.DESCRIPTION' | translate }}</p>
<cnsl-project-grants [projectId]="projectId"></cnsl-project-grants>
</ng-container>
<ng-container *ngIf="currentSetting === 'grants'">
<ng-container *ngIf="currentSetting.id === 'grants'">
<h2 class="section-h2">{{ 'GRANTS.TITLE' | translate }}</h2>
<p class="desc cnsl-secondary-text">{{ 'GRANTS.DESC' | translate }}</p>

View File

@@ -1,7 +1,6 @@
import { Location } from '@angular/common';
import { Component, EventEmitter, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
@@ -12,8 +11,7 @@ import { ProjectPrivateLabelingDialogComponent } from 'src/app/modules/project-p
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 { App } from 'src/app/proto/generated/zitadel/app_pb';
import { ListAppsResponse, UpdateProjectRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { UpdateProjectRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Member } from 'src/app/proto/generated/zitadel/member_pb';
import { PrivateLabelingSetting, Project, ProjectState } from 'src/app/proto/generated/zitadel/project_pb';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
@@ -21,7 +19,7 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { NameDialogComponent } from '../../../../modules/name-dialog/name-dialog.component';
import { NameDialogComponent } from 'src/app/modules/name-dialog/name-dialog.component';
const ROUTEPARAM = 'projectid';
@@ -39,11 +37,6 @@ export class OwnedProjectDetailComponent implements OnInit {
public projectId: string = '';
public project?: Project.AsObject;
public pageSizeApps: number = 10;
public appsDataSource: MatTableDataSource<App.AsObject> = new MatTableDataSource<App.AsObject>();
public appsResult!: ListAppsResponse.AsObject;
public appsColumns: string[] = ['name'];
public ProjectState: any = ProjectState;
public ChangeType: any = ChangeType;
@@ -61,22 +54,25 @@ export class OwnedProjectDetailComponent implements OnInit {
public refreshChanges$: EventEmitter<void> = new EventEmitter();
public settingsList: SidenavSetting[] = [GENERAL, ROLES, PROJECTGRANTS, GRANTS];
public currentSetting: string = '';
public currentSetting = this.settingsList[0];
constructor(
public translate: TranslateService,
private route: ActivatedRoute,
private toast: ToastService,
private mgmtService: ManagementService,
private _location: Location,
private dialog: MatDialog,
private router: Router,
private breadcrumbService: BreadcrumbService,
) {
this.currentSetting = 'general';
route.queryParams.pipe(take(1)).subscribe((params: Params) => {
const { id } = params;
if (id) {
this.currentSetting = id;
route.queryParamMap.pipe(take(1)).subscribe((params) => {
const id = params.get('id');
if (!id) {
return;
}
const setting = this.settingsList.find((setting) => setting.id === id);
if (setting) {
this.currentSetting = setting;
}
});
}
@@ -85,7 +81,7 @@ export class OwnedProjectDetailComponent implements OnInit {
const projectId = this.route.snapshot.paramMap.get(ROUTEPARAM);
if (projectId) {
this.projectId = projectId;
this.getData(projectId);
this.getData(projectId).then();
}
}
@@ -249,7 +245,7 @@ export class OwnedProjectDetailComponent implements OnInit {
const params: Params = {
deferredReload: true,
};
this.router.navigate(['/projects'], { queryParams: params });
this.router.navigate(['/projects'], { queryParams: params }).then();
})
.catch((error) => {
this.toast.showError(error);
@@ -280,10 +276,6 @@ export class OwnedProjectDetailComponent implements OnInit {
}
}
public navigateBack(): void {
this._location.back();
}
public updateName(): void {
this.saveProject();
}
@@ -323,7 +315,7 @@ export class OwnedProjectDetailComponent implements OnInit {
public showDetail(): void {
if (this.project) {
this.router.navigate(['projects', this.project.id, 'members']);
this.router.navigate(['projects', this.project.id, 'members']).then();
}
}
}

View File

@@ -5,7 +5,6 @@
[loading]="dataSource.loading$ | async"
*ngIf="projectId"
(refreshed)="refreshPage()"
[dataSize]="dataSource.totalResult"
[selection]="selection"
[timestamp]="dataSource.viewTimestamp"
(refreshed)="getRoleOptions(projectId)"

View File

@@ -2,7 +2,6 @@
<cnsl-refresh-table
[hideRefresh]="true"
(refreshed)="refreshPage(type)"
[dataSize]="totalResult"
[timestamp]="viewTimestamp"
[selection]="selection"
[loading]="loading$ | async"

View File

@@ -21,6 +21,11 @@
{{ 'USER.LOGINMETHODS.EMAIL.ISVERIFIED' | translate }}
</mat-checkbox>
</div>
<cnsl-form-field class="username">
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input class="stretchInput" cnslInput formControlName="username" required />
</cnsl-form-field>
<cnsl-form-field class="givenName">
<cnsl-label>{{ 'USER.PROFILE.FIRSTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="givenName" required />
@@ -29,14 +34,6 @@
<cnsl-label>{{ 'USER.PROFILE.LASTNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="familyName" required />
</cnsl-form-field>
<cnsl-form-field class="nickName">
<cnsl-label>{{ 'USER.PROFILE.NICKNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="nickName" />
</cnsl-form-field>
<cnsl-form-field class="username">
<cnsl-label>{{ 'USER.PROFILE.USERNAME' | translate }}</cnsl-label>
<input cnslInput formControlName="username" required />
</cnsl-form-field>
<div class="authenticationFactor">
<mat-radio-group

View File

@@ -13,8 +13,8 @@
grid-template-areas:
'email email'
'emailVerified emailVerified'
'username username'
'givenName familyName'
'nickName username'
'authenticationFactor authenticationFactor';
column-gap: 1rem;
}
@@ -35,10 +35,6 @@
grid-area: familyName;
}
.nickName {
grid-area: nickName;
}
.username {
grid-area: username;
}

View File

@@ -97,7 +97,6 @@ export class UserCreateV2Component implements OnInit {
username: new FormControl('', { nonNullable: true, validators: [requiredValidator, minLengthValidator(2)] }),
givenName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
familyName: new FormControl('', { nonNullable: true, validators: [requiredValidator] }),
nickName: new FormControl('', { nonNullable: true }),
emailVerified: new FormControl(false, { nonNullable: true }),
authenticationFactor: new FormControl<AuthenticationFactor['factor']>(authenticationFactor, {
nonNullable: true,
@@ -188,7 +187,6 @@ export class UserCreateV2Component implements OnInit {
profile: {
givenName: userValues.givenName,
familyName: userValues.familyName,
nickName: userValues.nickName,
},
email: {
email: userValues.email,

View File

@@ -11,12 +11,7 @@
>
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-refresh-table
[hideRefresh]="true"
[loading]="loading$ | async"
(refreshed)="getPasswordless()"
[dataSize]="dataSource.data.length"
>
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" (refreshed)="getPasswordless()">
<button
actions
class="button"

View File

@@ -24,13 +24,11 @@
<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)"
[setting]="currentSetting$()"
(settingChange)="currentSetting$.set($event)"
[settingsList]="settingsList"
queryParam="id"
>
<ng-container *ngIf="currentSetting === 'general' && humanUser(userQuery) as humanUser">
<ng-container *ngIf="currentSetting$().id === 'general' && humanUser(userQuery) as humanUser">
<cnsl-card
*ngIf="humanUser.type.value.profile as profile"
class="app-card"
@@ -91,11 +89,11 @@
</ng-template>
</ng-container>
<ng-container *ngIf="currentSetting === 'idp'">
<ng-container *ngIf="currentSetting$().id === 'idp'">
<cnsl-external-idps [userId]="user.userId" [service]="grpcAuthService"></cnsl-external-idps>
</ng-container>
<ng-container *ngIf="currentSetting === 'security'">
<ng-container *ngIf="currentSetting$().id === 'security'">
<cnsl-card *ngIf="humanUser(userQuery) as humanUser" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
@@ -127,7 +125,7 @@
></cnsl-auth-user-mfa>
</ng-container>
<ng-container *ngIf="currentSetting === 'grants'">
<ng-container *ngIf="currentSetting$().id === 'grants'">
<cnsl-card title="{{ 'GRANTS.USER.TITLE' | translate }}" description="{{ 'GRANTS.USER.DESCRIPTION' | translate }}">
<cnsl-user-grants
[userId]="user.userId"
@@ -149,7 +147,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'memberships'">
<ng-container *ngIf="currentSetting$().id === 'memberships'">
<cnsl-card
title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}"
description="{{ 'USER.MEMBERSHIPS.DESCRIPTION' | translate }}"
@@ -158,7 +156,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'metadata' && (metadata$ | async) as metadataQuery">
<ng-container *ngIf="currentSetting$().id === 'metadata' && (metadata$ | async) as metadataQuery">
<cnsl-metadata
*ngIf="metadataQuery.state !== 'error'"
[metadata]="metadataQuery.value"

View File

@@ -1,11 +1,11 @@
import { MediaMatcher } from '@angular/cdk/layout';
import { Component, DestroyRef, EventEmitter, OnInit } from '@angular/core';
import { Component, DestroyRef, EventEmitter, OnInit, signal } from '@angular/core';
import { Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Buffer } from 'buffer';
import { defer, EMPTY, fromEvent, mergeWith, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs';
import { defer, EMPTY, 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';
@@ -25,7 +25,7 @@ import { formatPhone } from 'src/app/utils/formatPhone';
import { EditDialogComponent, EditDialogData, EditDialogResult, EditDialogType } from './edit-dialog/edit-dialog.component';
import { LanguagesService } from 'src/app/services/languages.service';
import { Gender, HumanProfile, HumanUser, User, UserState } from '@zitadel/proto/zitadel/user/v2/user_pb';
import { catchError, filter, map, startWith, withLatestFrom } from 'rxjs/operators';
import { catchError, filter, map, startWith } from 'rxjs/operators';
import { pairwiseStartWith } from 'src/app/utils/pairwiseStartWith';
import { NewAuthService } from 'src/app/services/new-auth.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -75,7 +75,7 @@ export class AuthUserDetailComponent implements OnInit {
protected readonly user$: Observable<UserQuery>;
protected readonly metadata$: Observable<MetadataQuery>;
private readonly savedLanguage$: Observable<string>;
protected readonly currentSetting$: Observable<string | undefined>;
protected readonly currentSetting$ = signal<SidenavSetting>(this.settingsList[0]);
protected readonly loginPolicy$: Observable<LoginPolicy>;
protected readonly userName$: Observable<string>;
@@ -86,7 +86,6 @@ export class AuthUserDetailComponent implements OnInit {
private dialog: MatDialog,
private auth: AuthenticationService,
private breadcrumbService: BreadcrumbService,
private mediaMatcher: MediaMatcher,
public langSvc: LanguagesService,
private readonly route: ActivatedRoute,
private readonly newAuthService: NewAuthService,
@@ -95,7 +94,6 @@ export class AuthUserDetailComponent implements OnInit {
private readonly destroyRef: DestroyRef,
private readonly router: Router,
) {
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$);
@@ -150,6 +148,7 @@ export class AuthUserDetailComponent implements OnInit {
]);
}
});
this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => {
if (query.state == 'error') {
this.toast.showError(query.error);
@@ -159,22 +158,16 @@ export class AuthUserDetailComponent implements OnInit {
this.savedLanguage$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((savedLanguage) => this.translate.use(savedLanguage));
}
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)),
);
const param = this.route.snapshot.queryParamMap.get('id');
if (!param) {
return;
}
const setting = this.settingsList.find(({ id }) => id === param);
if (!setting) {
return;
}
this.currentSetting$.set(setting);
}
private getUser$(): Observable<UserQuery> {

View File

@@ -9,12 +9,7 @@
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-refresh-table
[hideRefresh]="true"
[loading]="loading$ | async"
(refreshed)="getMFAs()"
[dataSize]="dataSource.data.length"
>
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" (refreshed)="getMFAs()">
<button
actions
class="button"

View File

@@ -8,13 +8,7 @@
>
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-refresh-table
[hideRefresh]="true"
[loading]="loading$ | async"
[dataSize]="dataSource.data.length"
[timestamp]="viewTimestamp"
[selection]="selection"
>
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" [timestamp]="viewTimestamp" [selection]="selection">
<div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">

View File

@@ -11,7 +11,7 @@
>
<mat-icon class="icon">refresh</mat-icon>
</button>
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async" [dataSize]="dataSource.data.length">
<cnsl-refresh-table [hideRefresh]="true" [loading]="loading$ | async">
<button
actions
[disabled]="disabled"

View File

@@ -52,10 +52,9 @@
<cnsl-meta-layout>
<cnsl-sidenav
*ngIf="settingsList$ | async as settingsList"
[ngModel]="currentSetting$ | async"
(ngModelChange)="goToSetting($event)"
[setting]="currentSetting$()"
(settingChange)="currentSetting$.set($event)"
[settingsList]="settingsList"
queryParam="id"
>
<div class="max-width-container">
<cnsl-info-section class="locked" *ngIf="user?.state === UserState.LOCKED" [type]="InfoSectionType.WARN">
@@ -72,7 +71,7 @@
</cnsl-info-section>
</div>
<ng-container *ngIf="(currentSetting$ | async) === 'general'">
<ng-container *ngIf="currentSetting$().id === 'general'">
<ng-template
*ngIf="humanUser(user) as user"
cnslHasRole
@@ -141,13 +140,13 @@
</ng-container>
<cnsl-external-idps
*ngIf="(currentSetting$ | async) === 'idp' && user.type.case === 'human' && user.userId"
*ngIf="currentSetting$().id === 'idp' && user.type.case === 'human' && user.userId"
[userId]="user.userId"
[service]="mgmtService"
/>
<cnsl-card
*ngIf="(currentSetting$ | async) === 'general' && user.type.case === 'machine'"
*ngIf="currentSetting$().id === 'general' && user.type.case === 'machine'"
title="{{ 'USER.MACHINE.TITLE' | translate }}"
>
<cnsl-detail-form-machine
@@ -158,7 +157,7 @@
/>
</cnsl-card>
<ng-container *ngIf="(currentSetting$ | async) === 'pat'">
<ng-container *ngIf="currentSetting$().id === 'pat'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.userId]">
<cnsl-card
*ngIf="user.type.case === 'machine' && user.userId"
@@ -170,7 +169,7 @@
</ng-template>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'keys'">
<ng-container *ngIf="currentSetting$().id === 'keys'">
<ng-template cnslHasRole [hasRole]="['user.read$', 'user.read:' + user.userId]">
<cnsl-card
*ngIf="user.type.case === 'machine' && user.userId"
@@ -182,7 +181,7 @@
</ng-template>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'security'">
<ng-container *ngIf="currentSetting$().id === 'security'">
<cnsl-card *ngIf="user.type.case === 'human'" title="{{ 'USER.PASSWORD.TITLE' | translate }}">
<div class="contact-method-col">
<div class="contact-method-row">
@@ -214,7 +213,7 @@
<cnsl-user-mfa *ngIf="user.type.case === 'human'" [user]="user"></cnsl-user-mfa>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'grants'">
<ng-container *ngIf="currentSetting$().id === 'grants'">
<cnsl-card
*ngIf="user.userId"
title="{{ 'GRANTS.USER.TITLE' | translate }}"
@@ -239,7 +238,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'memberships'">
<ng-container *ngIf="currentSetting$().id === 'memberships'">
<cnsl-card
*ngIf="user.userId"
title="{{ 'USER.MEMBERSHIPS.TITLE' | translate }}"
@@ -249,7 +248,7 @@
</cnsl-card>
</ng-container>
<ng-container *ngIf="(currentSetting$ | async) === 'metadata' && (metadata$ | async) as metadataQuery">
<ng-container *ngIf="currentSetting$().id === 'metadata' && (metadata$ | async) as metadataQuery">
<cnsl-metadata
*ngIf="user.userId && metadataQuery.state !== 'error'"
[metadata]="metadataQuery.value"

View File

@@ -1,12 +1,11 @@
import { MediaMatcher } from '@angular/cdk/layout';
import { Location } from '@angular/common';
import { Component, DestroyRef, EventEmitter, OnInit } from '@angular/core';
import { Component, DestroyRef, EventEmitter, OnInit, signal } 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 { catchError, filter, map, startWith, tap, withLatestFrom } from 'rxjs/operators';
import { catchError, filter, map, startWith, take } 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';
@@ -39,7 +38,7 @@ import {
combineLatestWith,
defer,
EMPTY,
fromEvent,
identity,
mergeWith,
Observable,
ObservedValueOf,
@@ -79,6 +78,7 @@ type MetadataQuery =
type UserWithHumanType = Omit<UserV2, 'type'> & { type: { case: 'human'; value: HumanUser } };
// todo: figure out why media matcher is needed
@Component({
selector: 'cnsl-user-detail',
templateUrl: './user-detail.component.html',
@@ -98,7 +98,7 @@ export class UserDetailComponent implements OnInit {
public refreshChanges$: EventEmitter<void> = new EventEmitter();
public InfoSectionType: any = InfoSectionType;
public currentSetting$: Observable<string | undefined>;
public currentSetting$ = signal<SidenavSetting>(GENERAL);
public settingsList$: Observable<SidenavSetting[]>;
public metadata$: Observable<MetadataQuery>;
public loginPolicy$: Observable<LoginPolicy>;
@@ -111,7 +111,6 @@ export class UserDetailComponent implements OnInit {
private _location: Location,
private dialog: MatDialog,
private router: Router,
private mediaMatcher: MediaMatcher,
public langSvc: LanguagesService,
private readonly userService: UserService,
private readonly newMgmtService: NewMgmtService,
@@ -126,9 +125,8 @@ 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.settingsList$ = this.getSettingsList$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.metadata$ = this.getMetadata$(this.user$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe(
@@ -177,31 +175,6 @@ export class UserDetailComponent implements OnInit {
);
}
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 async goToSetting(setting: string) {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: { id: setting },
queryParamsHandling: 'merge',
skipLocationChange: true,
});
}
private getUserById(userId: string): Observable<UserQuery> {
return defer(() => this.userService.getUserById(userId)).pipe(
map(({ user }) => {
@@ -244,6 +217,20 @@ export class UserDetailComponent implements OnInit {
this.toast.showError(query.value);
}
});
const param = this.route.snapshot.queryParamMap.get('id');
if (!param) {
return;
}
this.settingsList$
.pipe(
takeUntilDestroyed(this.destroyRef),
map((settings) => settings.find(({ id }) => id === param)),
filter(Boolean),
take(1),
)
.subscribe((setting) => this.currentSetting$.set(setting));
}
public changeUsername(user: UserV2): void {

Some files were not shown because too many files have changed in this diff Show More