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 <[email protected]>
Co-authored-by: Stefan Benz <[email protected]>
Co-authored-by: Silvan <[email protected]>
Co-authored-by: Ramon <[email protected]>
Co-authored-by: Elio Bischof <[email protected]>
Co-authored-by: Kenta Yamaguchi <[email protected]>
Co-authored-by: Harsha Reddy <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
Co-authored-by: Iraq <[email protected]>
Co-authored-by: Florian Forster <[email protected]>
Co-authored-by: Tim Möhlmann <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Fabienne Bühler
2025-04-02 16:53:06 +02:00
committed by GitHub
co-authored by Livio Spring Stefan Benz Silvan Ramon Elio Bischof Kenta Yamaguchi Harsha Reddy Livio Spring Max Peintner Iraq Florian Forster Tim Möhlmann Copilot Max Peintner
parent d14a23ae7e
commit 07ce3b6905
559 changed files with 14578 additions and 7622 deletions
@@ -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>
@@ -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 {
@@ -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>
@@ -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;
}
@@ -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);
}
}
@@ -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>
@@ -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);
}
}
@@ -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>
@@ -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;
}
@@ -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();
});
});
@@ -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,
});
}
}
@@ -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>
@@ -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;
}
@@ -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();
}
}
}
@@ -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>
@@ -0,0 +1,12 @@
.target-description {
margin-bottom: 0;
}
.actions {
display: flex;
justify-content: space-between;
.fill-space {
font: 1;
}
}
@@ -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();
});
});
@@ -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);
}
}
@@ -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>
@@ -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;
}
@@ -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();
});
});
@@ -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);
}
}
@@ -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>
@@ -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;
}
@@ -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,
);
}
}
@@ -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 {}
@@ -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>
@@ -0,0 +1,4 @@
.target-key {
display: flex;
white-space: nowrap;
}
@@ -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)),
);
}
@@ -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>
@@ -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);
});
}
}
@@ -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 {}
@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>
@@ -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>
@@ -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>
@@ -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');
@@ -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;
@@ -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>
@@ -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"
@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>
@@ -1,7 +1,6 @@
<cnsl-refresh-table
*ngIf="dataSource"
(refreshed)="changePage()"
[dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp"
[selection]="selection"
[loading]="dataSource.loading$ | async"
@@ -1,7 +1,6 @@
<cnsl-refresh-table
*ngIf="dataSource"
(refreshed)="changePage()"
[dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp"
[hideRefresh]="true"
[selection]="selection"
@@ -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>
@@ -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>
@@ -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;
@@ -1,7 +1,6 @@
<cnsl-refresh-table
[loading]="loading$ | async"
(refreshed)="refreshPage()"
[dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp"
[selection]="selection"
>
@@ -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>
@@ -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();
}
}
@@ -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>
@@ -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)),
);
}
@@ -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>
@@ -0,0 +1,4 @@
.state.next {
color: #0e6245;
border: 1px solid #0e6245;
}
@@ -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;
}
@@ -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" />
@@ -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);
}
}
@@ -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 {}
@@ -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;
}
}
@@ -2,7 +2,6 @@
[showSelectionActionButton]="showSelectionActionButton"
*ngIf="projectId"
(refreshed)="refreshPage()"
[dataSize]="dataSource.totalResult"
[emitRefreshOnPreviousRoutes]="['/projects/' + projectId + '/roles/create']"
[selection]="selection"
[loading]="dataSource.loading$ | async"
@@ -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[] = [];
@@ -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>
@@ -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);
}
}
@@ -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],
})
@@ -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'],
},
};
@@ -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>
@@ -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;
}
}
@@ -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',
@@ -4,7 +4,6 @@
[hideRefresh]="true"
[emitRefreshOnPreviousRoutes]="refreshOnPreviousRoutes"
[timestamp]="dataSource.viewTimestamp"
[dataSize]="dataSource.totalResult"
[selection]="selection"
>
<button