mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-15 10:25:11 +00:00
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:
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
@@ -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 {
|
||||
|
||||
+75
@@ -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>
|
||||
+12
@@ -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;
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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>
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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>
|
||||
+21
@@ -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;
|
||||
}
|
||||
+20
@@ -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();
|
||||
});
|
||||
});
|
||||
+343
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+28
@@ -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>
|
||||
+18
@@ -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;
|
||||
}
|
||||
+102
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.target-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.fill-space {
|
||||
font: 1;
|
||||
}
|
||||
}
|
||||
+20
@@ -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();
|
||||
});
|
||||
});
|
||||
+155
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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>
|
||||
+20
@@ -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;
|
||||
}
|
||||
+20
@@ -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();
|
||||
});
|
||||
});
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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>
|
||||
+25
@@ -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;
|
||||
}
|
||||
+115
@@ -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 {}
|
||||
+82
@@ -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>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.target-key {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+33
@@ -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)),
|
||||
);
|
||||
}
|
||||
+13
@@ -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>
|
||||
+121
@@ -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"
|
||||
>
|
||||
|
||||
+60
@@ -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>
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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>
|
||||
+23
@@ -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)),
|
||||
);
|
||||
}
|
||||
+57
@@ -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>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.state.next {
|
||||
color: #0e6245;
|
||||
border: 1px solid #0e6245;
|
||||
}
|
||||
+31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user