rehaul havigation

This commit is contained in:
Max Peintner
2025-07-17 13:39:01 +02:00
parent caca43a07f
commit d85c813e3c
32 changed files with 716 additions and 588 deletions

View File

@@ -75,7 +75,7 @@ const routes: Routes = [
loadChildren: () => import('./pages/actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['org.action.read', 'org.flow.read'],
roles: ['iam.read', 'iam.read'],
},
},
{

View File

@@ -5,13 +5,12 @@
*ngIf="
breadc[breadc.length - 1] &&
!breadc[breadc.length - 1].hideNav &&
breadc[breadc.length - 1].type !== BreadcrumbType.AUTHUSER &&
breadc[breadc.length - 1].type !== BreadcrumbType.INSTANCE
breadc[breadc.length - 1].type !== BreadcrumbType.AUTHUSER
"
[ngSwitch]="breadc[0].type"
>
<div class="nav-row" @navrow>
<ng-container *ngSwitchCase="BreadcrumbType.ORG">
<ng-container *ngSwitchCase="BreadcrumbType.INSTANCE">
<div class="nav-row-abs" @navrowproject>
<a
class="nav-item"
@@ -22,6 +21,48 @@
<span class="label">{{ 'MENU.DASHBOARD' | translate }}</span>
</a>
<ng-container class="org-list" *ngIf="org">
<ng-template cnslHasRole [hasRole]="['org.read']">
<a
class="nav-item"
[routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: false }"
[routerLink]="['/orgs']"
>
<span class="label">{{ 'MENU.ORGS' | translate }}</span>
</a>
</ng-template>
<ng-template cnslHasRole [hasRole]="['org.action.read']">
<a
class="nav-item"
[routerLinkActive]="['active']"
[routerLink]="['/actions']"
[routerLinkActiveOptions]="{ exact: false }"
>
<span class="label">{{ 'MENU.ACTIONS' | translate }}</span>
</a>
</ng-template>
<ng-template cnslHasRole [hasRole]="['org.read']">
<a
class="nav-item"
[routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: false }"
[routerLink]="['/instance']"
*ngIf="['policy.read'] | hasRole | async"
>
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
</a>
</ng-template>
</ng-container>
<template [ngTemplateOutlet]="shortcutKeyRef"></template>
</div>
</ng-container>
<ng-container *ngSwitchCase="BreadcrumbType.ORG">
<div class="nav-row-abs" @navrowproject>
<ng-container class="org-list" *ngIf="org">
<ng-template cnslHasRole [hasRole]="['org.read']">
<a

View File

@@ -23,7 +23,8 @@
.nav-row {
position: relative;
width: 100%;
height: 36px;
height: 40px; // Increased height to accommodate the line
overflow: visible; // Allow the indicator line to show below
.nav-row-abs {
padding: 0 2rem;
@@ -32,7 +33,7 @@
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
overflow-y: visible; // Allow the indicator line to show
align-self: stretch;
position: absolute;
top: 0;
@@ -51,19 +52,46 @@
.nav-item {
display: flex;
align-items: center;
font-size: 14px;
line-height: 14px;
padding: 0.4rem 12px;
color: map-get($foreground, text);
transition: all 0.2s ease;
text-decoration: none;
border-radius: 50vw;
font-weight: 500;
margin: 0.25rem 2px;
font-size: 14px;
white-space: nowrap;
position: relative;
height: 36px;
box-sizing: border-box;
height: 27px;
display: flex;
align-items: center;
border: 0;
background: transparent;
border-radius: 6px;
padding: 0 0.5rem;
cursor: pointer;
color: map-get($foreground, text);
opacity: 0.8;
// Active indicator line
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
width: 0;
height: 1px;
background-color: map-get($foreground, text);
transition: width 0.2s ease;
border-radius: 2px;
z-index: 999;
}
&:hover {
opacity: 1;
color: map-get($foreground, text);
background-color: if($is-dark-theme, #ffffff10, #00000010);
}
.c_label {
display: flex;
@@ -97,14 +125,14 @@
}
}
&:hover {
background: if($is-dark-theme, #ffffff40, #00000010);
}
&.active {
background-color: $primary-color;
opacity: 1;
color: map-get($primary, default-contrast);
&::after {
width: 100%; // Full width for testing
}
.c_label {
.count {
display: inline-block;

View File

@@ -1,7 +1,7 @@
<div class="new-header-wrapper">
<ng-container *ngIf="myInstanceQuery.data()?.instance as instance">
<ng-container *ngTemplateOutlet="slash"></ng-container>
<a class="new-header-breadcrumb" matRipple [matRippleUnbounded]="false" [routerLink]="['/instance']">
<a class="new-header-breadcrumb" matRipple [matRippleUnbounded]="false" [routerLink]="['/']">
{{ instance.name }}
</a>
<cnsl-header-button
@@ -37,7 +37,7 @@
class="new-header-breadcrumb"
matRipple
[matRippleUnbounded]="false"
[routerLink]="['/']"
[routerLink]="['/org']"
>
{{ org.name }}
</a>

View File

@@ -1,10 +1,4 @@
<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="setting()?.id === 'features'">
<cnsl-features></cnsl-features>
</ng-container>
@@ -74,12 +68,6 @@
<ng-container *ngIf="setting()?.id === 'failedevents' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-iam-failed-events></cnsl-iam-failed-events>
</ng-container>
<!-- todo: figure out permissions -->
<ng-container *ngIf="setting()?.id === 'actions'">
<cnsl-actions-two-actions />
</ng-container>
<ng-container *ngIf="setting()?.id === 'actions_targets'">
<cnsl-actions-two-targets />
</ng-container>
<ng-content></ng-content>
</cnsl-sidenav>

View File

@@ -1,15 +1,6 @@
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
import { SidenavSetting } from '../sidenav/sidenav.component';
export const ORGANIZATIONS: SidenavSetting = {
id: 'organizations',
i18nKey: 'SETTINGS.LIST.ORGS',
groupI18nKey: 'SETTINGS.GROUPS.GENERAL',
requiredRoles: {
[PolicyComponentServiceType.ADMIN]: ['iam.read'],
},
};
export const FEATURESETTINGS: SidenavSetting = {
id: 'features',
i18nKey: 'SETTINGS.LIST.FEATURESETTINGS',
@@ -222,23 +213,3 @@ export const BRANDING: SidenavSetting = {
[PolicyComponentServiceType.ADMIN]: ['iam.policy.read'],
},
};
export const ACTIONS: SidenavSetting = {
id: 'actions',
i18nKey: 'SETTINGS.LIST.ACTIONS',
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
requiredRoles: {
[PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'],
},
beta: true,
};
export const ACTIONS_TARGETS: SidenavSetting = {
id: 'actions_targets',
i18nKey: 'SETTINGS.LIST.TARGETS',
groupI18nKey: 'SETTINGS.GROUPS.ACTIONS',
requiredRoles: {
[PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'],
},
beta: true,
};

View File

@@ -1,107 +1,14 @@
<div class="max-width-container">
<div class="enlarged-container actions-enlarged-container">
<div class="actions-title-row">
<h1>{{ 'DESCRIPTIONS.ACTIONS.TITLE' | translate }}</h1>
<a mat-icon-button href="https://zitadel.com/docs/concepts/features/actions" rel="noreferrer" target="_blank">
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<cnsl-info-section [type]="InfoSectionType.ALERT">
{{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }}
</cnsl-info-section>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}</p>
<div class="enlarged-container">
<h1>{{ 'ORG.PAGES.LIST' | translate }}</h1>
<p class="org-desc cnsl-secondary-text">{{ 'ORG.PAGES.LISTDESCRIPTION' | translate }}</p>
<cnsl-info-section class="max-actions" *ngIf="maxActions"
>{{ 'FLOWS.ACTIONSMAX' | translate: { value: maxActions } }}
</cnsl-info-section>
<cnsl-meta-layout>
<cnsl-sidenav [(setting)]="currentSetting" [settingsList]="settingsList">
<ng-container *ngIf="currentSetting.id === 'actions'"> actions </ng-container>
<ng-template cnslHasRole [hasRole]="['org.action.read']">
<cnsl-card
title="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.TITLE' | translate }}"
description="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.DESCRIPTION' | translate }}"
>
<cnsl-action-table (changedSelection)="selection = $event"></cnsl-action-table>
</cnsl-card>
</ng-template>
<div class="title-section">
<h2>{{ 'DESCRIPTIONS.ACTIONS.FLOWS.TITLE' | translate }}</h2>
<i class="las la-exchange-alt"></i>
</div>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.FLOWS.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['org.flow.read']">
<div class="actions-flow">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'FLOWS.FLOWTYPE' | translate }}</cnsl-label>
<mat-select [formControl]="typeControl">
<mat-option *ngFor="let type of typesForSelection" [value]="type">
{{ type.name?.localizedMessage }}
</mat-option>
</mat-select>
</cnsl-form-field>
<div *ngIf="flow" class="trigger-wrapper">
<div class="actions-topbottomline"></div>
<div class="flow-type">
<i class="type-icon las la-dot-circle"></i>
<span>{{ flow.type?.name?.localizedMessage }}</span>
<button
*ngIf="flow.type && (flow.triggerActionsList?.length ?? 0) > 0"
matTooltip="{{ 'ACTIONS.CLEAR' | translate }}"
mat-icon-button
color="warn"
(click)="clearFlow(flow.type.id)"
>
<i class="type-button-icon las la-trash"></i>
</button>
</div>
<cnsl-card *ngFor="let trigger of flow.triggerActionsList; index as i" class="trigger">
<div class="trigger-top">
<mat-icon svgIcon="mdi_arrow_right_bottom" class="icon"></mat-icon>
<span>{{ trigger.triggerType?.name?.localizedMessage }}</span>
<span class="fill-space"></span>
<button color="warn" mat-icon-button (click)="removeTriggerActionsList(i)">
<i class="las la-trash"></i>
</button>
</div>
<span class="fill-space"></span>
<div class="flow-action-wrapper" cdkDropList (cdkDropListDropped)="drop(i, trigger.actionsList, $event)">
<div
cdkDrag
cdkDragLockAxis="y"
cdkDragBoundary=".action-wrapper"
class="flow-action"
*ngFor="let action of trigger.actionsList"
>
<i class="las la-code"></i>
<span class="flow-action-name">{{ action.name }}</span>
<span class="fill-space"></span>
<span
class="state"
[ngClass]="{
active: action.state === ActionState.ACTION_STATE_ACTIVE,
inactive: action.state === ActionState.ACTION_STATE_INACTIVE,
}"
>
{{ 'FLOWS.STATES.' + action.state | translate }}</span
>
</div>
</div>
</cnsl-card>
<button *ngIf="flow.type" class="add-btn" mat-raised-button color="primary" (click)="openAddTrigger(flow.type)">
<div class="cnsl-action-button">
<mat-icon>add</mat-icon>
<span>{{ 'FLOWS.ADDTRIGGER' | translate }}</span>
<span *ngIf="selection && selection.length">&nbsp;({{ selection.length }})</span>
</div>
</button>
</div>
</div>
</ng-template>
<ng-container *ngIf="currentSetting.id === 'targets'"> targets </ng-container>
</cnsl-sidenav>
</cnsl-meta-layout>
</div>
</div>

View File

@@ -1,186 +1,7 @@
.actions-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
a {
.icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
}
}
h1 {
margin: 0;
}
@mixin actions-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$is-dark-theme: map-get($theme, is-dark);
$primary: map-get($theme, primary);
$primary-color: map-get($primary, 500);
.actions-enlarged-container {
h1 {
margin: 0;
}
.desc {
margin-bottom: 2rem;
font-size: 14px;
}
.title-section {
display: flex;
align-items: center;
margin-top: 3rem;
margin-bottom: 1rem;
h2 {
margin: 0;
}
i {
margin-left: 0.5rem;
}
}
.actions-flow {
display: flex;
flex-direction: column;
max-width: 1000px;
position: relative;
.formfield {
max-width: 300px;
}
.flow-type {
margin: 0.5rem 0;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 1.5rem;
.type-icon {
color: $primary-color;
}
.type-button-icon,
.type-icon,
span {
margin-right: 1rem;
}
.type-icon,
.type-button-icon {
position: relative;
}
}
.trigger-wrapper {
position: relative;
.trigger {
display: flex;
align-items: center;
position: relative;
.trigger-top {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding-left: 7px;
.fill-space {
flex: 1;
}
}
.icon {
margin-right: 1rem;
color: $primary-color;
}
.fill-space {
flex: 1;
}
.flow-action-wrapper {
padding: 0 0.5rem;
margin: 0;
.flow-action {
display: flex;
align-items: center;
font-size: 14px;
padding: 0.5rem 0;
cursor: move;
.flow-action-name {
margin-right: 1rem;
}
.fill-space {
flex: 1;
}
i {
margin-right: 0.5rem;
}
}
.state {
margin-left: 1rem;
}
}
}
}
.actions-topbottomline {
position: absolute;
top: 26px;
bottom: 1.5rem;
left: 35px;
width: 2px;
z-index: 0;
background-color: $primary-color;
}
.add-btn {
display: flex;
align-items: center;
align-self: flex-start;
margin: 1rem 0;
}
}
.cdk-drag-preview {
color: white;
display: flex;
align-items: center;
font-size: 14px;
border-radius: 0.5rem;
padding: 0 0.5rem;
background-color: $primary-color;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
i {
margin-right: 0.5rem;
}
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
}
.org-desc {
font-size: 14px;
}

View File

@@ -1,19 +1,19 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ActionsComponent } from './actions.component';
import { OrgListComponent } from './actions.component';
describe('ActionsComponent', () => {
let component: ActionsComponent;
let fixture: ComponentFixture<ActionsComponent>;
describe('OrgListComponent', () => {
let component: OrgListComponent;
let fixture: ComponentFixture<OrgListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ActionsComponent],
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [OrgListComponent],
}).compileComponents();
});
}));
beforeEach(() => {
fixture = TestBed.createComponent(ActionsComponent);
fixture = TestBed.createComponent(OrgListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@@ -1,180 +1,28 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, DestroyRef } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb';
import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Component } from '@angular/core';
import { enterAnimations } from 'src/app/animations';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const ACTIONS: SidenavSetting = { id: 'general', i18nKey: 'MENU.ACTIONS' };
const TARGETS: SidenavSetting = { id: 'roles', i18nKey: 'MENU.TARGETS' };
@Component({
selector: 'cnsl-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.scss'],
animations: [enterAnimations],
})
export class ActionsComponent {
protected flow!: Flow.AsObject;
public settingsList: SidenavSetting[] = [ACTIONS, TARGETS];
public currentSetting = this.settingsList[0];
protected typeControl: UntypedFormControl = new UntypedFormControl();
protected typesForSelection: FlowType.AsObject[] = [];
protected selection: Action.AsObject[] = [];
protected InfoSectionType = InfoSectionType;
protected ActionKeysType = ActionKeysType;
protected maxActions: number | null = null;
protected ActionState = ActionState;
constructor(
private mgmtService: ManagementService,
breadcrumbService: BreadcrumbService,
private dialog: MatDialog,
private toast: ToastService,
destroyRef: DestroyRef,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
breadcrumbService.setBreadcrumb([bread]);
this.getFlowTypes().then();
this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
this.loadFlow((value as FlowType.AsObject).id);
});
}
private async getFlowTypes(): Promise<void> {
try {
let resp = await this.mgmtService.listFlowTypes();
this.typesForSelection = resp.resultList;
if (!this.flow && resp.resultList[0]) {
const type = resp.resultList[0];
this.typeControl.setValue(type);
}
} catch (error) {
this.toast.showError(error);
}
}
private loadFlow(id: string) {
this.mgmtService.getFlow(id).then((flowResponse) => {
if (flowResponse.flow) {
this.flow = flowResponse.flow;
}
});
}
public clearFlow(id: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.CLEAR.TITLE',
descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION',
},
width: '400px',
constructor(breadcrumbService: BreadcrumbService) {
const iamBread = new Breadcrumb({
type: BreadcrumbType.INSTANCE,
name: 'Instance',
routerLink: ['/instance'],
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.clearFlow(id)
.then(() => {
this.toast.showInfo('FLOWS.FLOWCLEARED', true);
this.loadFlow(id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void {
const dialogRef = this.dialog.open(AddFlowDialogComponent, {
data: {
flowType: flow,
actions: this.selection && this.selection.length ? this.selection : [],
},
width: '400px',
});
dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => {
if (req) {
this.mgmtService
.setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType())
.then(() => {
this.toast.showInfo('FLOWS.FLOWCHANGED', true);
this.loadFlow(flow.id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop<Action.AsObject[]>) {
moveItemInArray(array, event.previousIndex, event.currentIndex);
this.saveFlow(triggerActionsListIndex);
}
saveFlow(index: number) {
if (
this.flow.type &&
this.flow.triggerActionsList &&
this.flow.triggerActionsList[index] &&
this.flow.triggerActionsList[index]?.triggerType
) {
this.mgmtService
.setTriggerActions(
this.flow.triggerActionsList[index].actionsList.map((action) => action.id),
this.flow.type.id,
this.flow.triggerActionsList[index].triggerType?.id ?? '',
)
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
protected removeTriggerActionsList(index: number) {
if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE',
descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '')
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
this.loadFlow(this.flow?.type?.id ?? '');
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
breadcrumbService.setBreadcrumb([iamBread]);
}
}

View File

@@ -1,68 +1,16 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { FormFieldModule } from 'src/app/modules/form-field/form-field.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { InputModule } from 'src/app/modules/input/input.module';
import { PaginatorModule } from 'src/app/modules/paginator/paginator.module';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
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 { OrgTableModule } from 'src/app/modules/org-table/org-table.module';
import { ActionTableComponent } from './action-table/action-table.component';
import { ActionsRoutingModule } from './actions-routing.module';
import { ActionsComponent } from './actions.component';
import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module';
import { SidenavModule } from 'src/app/modules/sidenav/sidenav.module';
@NgModule({
declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent],
imports: [
CommonModule,
FormsModule,
ActionsRoutingModule,
TranslateModule,
MatDialogModule,
RefreshTableModule,
MatTableModule,
PaginatorModule,
MatButtonModule,
ReactiveFormsModule,
MatIconModule,
DurationToSecondsPipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
HasRoleModule,
ActionKeysModule,
MatTooltipModule,
CardModule,
MatCheckboxModule,
InputModule,
FormFieldModule,
MatSelectModule,
WarnDialogModule,
DragDropModule,
InfoSectionModule,
HasRolePipeModule,
TableActionsModule,
CodemirrorModule,
],
declarations: [ActionsComponent],
imports: [CommonModule, ActionsRoutingModule, OrgTableModule, TranslateModule, MetaLayoutModule, SidenavModule],
exports: [ActionsComponent],
})
export default class ActionsModule {}

View File

@@ -27,8 +27,8 @@ export class HomeComponent {
public themeService: ThemeService,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
type: BreadcrumbType.INSTANCE,
routerLink: ['/'],
};
breadcrumbService.setBreadcrumb([bread]);

View File

@@ -33,10 +33,7 @@ import {
VIEWS,
FAILEDEVENTS,
EVENTS,
ORGANIZATIONS,
FEATURESETTINGS,
ACTIONS,
ACTIONS_TARGETS,
} from 'src/app/modules/settings-list/settings';
import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
@@ -58,10 +55,7 @@ export class InstanceComponent {
protected id: string = '';
protected readonly defaultSettingsList: SidenavSetting[] = [
ORGANIZATIONS,
FEATURESETTINGS,
ACTIONS,
ACTIONS_TARGETS,
// notifications
// { showWarn: true, ...NOTIFICATIONS },
NOTIFICATIONS,

View File

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

View File

@@ -0,0 +1,107 @@
<div class="max-width-container">
<div class="enlarged-container actions-enlarged-container">
<div class="actions-title-row">
<h1>{{ 'DESCRIPTIONS.ACTIONS.TITLE' | translate }}</h1>
<a mat-icon-button href="https://zitadel.com/docs/concepts/features/actions" rel="noreferrer" target="_blank">
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<cnsl-info-section [type]="InfoSectionType.ALERT">
{{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }}
</cnsl-info-section>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}</p>
<cnsl-info-section class="max-actions" *ngIf="maxActions"
>{{ 'FLOWS.ACTIONSMAX' | translate: { value: maxActions } }}
</cnsl-info-section>
<ng-template cnslHasRole [hasRole]="['org.action.read']">
<cnsl-card
title="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.TITLE' | translate }}"
description="{{ 'DESCRIPTIONS.ACTIONS.SCRIPTS.DESCRIPTION' | translate }}"
>
<cnsl-action-table (changedSelection)="selection = $event"></cnsl-action-table>
</cnsl-card>
</ng-template>
<div class="title-section">
<h2>{{ 'DESCRIPTIONS.ACTIONS.FLOWS.TITLE' | translate }}</h2>
<i class="las la-exchange-alt"></i>
</div>
<p class="desc cnsl-secondary-text">{{ 'DESCRIPTIONS.ACTIONS.FLOWS.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['org.flow.read']">
<div class="actions-flow">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'FLOWS.FLOWTYPE' | translate }}</cnsl-label>
<mat-select [formControl]="typeControl">
<mat-option *ngFor="let type of typesForSelection" [value]="type">
{{ type.name?.localizedMessage }}
</mat-option>
</mat-select>
</cnsl-form-field>
<div *ngIf="flow" class="trigger-wrapper">
<div class="actions-topbottomline"></div>
<div class="flow-type">
<i class="type-icon las la-dot-circle"></i>
<span>{{ flow.type?.name?.localizedMessage }}</span>
<button
*ngIf="flow.type && (flow.triggerActionsList?.length ?? 0) > 0"
matTooltip="{{ 'ACTIONS.CLEAR' | translate }}"
mat-icon-button
color="warn"
(click)="clearFlow(flow.type.id)"
>
<i class="type-button-icon las la-trash"></i>
</button>
</div>
<cnsl-card *ngFor="let trigger of flow.triggerActionsList; index as i" class="trigger">
<div class="trigger-top">
<mat-icon svgIcon="mdi_arrow_right_bottom" class="icon"></mat-icon>
<span>{{ trigger.triggerType?.name?.localizedMessage }}</span>
<span class="fill-space"></span>
<button color="warn" mat-icon-button (click)="removeTriggerActionsList(i)">
<i class="las la-trash"></i>
</button>
</div>
<span class="fill-space"></span>
<div class="flow-action-wrapper" cdkDropList (cdkDropListDropped)="drop(i, trigger.actionsList, $event)">
<div
cdkDrag
cdkDragLockAxis="y"
cdkDragBoundary=".action-wrapper"
class="flow-action"
*ngFor="let action of trigger.actionsList"
>
<i class="las la-code"></i>
<span class="flow-action-name">{{ action.name }}</span>
<span class="fill-space"></span>
<span
class="state"
[ngClass]="{
active: action.state === ActionState.ACTION_STATE_ACTIVE,
inactive: action.state === ActionState.ACTION_STATE_INACTIVE,
}"
>
{{ 'FLOWS.STATES.' + action.state | translate }}</span
>
</div>
</div>
</cnsl-card>
<button *ngIf="flow.type" class="add-btn" mat-raised-button color="primary" (click)="openAddTrigger(flow.type)">
<div class="cnsl-action-button">
<mat-icon>add</mat-icon>
<span>{{ 'FLOWS.ADDTRIGGER' | translate }}</span>
<span *ngIf="selection && selection.length">&nbsp;({{ selection.length }})</span>
</div>
</button>
</div>
</div>
</ng-template>
</div>
</div>

View File

@@ -0,0 +1,186 @@
.actions-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
a {
.icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
}
}
}
@mixin actions-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$is-dark-theme: map-get($theme, is-dark);
$primary: map-get($theme, primary);
$primary-color: map-get($primary, 500);
.actions-enlarged-container {
h1 {
margin: 0;
}
.desc {
margin-bottom: 2rem;
font-size: 14px;
}
.title-section {
display: flex;
align-items: center;
margin-top: 3rem;
margin-bottom: 1rem;
h2 {
margin: 0;
}
i {
margin-left: 0.5rem;
}
}
.actions-flow {
display: flex;
flex-direction: column;
max-width: 1000px;
position: relative;
.formfield {
max-width: 300px;
}
.flow-type {
margin: 0.5rem 0;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 1.5rem;
.type-icon {
color: $primary-color;
}
.type-button-icon,
.type-icon,
span {
margin-right: 1rem;
}
.type-icon,
.type-button-icon {
position: relative;
}
}
.trigger-wrapper {
position: relative;
.trigger {
display: flex;
align-items: center;
position: relative;
.trigger-top {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding-left: 7px;
.fill-space {
flex: 1;
}
}
.icon {
margin-right: 1rem;
color: $primary-color;
}
.fill-space {
flex: 1;
}
.flow-action-wrapper {
padding: 0 0.5rem;
margin: 0;
.flow-action {
display: flex;
align-items: center;
font-size: 14px;
padding: 0.5rem 0;
cursor: move;
.flow-action-name {
margin-right: 1rem;
}
.fill-space {
flex: 1;
}
i {
margin-right: 0.5rem;
}
}
.state {
margin-left: 1rem;
}
}
}
}
.actions-topbottomline {
position: absolute;
top: 26px;
bottom: 1.5rem;
left: 35px;
width: 2px;
z-index: 0;
background-color: $primary-color;
}
.add-btn {
display: flex;
align-items: center;
align-self: flex-start;
margin: 1rem 0;
}
}
.cdk-drag-preview {
color: white;
display: flex;
align-items: center;
font-size: 14px;
border-radius: 0.5rem;
padding: 0 0.5rem;
background-color: $primary-color;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
i {
margin-right: 0.5rem;
}
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
}
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActionsComponent } from './actions.component';
describe('ActionsComponent', () => {
let component: ActionsComponent;
let fixture: ComponentFixture<ActionsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ActionsComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ActionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,180 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, DestroyRef } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb';
import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.scss'],
})
export class ActionsComponent {
protected flow!: Flow.AsObject;
protected typeControl: UntypedFormControl = new UntypedFormControl();
protected typesForSelection: FlowType.AsObject[] = [];
protected selection: Action.AsObject[] = [];
protected InfoSectionType = InfoSectionType;
protected ActionKeysType = ActionKeysType;
protected maxActions: number | null = null;
protected ActionState = ActionState;
constructor(
private mgmtService: ManagementService,
breadcrumbService: BreadcrumbService,
private dialog: MatDialog,
private toast: ToastService,
destroyRef: DestroyRef,
) {
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
breadcrumbService.setBreadcrumb([bread]);
this.getFlowTypes().then();
this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
this.loadFlow((value as FlowType.AsObject).id);
});
}
private async getFlowTypes(): Promise<void> {
try {
let resp = await this.mgmtService.listFlowTypes();
this.typesForSelection = resp.resultList;
if (!this.flow && resp.resultList[0]) {
const type = resp.resultList[0];
this.typeControl.setValue(type);
}
} catch (error) {
this.toast.showError(error);
}
}
private loadFlow(id: string) {
this.mgmtService.getFlow(id).then((flowResponse) => {
if (flowResponse.flow) {
this.flow = flowResponse.flow;
}
});
}
public clearFlow(id: string): void {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.CLEAR.TITLE',
descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.clearFlow(id)
.then(() => {
this.toast.showInfo('FLOWS.FLOWCLEARED', true);
this.loadFlow(id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void {
const dialogRef = this.dialog.open(AddFlowDialogComponent, {
data: {
flowType: flow,
actions: this.selection && this.selection.length ? this.selection : [],
},
width: '400px',
});
dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => {
if (req) {
this.mgmtService
.setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType())
.then(() => {
this.toast.showInfo('FLOWS.FLOWCHANGED', true);
this.loadFlow(flow.id);
})
.catch((error: any) => {
this.toast.showError(error);
});
}
});
}
drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop<Action.AsObject[]>) {
moveItemInArray(array, event.previousIndex, event.currentIndex);
this.saveFlow(triggerActionsListIndex);
}
saveFlow(index: number) {
if (
this.flow.type &&
this.flow.triggerActionsList &&
this.flow.triggerActionsList[index] &&
this.flow.triggerActionsList[index]?.triggerType
) {
this.mgmtService
.setTriggerActions(
this.flow.triggerActionsList[index].actionsList.map((action) => action.id),
this.flow.type.id,
this.flow.triggerActionsList[index].triggerType?.id ?? '',
)
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
protected removeTriggerActionsList(index: number) {
if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) {
const dialogRef = this.dialog.open(WarnDialogComponent, {
data: {
confirmKey: 'ACTIONS.CLEAR',
cancelKey: 'ACTIONS.CANCEL',
titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE',
descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((resp) => {
if (resp) {
this.mgmtService
.setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '')
.then(() => {
this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true);
this.loadFlow(this.flow?.type?.id ?? '');
})
.catch((error) => {
this.toast.showError(error);
});
}
});
}
}
}

View File

@@ -0,0 +1,68 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { FormFieldModule } from 'src/app/modules/form-field/form-field.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { InputModule } from 'src/app/modules/input/input.module';
import { PaginatorModule } from 'src/app/modules/paginator/paginator.module';
import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module';
import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
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 { ActionTableComponent } from './action-table/action-table.component';
import { ActionsRoutingModule } from './actions-routing.module';
import { ActionsComponent } from './actions.component';
import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component';
import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component';
@NgModule({
declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent],
imports: [
CommonModule,
FormsModule,
ActionsRoutingModule,
TranslateModule,
MatDialogModule,
RefreshTableModule,
MatTableModule,
PaginatorModule,
MatButtonModule,
ReactiveFormsModule,
MatIconModule,
DurationToSecondsPipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
HasRoleModule,
ActionKeysModule,
MatTooltipModule,
CardModule,
MatCheckboxModule,
InputModule,
FormFieldModule,
MatSelectModule,
WarnDialogModule,
DragDropModule,
InfoSectionModule,
HasRolePipeModule,
TableActionsModule,
CodemirrorModule,
],
})
export default class ActionsModule {}

View File

@@ -37,7 +37,7 @@
@import 'src/app/pages/users/user-list/user-table/user-table.component';
@import 'src/app/pages/users/user-detail/contact/contact.component';
@import 'src/app/pages/projects/project-grid/project-grid.component';
@import 'src/app/pages/actions/actions.component';
@import 'src/app/pages/org-actions/actions.component';
@import 'src/app/app.component.scss';
@import './styles/color.scss';
@import 'src/app/pages/instance/instance.component.scss';