WIP: chore(ci): test nx

This commit is contained in:
Florian Forster
2025-07-30 16:05:36 -07:00
parent 82e4466928
commit 74efccb9cc
2799 changed files with 2067 additions and 2971 deletions

View File

@@ -0,0 +1,13 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

40
apps/console/.eslintrc.js Normal file
View File

@@ -0,0 +1,40 @@
module.exports = {
root: true,
ignorePatterns: ['projects/**/*'],
overrides: [
{
files: ['*.ts'],
parserOptions: {
project: ['tsconfig.json', 'e2e/tsconfig.json'],
createDefaultProgram: true,
tsconfigRootDir: __dirname,
},
extends: ['plugin:@angular-eslint/recommended', 'plugin:@angular-eslint/template/process-inline-templates'],
rules: {
'@angular-eslint/no-conflicting-lifecycle': 'off',
'@angular-eslint/no-host-metadata-property': 'off',
'@angular-eslint/component-selector': [
'error',
{
prefix: 'cnsl',
style: 'kebab-case',
type: 'element',
},
],
'@angular-eslint/directive-selector': [
'error',
{
prefix: 'cnsl',
style: 'camelCase',
type: 'attribute',
},
],
},
},
{
files: ['*.html'],
extends: ['plugin:@angular-eslint/template/recommended'],
rules: {},
},
],
};

51
apps/console/.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
dist/
tmp/
out-tsc/
# dependencies
node_modules/
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
pnpm-debug.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.vscode/settings.json
# Proto generated js files
src/app/proto

View File

@@ -0,0 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# grpc output
/src/app/proto
# dev environment
src/assets/environment.json

5
apps/console/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 125,
"singleQuote": true,
"trailingComma": "all"
}

137
apps/console/README.md Normal file
View File

@@ -0,0 +1,137 @@
# Console Angular App
This is the ZITADEL Console Angular application.
## Development
### Prerequisites
- Node.js 18 or later
- pnpm (latest)
### Installation
```bash
pnpm install
```
### Proto Generation
The Console app uses **dual proto generation** with Turbo dependency management:
1. **`@zitadel/proto` generation**: Modern ES modules with `@bufbuild/protobuf` for v2 APIs
2. **Local `buf.gen.yaml` generation**: Traditional protobuf JavaScript classes for v1 APIs
The Console app's `turbo.json` ensures that `@zitadel/proto#generate` runs before the Console's own generation, providing both:
- Modern schemas from `@zitadel/proto` (e.g., `UserSchema`, `DetailsSchema`)
- Legacy classes from `src/app/proto/generated` (e.g., `User`, `Project`)
Generated files:
- **`@zitadel/proto`**: Modern ES modules in `login/packages/zitadel-proto/`
- **Local generation**: Traditional protobuf files in `src/app/proto/generated/`
- TypeScript definition files (`.d.ts`)
- JavaScript files (`.js`)
- gRPC client files (`*ServiceClientPb.ts`)
- OpenAPI/Swagger JSON files (`.swagger.json`)
To generate proto files:
```bash
pnpm run generate
```
This automatically runs both generations in the correct order via Turbo dependencies.
### Development Server
To start the development server:
```bash
pnpm start
```
This will:
1. Fetch the environment configuration from the server
2. Serve the app on the default port
### Building
To build for production:
```bash
pnpm run build
```
This will:
1. Generate proto files (via `prebuild` script)
2. Build the Angular app with production optimizations
### Linting
To run linting and formatting checks:
```bash
pnpm run lint
```
To auto-fix formatting issues:
```bash
pnpm run lint:fix
```
## Project Structure
- `src/app/proto/generated/` - Generated proto files (Angular-specific format)
- `buf.gen.yaml` - Local proto generation configuration
- `turbo.json` - Turbo dependency configuration for proto generation
- `prebuild.development.js` - Development environment configuration script
## Proto Generation Details
The Console app uses **dual proto generation** managed by Turbo dependencies:
### Dependency Chain
The Console app has the following build dependencies managed by Turbo:
1. `@zitadel/proto#generate` - Generates modern protobuf files
2. `@zitadel/client#build` - Builds the TypeScript gRPC client library
3. `console#generate` - Generates Console-specific protobuf files
4. `console#build` - Builds the Angular application
This ensures that the Console always has access to the latest client library and protobuf definitions.
### Legacy v1 API (Traditional Protobuf)
- Uses local `buf.gen.yaml` configuration
- Generates traditional Google protobuf JavaScript classes extending `jspb.Message`
- Uses plugins: `protocolbuffers/js`, `grpc/web`, `grpc-ecosystem/openapiv2`
- Output: `src/app/proto/generated/`
- Used for: Most existing Console functionality
### Modern v2 API (ES Modules)
- Uses `@zitadel/proto` package generation
- Generates modern ES modules with `@bufbuild/protobuf`
- Uses plugin: `@bufbuild/es` with ES modules and JSON types
- Output: `login/packages/zitadel-proto/`
- Used for: New user v2 API and services
### Dependency Management
The Console's `turbo.json` ensures proper execution order:
1. `@zitadel/proto#generate` runs first (modern ES modules)
2. Console's local generation runs second (traditional protobuf)
3. Build/lint/start tasks depend on both generations being complete
This approach allows the Console app to use both v1 and v2 APIs while maintaining proper build dependencies.
## Legacy Information
This project was originally generated with Angular CLI version 8.3.20 and has been updated over time.

130
apps/console/angular.json Normal file
View File

@@ -0,0 +1,130 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"console": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "cnsl",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/console",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{ "glob": "**/*", "input": "../docs/static/img/tech", "output": "assets/docs/img/tech" }
],
"styles": ["src/styles.scss"],
"scripts": ["./node_modules/tinycolor2/dist/tinycolor-min.js"],
"stylePreprocessorOptions": {
"includePaths": ["node_modules"]
},
"allowedCommonJsDependencies": [
"opentype.js",
"fast-sha256",
"buffer",
"moment",
"grpc-web",
"@angular/common/locales/de",
"codemirror/mode/javascript/javascript",
"codemirror/mode/xml/xml",
"file-saver",
"qrcode",
"codemirror"
]
},
"configurations": {
"production": {
"optimization": {
"fonts": {
"inline": false
},
"styles": {
"inlineCritical": false,
"minify": false
}
},
"budgets": [
{
"type": "initial",
"maximumWarning": "8mb",
"maximumError": "10mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "development"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "console:build:production"
},
"development": {
"browserTarget": "console:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "console:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
},
"cli": {
"analytics": false,
"schematicCollections": ["@angular-eslint/schematics"]
}
}

14
apps/console/buf.gen.yaml Normal file
View File

@@ -0,0 +1,14 @@
# buf.gen.yaml
version: v1
managed:
enabled: true
plugins:
- plugin: buf.build/protocolbuffers/js
out: src/app/proto/generated
opt: import_style=commonjs,binary
- plugin: buf.build/grpc/web
out: src/app/proto/generated
opt: import_style=typescript,mode=grpcweb
- plugin: buf.build/grpc-ecosystem/openapiv2
out: src/app/proto/generated
opt: allow_delete_body

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function(config) {
config.set({
basePath: '',
frameworks: [ 'jasmine', '@angular-devkit/build-angular' ],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/console'),
reports: [ 'html', 'lcovonly', 'text-summary' ],
fixWebpackSourcePaths: true
},
reporters: [ 'progress', 'kjhtml' ],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: [ 'Chrome' ],
singleRun: false,
restartOnFileChange: true
});
};

View File

@@ -0,0 +1,45 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/assets/i18n/**",
"/*.css",
"/*.js"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"!/assets/i18n/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
],
"dataGroups": [
{
"name": "api-freshness",
"urls": [
"/GetMyzitadelPermissions"
],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "1d",
"timeout": "0s"
}
}
]
}

113
apps/console/package.json Normal file
View File

@@ -0,0 +1,113 @@
{
"name": "@zitadel/console",
"private": true,
"scripts": {
"ng": "ng",
"dev": "node prebuild.development.js && ng serve",
"start": "node prebuild.development.js && ng serve",
"build": "ng build --configuration production --base-href=/ui/console/",
"lint": "pnpm run '/^lint:check:.*$/'",
"lint:check:ng": "ng lint",
"lint:check:prettier": "prettier --check src",
"lint:fix": "prettier --write src",
"generate": "pnpm exec buf generate ../../proto --include-imports --include-wkt",
"clean": "rm -rf dist .angular .turbo node_modules src/app/proto/generated"
},
"nx": {
"targets": {
"generate": {
"outputs": [
"{projectRoot}/src/app/proto/generated/**"
]
},
"build": {
"outputs": [
"{projectRoot}/dist/**"
],
"dependsOn": [
"generate"
]
}
}
},
"dependencies": {
"@angular/animations": "^16.2.12",
"@angular/cdk": "^16.2.14",
"@angular/common": "^16.2.12",
"@angular/compiler": "^16.2.12",
"@angular/core": "^16.2.12",
"@angular/forms": "^16.2.12",
"@angular/material": "^16.2.14",
"@angular/material-moment-adapter": "^16.2.14",
"@angular/platform-browser": "^16.2.12",
"@angular/platform-browser-dynamic": "^16.2.12",
"@angular/router": "^16.2.12",
"@angular/service-worker": "^16.2.12",
"@bufbuild/protobuf": "^2.6.1",
"@connectrpc/connect": "^2.0.0",
"@connectrpc/connect-web": "^2.0.0",
"@ctrl/ngx-codemirror": "^6.1.0",
"@fortawesome/angular-fontawesome": "^0.13.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@ngx-translate/core": "^15.0.0",
"@zitadel/client": "workspace:*",
"@zitadel/proto": "workspace:*",
"angular-oauth2-oidc": "^15.0.1",
"angularx-qrcode": "^16.0.2",
"buffer": "^6.0.3",
"codemirror": "^5.65.19",
"file-saver": "^2.0.5",
"flag-icons": "^7.3.2",
"google-protobuf": "^3.21.4",
"grpc-web": "^1.5.0",
"i18n-iso-countries": "^7.14.0",
"libphonenumber-js": "^1.12.6",
"material-design-icons-iconfont": "^6.7.0",
"moment": "^2.30.1",
"ngx-color": "^9.0.0",
"opentype.js": "^1.3.4",
"posthog-js": "^1.232.7",
"rxjs": "^7.8.2",
"tinycolor2": "^1.6.0",
"tslib": "^2.7.0",
"uuid": "^10.0.0",
"zone.js": "~0.13.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.2.2",
"@angular-eslint/builder": "18.3.0",
"@angular-eslint/eslint-plugin": "18.0.0",
"@angular-eslint/eslint-plugin-template": "18.0.0",
"@angular-eslint/schematics": "16.2.0",
"@angular-eslint/template-parser": "18.3.0",
"@angular/cli": "^16.2.15",
"@angular/compiler-cli": "^16.2.5",
"@angular/language-service": "^18.2.4",
"@bufbuild/buf": "^1.55.1",
"@netlify/framework-info": "^9.8.13",
"@types/file-saver": "^2.0.7",
"@types/google-protobuf": "^3.15.3",
"@types/jasmine": "~5.1.4",
"@types/jasminewd2": "~2.0.13",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.5.5",
"@types/opentype.js": "^1.3.8",
"@types/qrcode": "^1.5.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.57.1",
"jasmine-core": "~5.6.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-coverage-istanbul-reporter": "^3.0.3",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.1.0",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "5.1"
}
}

View File

@@ -0,0 +1,28 @@
var fs = require('fs');
var path = require('path')
var http = require('http');
var https = require('https');
var urlModule = require('url');
var defaultEnvironmentJsonURL = 'http://localhost:8080/ui/console/assets/environment.json'
var devEnvFile = path.join(__dirname, "src", "assets", "environment.json")
var url = process.env["ENVIRONMENT_JSON_URL"] || defaultEnvironmentJsonURL;
var protocol = urlModule.parse(url).protocol;
var getter = protocol === 'https:' ? https.get : http.get;
getter(url, function (res) {
var body = '';
res.on('data', function (chunk) {
body += chunk;
});
res.on('end', function () {
fs.writeFileSync(devEnvFile, body);
console.log("Developing against the following environment")
console.log(JSON.stringify(JSON.parse(body), null, 4))
});
}).on('error', function (e) {
console.error("Got an error: ", e);
});

View File

@@ -0,0 +1,191 @@
import {
animate,
animateChild,
AnimationTriggerMetadata,
group,
query,
stagger,
style,
transition,
trigger,
} from '@angular/animations';
export const toolbarAnimation: AnimationTriggerMetadata = trigger('toolbar', [
transition(':enter', [
style({
transform: 'translateY(-100%)',
opacity: 0,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(0%)',
opacity: 1,
}),
),
]),
]);
export const adminLineAnimation: AnimationTriggerMetadata = trigger('adminline', [
transition(':enter', [
style({
transform: 'translateY(100%)',
opacity: 0.5,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(0%)',
opacity: 1,
}),
),
]),
]);
export const accountCard: AnimationTriggerMetadata = trigger('accounts', [
transition(':enter', [
style({
transform: 'scale(.9) translateY(-10%)',
height: '200px',
opacity: 0,
}),
animate(
'.1s ease-out',
style({
transform: 'scale(1) translateY(0%)',
height: '*',
opacity: 1,
}),
),
]),
]);
export const navAnimations: Array<AnimationTriggerMetadata> = [
trigger('navAnimation', [transition('* => *', [query('@navitem', stagger('50ms', animateChild()), { optional: true })])]),
trigger('navitem', [
transition(':enter', [
style({
opacity: 0,
}),
animate(
'.0s',
style({
opacity: 1,
}),
),
]),
transition(':leave', [
style({
opacity: 1,
}),
animate(
'.0s',
style({
opacity: 0,
}),
),
]),
]),
];
export const enterAnimations: Array<AnimationTriggerMetadata> = [
trigger('appearfade', [
transition(':enter', [
style({
transform: 'scale(.9) translateY(-10%)',
opacity: 0,
}),
animate(
'100ms ease-in-out',
style({
transform: 'scale(1) translateY(0%)',
opacity: 1,
}),
),
]),
transition(':leave', [
style({
transform: 'scale(1) translateY(0%)',
opacity: 1,
}),
animate(
'100ms ease-in-out',
style({
transform: 'scale(.9) translateY(-10%)',
opacity: 0,
}),
),
]),
]),
];
export const routeAnimations: AnimationTriggerMetadata = trigger('routeAnimations', [
transition('HomePage => AddPage', [
style({ transform: 'translateX(30vw)', opacity: 0 }),
animate('250ms ease-out', style({ transform: 'translateX(0%)', opacity: 1 })),
]),
transition('AddPage => HomePage', [animate('250ms', style({ transform: 'translateX(30vw)', opacity: 0 }))]),
transition('HomePage => DetailPage', [
query(':enter, :leave', style({ position: 'absolute', left: 0, right: 0 }), {
optional: true,
}),
group([
query(
':enter',
[
style({
transform: 'translateX(20%)',
opacity: 0,
}),
animate(
'.35s ease-in',
style({
transform: 'translateX(0%)',
opacity: 1,
}),
),
],
{
optional: true,
},
),
query(':leave', [style({ opacity: 1, width: '100%' }), animate('.35s ease-out', style({ opacity: 0 }))], {
optional: true,
}),
]),
]),
transition('DetailPage => HomePage', [
query(':enter, :leave', style({ position: 'absolute', left: 0, right: 0 }), {
optional: true,
}),
group([
query(
':enter',
[
style({
opacity: 0,
}),
animate(
'.35s ease-out',
style({
opacity: 1,
}),
),
],
{
optional: true,
},
),
query(
':leave',
[
style({ width: '100%', transform: 'translateX(0%)' }),
animate('.35s ease-in', style({ transform: 'translateX(30%)', opacity: 0 })),
],
{
optional: true,
},
),
]),
]),
]);

View File

@@ -0,0 +1,150 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { roleGuard } from './guards/role-guard';
import { UserGrantContext } from './modules/user-grants/user-grants-datasource';
import { OrgCreateComponent } from './pages/org-create/org-create.component';
const routes: Routes = [
{
path: '',
loadChildren: () => import('./pages/home/home.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['.'],
},
},
{
path: 'signedout',
loadChildren: () => import('./pages/signedout/signedout.module'),
},
{
path: 'orgs/create',
component: OrgCreateComponent,
canActivate: [authGuard, roleGuard],
data: {
roles: ['(org.create)?(iam.write)?'],
},
loadChildren: () => import('./pages/org-create/org-create.module'),
},
{
path: 'orgs',
loadChildren: () => import('./pages/org-list/org-list.module'),
canActivate: [authGuard],
},
{
path: 'granted-projects',
loadChildren: () => import('./pages/projects/granted-projects/granted-projects.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['project.grant.read'],
},
},
{
path: 'projects',
loadChildren: () => import('./pages/projects/projects.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['project.read'],
},
},
{
path: 'users',
canActivate: [authGuard],
loadChildren: () => import('src/app/pages/users/users.module'),
},
{
path: 'instance',
loadChildren: () => import('./pages/instance/instance.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['iam.read', 'iam.write'],
},
},
{
path: 'org',
loadChildren: () => import('./pages/orgs/org.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['org.read'],
},
},
{
path: 'actions',
loadChildren: () => import('./pages/actions/actions.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['org.action.read', 'org.flow.read'],
},
},
{
path: 'grants',
loadChildren: () => import('./pages/grants/grants.module'),
canActivate: [authGuard, roleGuard],
data: {
context: UserGrantContext.NONE,
roles: ['user.grant.read'],
},
},
{
path: 'grant-create',
canActivate: [authGuard],
children: [
{
path: 'project/:projectid/grant/:grantid',
loadChildren: () => import('src/app/pages/user-grant-create/user-grant-create.module'),
canActivate: [roleGuard],
data: {
roles: ['user.grant.write'],
},
},
{
path: 'project/:projectid',
loadChildren: () => import('src/app/pages/user-grant-create/user-grant-create.module'),
canActivate: [roleGuard],
data: {
roles: ['user.grant.write'],
},
},
{
path: 'user/:userid',
loadChildren: () => import('src/app/pages/user-grant-create/user-grant-create.module'),
canActivate: [roleGuard],
data: {
roles: ['user.grant.write'],
},
},
{
path: '',
loadChildren: () => import('src/app/pages/user-grant-create/user-grant-create.module'),
canActivate: [roleGuard],
data: {
roles: ['user.grant.write'],
},
},
],
},
{
path: 'org-settings',
loadChildren: () => import('./pages/org-settings/org-settings.module'),
canActivate: [authGuard, roleGuard],
data: {
roles: ['policy.read'],
},
},
{
path: '**',
redirectTo: '/',
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,27 @@
<div class="main-container">
<ng-container *ngIf="authService.user | async as user">
<cnsl-header
[org]="org"
[user]="user"
[isDarkTheme]="componentCssClass === 'dark-theme'"
(changedActiveOrg)="changedOrg($event)"
></cnsl-header>
<cnsl-nav
id="mainnav"
class="nav"
[ngClass]="{ shadow: yoffset > 60 }"
[org]="org"
[user]="user"
[isDarkTheme]="componentCssClass === 'dark-theme'"
></cnsl-nav>
</ng-container>
<div class="router-container" [@routeAnimations]="prepareRoute(outlet)">
<div class="outlet">
<router-outlet class="outlet" #outlet="outlet"></router-outlet>
</div>
</div>
<span class="fill-space"></span>
<cnsl-footer></cnsl-footer>
</div>

View File

@@ -0,0 +1,57 @@
@mixin main-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$is-dark-theme: map-get($theme, is-dark);
.main-container {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100%;
position: relative;
.router-container {
padding: 0 2rem 50px 2rem;
@media only screen and (max-width: 500px) {
padding: 0 1rem;
}
.outlet {
margin: 0 auto;
}
}
.nav {
position: sticky;
top: 0;
right: 0;
left: 0;
background-color: map-get($background, toolbar);
backdrop-filter: blur(10px);
border-bottom: 1px solid map-get($foreground, divider);
z-index: 50;
transform: all 0.2s ease;
@-moz-document url-prefix() {
background-color: map-get($background, moz-toolbar);
backdrop-filter: none;
}
&.shadow {
box-shadow: 0 0 15px 0 rgb(0 0 0 / 10%);
}
}
.fill-space {
flex: 1;
}
}
}

View File

@@ -0,0 +1,32 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
declarations: [AppComponent],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'console'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('console');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('console app is running!');
});
});

View File

@@ -0,0 +1,326 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay';
import { DOCUMENT, ViewportScroller } from '@angular/common';
import { Component, DestroyRef, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
import { Observable, of, Subject, switchMap } from 'rxjs';
import { filter, map, startWith, takeUntil, tap } from 'rxjs/operators';
import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations';
import { Org } from './proto/generated/zitadel/org_pb';
import { PrivacyPolicy } from './proto/generated/zitadel/policy_pb';
import { AuthenticationService } from './services/authentication.service';
import { GrpcAuthService } from './services/grpc-auth.service';
import { KeyboardShortcutsService } from './services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from './services/mgmt.service';
import { ThemeService } from './services/theme.service';
import { UpdateService } from './services/update.service';
import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language';
import { PosthogService } from './services/posthog.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation],
})
export class AppComponent {
@ViewChild('drawer') public drawer!: MatDrawer;
public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => {
return result.matches;
}),
);
@HostBinding('class') public componentCssClass: string = 'dark-theme';
public yoffset: number = 0;
@HostListener('window:scroll', ['$event']) onScroll(event: Event): void {
this.yoffset = this.viewPortScroller.getScrollPosition()[1];
}
public org!: Org.AsObject;
public orgs$: Observable<Org.AsObject[]> = of([]);
public showAccount: boolean = false;
public isDarkTheme: Observable<boolean> = of(true);
public showProjectSection: boolean = false;
public language: string = 'en';
public privacyPolicy!: PrivacyPolicy.AsObject;
constructor(
@Inject('windowObject') public window: Window,
public viewPortScroller: ViewportScroller,
public translate: TranslateService,
public authenticationService: AuthenticationService,
public authService: GrpcAuthService,
private breakpointObserver: BreakpointObserver,
public overlayContainer: OverlayContainer,
private themeService: ThemeService,
public mgmtService: ManagementService,
public matIconRegistry: MatIconRegistry,
public domSanitizer: DomSanitizer,
private router: Router,
update: UpdateService,
keyboardShortcuts: KeyboardShortcutsService,
private activatedRoute: ActivatedRoute,
@Inject(DOCUMENT) private document: Document,
private posthog: PosthogService,
private readonly destroyRef: DestroyRef,
) {
console.log(
'%cWait!',
'text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; color: #5469D4; font-size: 50px',
);
console.log(
'%cInserting something here could give attackers access to your zitadel account.',
'color: red; font-size: 18px',
);
console.log(
"%cIf you don't know exactly what you're doing, close the window and stay on the safe side",
'font-size: 16px',
);
console.log('%cIf you know exactly what you are doing, you should work for us', 'font-size: 16px');
this.setLanguage();
this.matIconRegistry.addSvgIcon(
'mdi_account_check_outline',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/account-check-outline.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_account_cancel',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/account-cancel-outline.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_light_on',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/lightbulb-on-outline.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_content_copy',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/content-copy.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_light_off',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/lightbulb-off-outline.svg'),
);
this.matIconRegistry.addSvgIcon(
'usb',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/usb-flash-drive-outline.svg'),
);
this.matIconRegistry.addSvgIcon('mdi_radar', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/radar.svg'));
this.matIconRegistry.addSvgIcon(
'mdi_lock_question',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/lock-question.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_textbox_password',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/textbox-password.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_lock_reset',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/lock-reset.svg'),
);
this.matIconRegistry.addSvgIcon('mdi_broom', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/broom.svg'));
this.matIconRegistry.addSvgIcon(
'mdi_pin_outline',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/pin-outline.svg'),
);
this.matIconRegistry.addSvgIcon('mdi_pin', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/pin.svg'));
this.matIconRegistry.addSvgIcon(
'mdi_format-letter-case-lower',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/format-letter-case-lower.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_format-letter-case-upper',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/format-letter-case-upper.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_counter',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/counter.svg'),
);
this.matIconRegistry.addSvgIcon('mdi_openid', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/openid.svg'));
this.matIconRegistry.addSvgIcon('mdi_jwt', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/jwt.svg'));
this.matIconRegistry.addSvgIcon('mdi_smtp', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/mail.svg'));
this.matIconRegistry.addSvgIcon('mdi_symbol', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/symbol.svg'));
this.matIconRegistry.addSvgIcon(
'mdi_shield_alert',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/shield-alert.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_shield_check',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/shield-check.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_arrow_expand',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/arrow-expand.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_numeric',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/numeric.svg'),
);
this.matIconRegistry.addSvgIcon('mdi_api', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/api.svg'));
this.matIconRegistry.addSvgIcon(
'mdi_arrow_right_bottom',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/arrow-right-bottom.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_arrow_decision',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/arrow-decision-outline.svg'),
);
this.getProjectCount();
this.authService.activeOrgChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((org) => {
if (org) {
this.org = org;
this.getProjectCount();
}
});
this.activatedRoute.queryParamMap
.pipe(
map((params) => params.get('org')),
filter(Boolean),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((org) => this.authService.getActiveOrg(org));
this.authenticationService.authenticationChanged
.pipe(
filter(Boolean),
switchMap(() => this.authService.getActiveOrg()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (org) => (this.org = org),
error: async (err) => {
console.error(err);
return this.router.navigate(['/users/me']);
},
});
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => {
const theme = dark ? 'dark-theme' : 'light-theme';
this.onSetTheme(theme);
this.setFavicon(theme);
});
this.translate.onLangChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((language: LangChangeEvent) => {
this.document.documentElement.lang = language.lang;
this.language = language.lang;
});
}
// TODO implement Console storage
// private startIntroWorkflow(): void {
// setTimeout(() => {
// const cb = () => {
// this.storageService.setItem('intro-dismissed', true, StorageLocation.local);
// };
// const dismissed = this.storageService.getItem('intro-dismissed', StorageLocation.local);
// if (!dismissed) {
// this.workflowService.startWorkflow(IntroWorkflowOverlays, cb);
// }
// }, 1000);
// }
public prepareRoute(outlet: RouterOutlet): boolean {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];
}
public onSetTheme(theme: string): void {
localStorage.setItem('theme', theme);
this.overlayContainer.getContainerElement().classList.remove(theme === 'dark-theme' ? 'light-theme' : 'dark-theme');
this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = theme;
}
public changedOrg(org: Org.AsObject): void {
// Reference: https://stackoverflow.com/a/58114797
const currentUrl = this.router.url;
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
// We use navigateByUrl as our urls may have queryParams
this.router.navigateByUrl(currentUrl).then();
});
}
private setLanguage(): void {
this.translate.addLangs(supportedLanguages);
this.translate.setDefaultLang(fallbackLanguage);
this.authService.user.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)).subscribe((userprofile) => {
const cropped = navigator.language.split('-')[0] ?? fallbackLanguage;
const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage;
const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp)
? userprofile.human.profile?.preferredLanguage
: fallbackLang;
this.translate.use(lang);
this.language = lang;
this.document.documentElement.lang = lang;
});
}
private getProjectCount(): void {
this.authService.isAllowed(['project.read']).subscribe((allowed) => {
if (allowed) {
this.mgmtService.listProjects(0, 0);
this.mgmtService.listGrantedProjects(0, 0);
}
});
}
private setFavicon(theme: string): void {
this.authService.labelpolicy$.pipe(startWith(undefined), takeUntilDestroyed(this.destroyRef)).subscribe((lP) => {
if (theme === 'dark-theme' && lP?.iconUrlDark) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrlDark).then((response) => {
if (response.ok) {
this.document.getElementById('appFavicon')?.setAttribute('href', lP.iconUrlDark);
}
});
} else if (theme === 'light-theme' && lP?.iconUrl) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrl).then((response) => {
if (response.ok) {
this.document.getElementById('appFavicon')?.setAttribute('href', lP.iconUrl);
}
});
} else {
// Default Zitadel favicon
this.document.getElementById('appFavicon')?.setAttribute('href', 'favicon.ico');
}
});
}
}

View File

@@ -0,0 +1,253 @@
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import localeBg from '@angular/common/locales/bg';
import localeDe from '@angular/common/locales/de';
import localeCs from '@angular/common/locales/cs';
import localeEn from '@angular/common/locales/en';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeId from '@angular/common/locales/id';
import localeIt from '@angular/common/locales/it';
import localeJa from '@angular/common/locales/ja';
import localeMk from '@angular/common/locales/mk';
import localePl from '@angular/common/locales/pl';
import localePt from '@angular/common/locales/pt';
import localeZh from '@angular/common/locales/zh';
import localeRu from '@angular/common/locales/ru';
import localeNl from '@angular/common/locales/nl';
import localeSv from '@angular/common/locales/sv';
import localeHu from '@angular/common/locales/hu';
import localeKo from '@angular/common/locales/ko';
import localeRo from '@angular/common/locales/ro';
import localeTr from '@angular/common/locales/tr';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { AuthConfig, OAuthModule, OAuthStorage } from 'angular-oauth2-oidc';
import * as i18nIsoCountries from 'i18n-iso-countries';
import { from, Observable } from 'rxjs';
import { InfoOverlayModule } from 'src/app/modules/info-overlay/info-overlay.module';
import { AssetService } from 'src/app/services/asset.service';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HasRoleModule } from './directives/has-role/has-role.module';
import { FooterModule } from './modules/footer/footer.module';
import { HeaderModule } from './modules/header/header.module';
import { KeyboardShortcutsModule } from './modules/keyboard-shortcuts/keyboard-shortcuts.module';
import { NavModule } from './modules/nav/nav.module';
import { WarnDialogModule } from './modules/warn-dialog/warn-dialog.module';
import { HasRolePipeModule } from './pipes/has-role-pipe/has-role-pipe.module';
import { AdminService } from './services/admin.service';
import { AuthenticationService } from './services/authentication.service';
import { BreadcrumbService } from './services/breadcrumb.service';
import { EnvironmentService } from './services/environment.service';
import { ExhaustedService } from './services/exhausted.service';
import { GrpcAuthService } from './services/grpc-auth.service';
import { GrpcService } from './services/grpc.service';
import { AuthInterceptor } from './services/interceptors/auth.interceptor';
import { ExhaustedGrpcInterceptor } from './services/interceptors/exhausted.grpc.interceptor';
import { ExhaustedHttpInterceptor } from './services/interceptors/exhausted.http.interceptor';
import { GRPC_INTERCEPTORS } from './services/interceptors/grpc-interceptor';
import { I18nInterceptor } from './services/interceptors/i18n.interceptor';
import { OrgInterceptor } from './services/interceptors/org.interceptor';
import { KeyboardShortcutsService } from './services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from './services/mgmt.service';
import { NavigationService } from './services/navigation.service';
import { OverlayService } from './services/overlay/overlay.service';
import { RefreshService } from './services/refresh.service';
import { SeoService } from './services/seo.service';
import {
StatehandlerProcessorService,
StatehandlerProcessorServiceImpl,
} from './services/statehandler/statehandler-processor.service';
import { StatehandlerService, StatehandlerServiceImpl } from './services/statehandler/statehandler.service';
import { StorageService } from './services/storage.service';
import { ThemeService } from './services/theme.service';
import { ToastService } from './services/toast.service';
import { LanguagesService } from './services/languages.service';
import { PosthogService } from './services/posthog.service';
registerLocaleData(localeDe);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/de.json'));
registerLocaleData(localeEn);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/en.json'));
registerLocaleData(localeEs);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/es.json'));
registerLocaleData(localeFr);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/fr.json'));
registerLocaleData(localeId);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/id.json'));
registerLocaleData(localeIt);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/it.json'));
registerLocaleData(localeJa);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/ja.json'));
registerLocaleData(localePl);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/pl.json'));
registerLocaleData(localeZh);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/zh.json'));
registerLocaleData(localeBg);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/bg.json'));
registerLocaleData(localePt);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/pt.json'));
registerLocaleData(localeMk);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/mk.json'));
registerLocaleData(localeRu);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/ru.json'));
registerLocaleData(localeCs);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/cs.json'));
registerLocaleData(localeNl);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/nl.json'));
registerLocaleData(localeSv);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/sv.json'));
registerLocaleData(localeHu);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/hu.json'));
registerLocaleData(localeKo);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/ko.json'));
registerLocaleData(localeRo);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/ro.json'));
registerLocaleData(localeTr);
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/tr.json'));
export class WebpackTranslateLoader implements TranslateLoader {
getTranslation(lang: string): Observable<any> {
return from(import(`../assets/i18n/${lang}.json`));
}
}
const appInitializerFn = (grpcSvc: GrpcService) => {
return () => {
return grpcSvc.loadAppEnvironment();
};
};
const stateHandlerFn = (stateHandler: StatehandlerService) => {
return () => {
return stateHandler.initStateHandler();
};
};
const authConfig: AuthConfig = {
scope: 'openid profile email', // offline_access
responseType: 'code',
oidc: true,
requireHttps: false,
};
@NgModule({
declarations: [AppComponent],
imports: [
AppRoutingModule,
CommonModule,
BrowserModule,
HeaderModule,
OAuthModule.forRoot(),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: WebpackTranslateLoader,
},
}),
NavModule,
MatNativeDateModule,
HasRoleModule,
InfoOverlayModule,
BrowserAnimationsModule,
HttpClientModule,
MatIconModule,
MatTooltipModule,
FooterModule,
HasRolePipeModule,
MatSnackBarModule,
WarnDialogModule,
MatSelectModule,
MatDialogModule,
KeyboardShortcutsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }),
],
providers: [
ThemeService,
EnvironmentService,
ExhaustedService,
{
provide: APP_INITIALIZER,
useFactory: appInitializerFn,
multi: true,
deps: [GrpcService],
},
{
provide: APP_INITIALIZER,
useFactory: stateHandlerFn,
multi: true,
deps: [StatehandlerService],
},
{
provide: AuthConfig,
useValue: authConfig,
},
{
provide: StatehandlerProcessorService,
useClass: StatehandlerProcessorServiceImpl,
},
{
provide: StatehandlerService,
useClass: StatehandlerServiceImpl,
},
{
provide: OAuthStorage,
useClass: StorageService,
},
{
provide: HTTP_INTERCEPTORS,
multi: true,
useClass: ExhaustedHttpInterceptor,
},
{
provide: GRPC_INTERCEPTORS,
multi: true,
useClass: ExhaustedGrpcInterceptor,
},
{
provide: GRPC_INTERCEPTORS,
multi: true,
useClass: AuthInterceptor,
},
{
provide: GRPC_INTERCEPTORS,
multi: true,
useClass: I18nInterceptor,
},
{
provide: GRPC_INTERCEPTORS,
multi: true,
useClass: OrgInterceptor,
},
OverlayService,
SeoService,
RefreshService,
GrpcService,
BreadcrumbService,
AuthenticationService,
GrpcAuthService,
ManagementService,
AdminService,
KeyboardShortcutsService,
AssetService,
ToastService,
NavigationService,
LanguagesService,
PosthogService,
{ provide: 'windowObject', useValue: window },
],
bootstrap: [AppComponent],
})
export class AppModule {
constructor() {}
}

View File

@@ -0,0 +1,16 @@
<div class="cnsl-cr-row">
<div class="cnsl-cr-secondary-text" [ngStyle]="{ minWidth: labelMinWidth }">{{ label }}</div>
<button
class="cnsl-cr-copy"
[disabled]="copied === value"
[matTooltip]="(copied !== value ? 'ACTIONS.COPY' : 'ACTIONS.COPIED') | translate"
cnslCopyToClipboard
[valueToCopy]="value"
(copiedValue)="copied = $event"
>
{{ value }}
</button>
<div class="cnsl-cr-item">
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,41 @@
.cnsl-cr-row {
display: flex;
align-items: center;
.cnsl-cr-right {
flex-shrink: 0;
}
}
@mixin copy-row-theme($theme) {
$is-dark-theme: map-get($theme, is-dark);
$foreground: map-get($theme, foreground);
$button-text-color: map-get($foreground, text);
$button-disabled-text-color: map-get($foreground, disabled-button);
.cnsl-cr-copy {
flex-grow: 1;
text-align: left;
transition: opacity 0.15s ease-in-out;
background-color: #8795a110;
border: 1px solid #8795a160;
border-radius: 4px;
padding: 0.25rem 1rem;
margin: 0.25rem 0rem;
color: $button-text-color;
text-overflow: ellipsis;
overflow: hidden;
cursor: copy;
&[disabled] {
color: $button-disabled-text-color;
}
}
}
.row {
display: flex;
align-items: center;
.right {
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CopyToClipboardModule } from '../../directives/copy-to-clipboard/copy-to-clipboard.module';
@Component({
standalone: true,
selector: 'cnsl-copy-row',
templateUrl: './copy-row.component.html',
styleUrls: ['./copy-row.component.scss'],
imports: [CommonModule, TranslateModule, MatButtonModule, MatTooltipModule, CopyToClipboardModule],
})
export class CopyRowComponent {
@Input({ required: true }) public label = '';
@Input({ required: true }) public value = '';
@Input() public labelMinWidth = '';
public copied = '';
}

View File

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

View File

@@ -0,0 +1,39 @@
.feature-row {
display: flex;
flex-direction: column;
padding-bottom: 1rem;
.row {
display: flex;
align-items: center;
justify-content: space-between;
.buttongroup {
margin-right: 0.5rem;
margin-top: 0.5rem;
.toggle-row {
display: flex;
align-items: center;
i {
margin-right: 0.5rem;
}
.info-i {
font-size: 1.2rem;
margin-left: 0.5rem;
margin-right: 0;
}
.current-dot {
height: 8px;
width: 8px;
border-radius: 50%;
margin-left: 0.5rem;
background-color: rgb(59, 128, 247);
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
import { AsyncPipe, NgIf, UpperCasePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { ToggleStateKeys, ToggleStates } from '../features/features.component';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { FormsModule } from '@angular/forms';
import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { ReplaySubject } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'cnsl-feature-toggle',
templateUrl: './feature-toggle.component.html',
styleUrls: ['./feature-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonToggleModule,
UpperCasePipe,
TranslateModule,
FormsModule,
MatTooltipModule,
InfoSectionModule,
AsyncPipe,
NgIf,
],
})
export class FeatureToggleComponent<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]> {
@Input({ required: true }) toggleStateKey!: TKey;
@Input({ required: true })
set toggleState(toggleState: TValue) {
// we copy the toggleState so we can mutate it
this.toggleState$.next(structuredClone(toggleState));
}
@Output() readonly toggleChange = new EventEmitter<TValue>();
protected readonly Source = Source;
protected readonly toggleState$ = new ReplaySubject<TValue>(1);
protected readonly isInherited$ = this.toggleState$.pipe(
map(({ source }) => source == Source.SYSTEM || source == Source.UNSPECIFIED),
);
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<div class="feature-settings-wrapper">
<div class="feature-title-row">
<h2>{{ 'DESCRIPTIONS.SETTINGS.FEATURES.TITLE' | translate }}</h2>
<a
mat-icon-button
href="https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service"
rel="noreferrer"
target="_blank"
>
<mat-icon class="icon">info_outline</mat-icon>
</a>
</div>
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}</p>
<ng-template cnslHasRole [hasRole]="['iam.restrictions.write']">
<button color="warn" (click)="resetFeatures()" mat-stroked-button>
{{ 'SETTING.FEATURES.RESET' | translate }}
</button>
</ng-template>
<cnsl-card *ngIf="toggleStates$ | async as toggleStates">
<div class="features">
<cnsl-feature-toggle
*ngFor="let key of FEATURE_KEYS"
[toggleStateKey]="key"
[toggleState]="toggleStates[key]"
(toggleChange)="saveFeatures(key, $event)"
></cnsl-feature-toggle>
<cnsl-login-v2-feature-toggle [toggleState]="toggleStates.loginV2" (toggleChanged)="saveFeatures('loginV2', $event)" />
</div>
</cnsl-card>
</div>

View File

@@ -0,0 +1,69 @@
.feature-settings-wrapper {
.feature-title-row {
display: flex;
align-items: center;
h1 {
margin: 0;
}
a .icon {
font-size: 1.2rem;
height: 1.2rem;
line-height: 1.2rem;
}
}
.features {
.feature-row {
display: flex;
flex-direction: column;
.row {
display: flex;
align-items: center;
justify-content: space-between;
.buttongroup {
margin-right: 0.5rem;
margin-top: 0.5rem;
.toggle-row {
display: flex;
align-items: center;
i {
margin-right: 0.5rem;
}
.info-i {
font-size: 1.2rem;
margin-left: 0.5rem;
margin-right: 0;
}
.current-dot {
height: 8px;
width: 8px;
border-radius: 50%;
// background-color: rgb(84, 142, 230);
margin-left: 0.5rem;
&.enabled {
background-color: var(--success);
}
&.disabled {
background-color: var(--warn);
}
}
}
}
}
.feature-info {
margin-bottom: 1rem;
}
}
}
}

View File

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

View File

@@ -0,0 +1,173 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ToastService } from 'src/app/services/toast.service';
import { FeatureToggleComponent } from '../feature-toggle/feature-toggle.component';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import {
GetInstanceFeaturesResponse,
SetInstanceFeaturesRequestSchema,
} from '@zitadel/proto/zitadel/feature/v2/instance_pb';
import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb';
import { MessageInitShape } from '@bufbuild/protobuf';
import { firstValueFrom, Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-feature-toggle/login-v2-feature-toggle.component';
// to add a new feature, add the key here and in the FEATURE_KEYS array
const FEATURE_KEYS = [
'consoleUseV2UserApi',
'debugOidcParentError',
'disableUserTokenEvent',
'enableBackChannelLogout',
// 'improvedPerformance',
'loginDefaultOrg',
'oidcSingleV1SessionTermination',
'oidcTokenExchange',
'permissionCheckV2',
'userSchema',
] as const;
export type ToggleState = { source: Source; enabled: boolean };
export type ToggleStates = {
[key in (typeof FEATURE_KEYS)[number]]: ToggleState;
} & {
loginV2: ToggleState & { baseUri: string };
};
export type ToggleStateKeys = keyof ToggleStates;
@Component({
imports: [
CommonModule,
FormsModule,
MatButtonToggleModule,
HasRolePipeModule,
MatIconModule,
CardModule,
TranslateModule,
MatButtonModule,
MatCheckboxModule,
InfoSectionModule,
MatTooltipModule,
HasRoleModule,
FeatureToggleComponent,
LoginV2FeatureToggleComponent,
],
standalone: true,
selector: 'cnsl-features',
templateUrl: './features.component.html',
styleUrls: ['./features.component.scss'],
})
export class FeaturesComponent {
private readonly refresh$ = new ReplaySubject<true>(1);
protected readonly toggleStates$: Observable<ToggleStates>;
protected readonly Source = Source;
protected readonly FEATURE_KEYS = FEATURE_KEYS;
constructor(
private readonly featureService: NewFeatureService,
private readonly breadcrumbService: BreadcrumbService,
private readonly toast: ToastService,
) {
const breadcrumbs = [
new Breadcrumb({
type: BreadcrumbType.INSTANCE,
name: 'Instance',
routerLink: ['/instance'],
}),
];
this.breadcrumbService.setBreadcrumb(breadcrumbs);
this.toggleStates$ = this.getToggleStates().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
private getToggleStates() {
return this.refresh$.pipe(
startWith(true),
switchMap(async () => {
try {
return await this.featureService.getInstanceFeatures();
} catch (error) {
this.toast.showError(error);
return undefined;
}
}),
filter(Boolean),
map((res) => this.createToggleStates(res)),
);
}
private createToggleStates(featureData: GetInstanceFeaturesResponse): ToggleStates {
return FEATURE_KEYS.reduce(
(acc, key) => {
const feature = featureData[key];
acc[key] = {
source: feature?.source ?? Source.SYSTEM,
enabled: !!feature?.enabled,
};
return acc;
},
{
// to add special feature flags they have to be mapped here
loginV2: {
source: featureData.loginV2?.source ?? Source.SYSTEM,
enabled: !!featureData.loginV2?.required,
baseUri: featureData.loginV2?.baseUri ?? '',
},
} as ToggleStates,
);
}
public async saveFeatures<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]>(key: TKey, value: TValue) {
const toggleStates = { ...(await firstValueFrom(this.toggleStates$)), [key]: value };
const req = FEATURE_KEYS.reduce<MessageInitShape<typeof SetInstanceFeaturesRequestSchema>>((acc, key) => {
acc[key] = toggleStates[key].enabled;
return acc;
}, {});
// to save special flags they have to be handled here
req['loginV2'] = {
required: toggleStates.loginV2.enabled,
baseUri: toggleStates.loginV2.baseUri,
};
try {
await this.featureService.setInstanceFeatures(req);
// needed because of eventual consistency
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
this.toast.showInfo('POLICY.TOAST.SET', true);
} catch (error) {
this.toast.showError(error);
}
}
public async resetFeatures() {
try {
await this.featureService.resetInstanceFeatures();
// needed because of eventual consistency
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
} catch (error) {
this.toast.showError(error);
}
}
}

View File

@@ -0,0 +1,35 @@
<form>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'QUICKSTART.FRAMEWORK' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" #nameInput [formControl]="myControl" [matAutocomplete]="auto" />
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)">
<mat-option *ngIf="isLoading()" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let framework of filteredOptions | async" [value]="framework.id">
<div class="framework-option">
<div class="framework-option-column">
<div class="img-wrapper">
<img class="dark-only" *ngIf="framework.imgSrcDark" [src]="framework.imgSrcDark" />
<img class="light-only" *ngIf="framework.imgSrcLight" [src]="framework.imgSrcLight" />
</div>
<span class="fill-space"></span>
<span>{{ framework.title }}</span>
</div>
</div>
</mat-option>
<mat-option *ngIf="withCustom" [value]="'other'">
<div class="framework-option">
<div class="framework-option-column">
<div class="img-wrapper"></div>
<span class="fill-space"></span>
<span>{{ 'QUICKSTART.FRAMEWORK_OTHER' | translate }}</span>
</div>
</div>
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
</form>

View File

@@ -0,0 +1,69 @@
@mixin framework-autocomplete-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$list-background-color: map-get($background, 300);
$card-background-color: map-get($background, cards);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #fff, #000);
.full-width {
width: 100%;
}
input {
max-width: 500px;
}
.framework-option {
display: flex;
align-items: center;
.framework-option-column {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
width: 100%;
span {
line-height: normal;
}
.fill-space {
flex: 1;
}
.img-wrapper {
width: 50px;
margin-right: 1rem;
img {
width: 100%;
height: 100%;
max-width: 30px;
max-height: 30px;
object-fit: contain;
object-position: center;
}
}
.dark-only {
display: if($is-dark-theme, block, none);
}
.light-only {
display: if($is-dark-theme, none, block);
}
}
}
}

View File

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

View File

@@ -0,0 +1,63 @@
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 { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { InputModule } from 'src/app/modules/input/input.module';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable, map, of, startWith, switchMap, tap } from 'rxjs';
import { Framework } from '../quickstart/quickstart.component';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'cnsl-framework-autocomplete',
templateUrl: './framework-autocomplete.component.html',
styleUrls: ['./framework-autocomplete.component.scss'],
imports: [
TranslateModule,
RouterModule,
MatSelectModule,
MatAutocompleteModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
CommonModule,
MatButtonModule,
InputModule,
],
})
export class FrameworkAutocompleteComponent implements OnInit {
public isLoading = signal(false);
@Input() public frameworkId?: string;
@Input() public frameworks: Framework[] = [];
@Input() public withCustom: boolean = false;
public myControl: FormControl = new FormControl();
@Output() public selectionChanged: EventEmitter<string> = new EventEmitter();
public filteredOptions: Observable<Framework[]> = of([]);
constructor() {}
public ngOnInit() {
this.filteredOptions = this.myControl.valueChanges.pipe(
startWith(''),
map((value) => {
return this._filter(value || '');
}),
);
}
private _filter(value: string): Framework[] {
const filterValue = value.toLowerCase();
return this.frameworks
.filter((option) => option.id)
.filter((option) => option.title.toLowerCase().includes(filterValue));
}
public selected(event: MatAutocompleteSelectedEvent): void {
this.selectionChanged.emit(event.option.value);
}
}

View File

@@ -0,0 +1,17 @@
<h2 mat-dialog-title>{{ 'QUICKSTART.DIALOG.CHANGE.TITLE' | translate }}</h2>
<mat-dialog-content>
{{ 'QUICKSTART.DIALOG.CHANGE.DESCRIPTION' | translate }}
<div class="framework-change-block">
<cnsl-framework-autocomplete
[frameworkId]="data.framework?.id"
[frameworks]="data.frameworks"
(selectionChanged)="findFramework($event)"
></cnsl-framework-autocomplete>
</div>
</mat-dialog-content>
<div>
<mat-dialog-actions class="actions">
<button mat-stroked-button mat-dialog-close>{{ 'ACTIONS.CANCEL' | translate }}</button>
<button color="primary" mat-raised-button (click)="close()" cdkFocusInitial>{{ 'ACTIONS.CHANGE' | translate }}</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,10 @@
.framework-change-block {
display: flex;
flex-direction: column;
align-items: stretch;
}
.actions {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,41 @@
import { Component, Inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogModule,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { FrameworkAutocompleteComponent } from '../framework-autocomplete/framework-autocomplete.component';
import { Framework } from '../quickstart/quickstart.component';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'cnsl-framework-change-dialog',
templateUrl: './framework-change-dialog.component.html',
styleUrls: ['./framework-change-dialog.component.scss'],
standalone: true,
imports: [MatButtonModule, MatDialogModule, TranslateModule, FrameworkAutocompleteComponent],
})
export class FrameworkChangeDialogComponent {
public framework = signal<Framework | undefined>(undefined);
constructor(
public dialogRef: MatDialogRef<FrameworkChangeDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
) {
this.framework.set(data.framework);
}
public findFramework(id: string) {
const temp = this.data.frameworks.find((f: Framework) => f.id === id);
this.framework.set(temp);
}
public close() {
this.dialogRef.close(this.framework());
}
}

View File

@@ -0,0 +1,21 @@
<div class="framework-change-wrapper">
<div class="framework-card-wrapper">
<div class="framework-card card" *ngIf="framework | async as framework; else frameworkFallback">
<div>
<img class="dark-only" *ngIf="framework.imgSrcDark" [src]="framework.imgSrcDark" />
<img class="light-only" *ngIf="framework.imgSrcLight" [src]="framework.imgSrcLight" />
</div>
<span>{{ framework.title }}</span>
</div>
<ng-template #frameworkFallback>
<div class="framework-card card">
<span>{{ 'QUICKSTART.SELECT_FRAMEWORK' | translate }}</span>
</div>
</ng-template>
<button (click)="openDialog()" mat-stroked-button>
{{ 'ACTIONS.CHANGE' | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,82 @@
@mixin framework-change-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$list-background-color: map-get($background, 300);
$card-background-color: map-get($background, cards);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #fff, #000);
.framework-change-wrapper {
.framework-card-wrapper {
display: flex;
align-items: center;
gap: 1rem;
.framework-card {
position: relative;
flex-shrink: 0;
text-decoration: none;
border-radius: 0.5rem;
box-sizing: border-box;
transition: all 0.1s ease-in;
display: flex;
flex-direction: row;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
// background-color: if($is-dark-theme, map-get($background, state), #e4e7e4);
// box-shadow: 0 0 3px #0000001a;
border: 1px solid rgba(#8795a1, 0.2);
padding: 0 0.5rem;
img {
width: 100%;
height: 100%;
max-width: 40px;
max-height: 40px;
object-fit: contain;
object-position: center;
}
.dark-only {
display: if($is-dark-theme, block, none);
}
.light-only {
display: if($is-dark-theme, none, block);
}
span {
margin: 0.5rem;
text-align: center;
color: map-get($foreground, text);
}
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14px;
margin-bottom: 0.5rem;
color: map-get($primary, 400);
.icon {
margin-left: 0rem;
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,76 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { ActivatedRoute, Params, RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import frameworkDefinition from '../../../../../../docs/frameworks.json';
import { MatButtonModule } from '@angular/material/button';
import { Framework } from '../quickstart/quickstart.component';
import { BehaviorSubject, Subject, takeUntil } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { FrameworkChangeDialogComponent } from './framework-change-dialog.component';
@Component({
standalone: true,
selector: 'cnsl-framework-change',
templateUrl: './framework-change.component.html',
styleUrls: ['./framework-change.component.scss'],
imports: [TranslateModule, RouterModule, CommonModule, MatButtonModule],
})
export class FrameworkChangeComponent implements OnInit, OnDestroy {
private destroy$: Subject<void> = new Subject();
public framework: BehaviorSubject<Framework | undefined> = new BehaviorSubject<Framework | undefined>(undefined);
@Output() public frameworkChanged: EventEmitter<Framework> = new EventEmitter();
public frameworks: Framework[] = frameworkDefinition.map((f) => {
return {
...f,
fragment: '',
imgSrcDark: `assets${f.imgSrcDark}`,
imgSrcLight: `assets${f.imgSrcLight ? f.imgSrcLight : f.imgSrcDark}`,
};
});
constructor(
private activatedRoute: ActivatedRoute,
private dialog: MatDialog,
) {
this.framework.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.frameworkChanged.emit(value);
});
}
public ngOnInit() {
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params: Params) => {
const { framework } = params;
if (framework) {
this.findFramework(framework);
}
});
}
public ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
public findFramework(id: string) {
const temp = this.frameworks.find((f) => f.id === id);
this.framework.next(temp);
this.frameworkChanged.emit(temp);
}
public openDialog(): void {
const ref = this.dialog.open(FrameworkChangeDialogComponent, {
width: '400px',
data: {
framework: this.framework.value,
frameworks: this.frameworks,
},
});
ref.afterClosed().subscribe((resp) => {
if (resp) {
this.framework.next(resp);
}
});
}
}

View File

@@ -0,0 +1,53 @@
<div class="configuration-wrapper">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.NAME' | translate }}
</span>
<span class="right name">
<span>{{ name }}</span>
<button (click)="changeName.emit()" mat-icon-button><i class="las la-pen"></i></button>
</span>
</div>
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.TYPE' | translate }}
</span>
<span class="right">
{{ 'APP.OIDC.APPTYPE.' + configuration.appType | translate }}
</span>
</div>
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.GRANT' | translate }}
</span>
<span class="right" *ngIf="configuration.grantTypesList && configuration.grantTypesList.length > 0">
[<span *ngFor="let element of configuration.grantTypesList ?? []; index as i">
{{ 'APP.OIDC.GRANT.' + element | translate }}
{{ i < configuration.grantTypesList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.OIDC.RESPONSETYPE' | translate }}
</span>
<span class="right" *ngIf="configuration.responseTypesList && configuration.responseTypesList.length > 0">
[<span *ngFor="let element of configuration.responseTypesList ?? []; index as i">
{{ 'APP.OIDC.RESPONSE.' + element | translate }}
{{ i < configuration.responseTypesList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.AUTHMETHOD' | translate }}
</span>
<span class="right">
<span>
{{ 'APP.OIDC.AUTHMETHOD.' + configuration.authMethodType | translate }}
</span>
</span>
</div>
</div>

View File

@@ -0,0 +1,23 @@
.configuration-wrapper {
.row {
display: flex;
justify-content: space-between;
align-items: center;
.left,
.right {
margin-bottom: 0.5rem;
font-size: 14px;
}
.name {
display: flex;
align-items: center;
button {
margin-right: -0.5rem;
margin-left: 0.25rem;
}
}
}
}

View File

@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OIDCConfigurationComponent } from './oidc-configuration.component';
describe('QuickstartComponent', () => {
let component: OIDCConfigurationComponent;
let fixture: ComponentFixture<OIDCConfigurationComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [OIDCConfigurationComponent],
});
fixture = TestBed.createComponent(OIDCConfigurationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { 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 type { FrameworkName } from '@netlify/framework-info/lib/generated/frameworkNames';
import { AddOIDCAppRequest } from 'src/app/proto/generated/zitadel/management_pb';
export type FrameworkDefinition = {
id?: FrameworkName | string;
title: string;
imgSrcDark: string;
imgSrcLight?: string;
docsLink: string;
external?: boolean;
};
export type Framework = FrameworkDefinition & {
fragment: string;
};
@Component({
standalone: true,
selector: 'cnsl-oidc-app-configuration',
templateUrl: './oidc-configuration.component.html',
styleUrls: ['./oidc-configuration.component.scss'],
imports: [TranslateModule, RouterModule, CommonModule, MatButtonModule],
})
export class OIDCConfigurationComponent {
@Input() public name?: string;
@Input() public configuration: AddOIDCAppRequest.AsObject = new AddOIDCAppRequest().toObject();
@Output() public changeName: EventEmitter<string> = new EventEmitter();
}

View File

@@ -0,0 +1,23 @@
<div class="quickstart-header">
<div class="quickstart-left">
<h2>{{ 'QUICKSTART.TITLE' | translate }}</h2>
<p class="description">{{ 'QUICKSTART.DESCRIPTION' | translate }}</p>
<div class="btn-wrapper">
<a mat-raised-button color="primary" [routerLink]="['/projects', 'app-create']">{{
'QUICKSTART.BTN_START' | translate
}}</a>
<a mat-stroked-button color="primary" href="https://zitadel.com/docs/sdk-examples/introduction" target="_blank">{{
'QUICKSTART.BTN_LEARNMORE' | translate
}}</a>
</div>
</div>
<div class="quickstart-card-wrapper">
<ng-container *ngFor="let framework of frameworks.slice(0, 18)">
<a [routerLink]="['/projects', 'app-create']" [queryParams]="{ framework: framework.id }" class="quickstart-card card">
<img class="dark-only" *ngIf="framework.imgSrcDark" [src]="framework.imgSrcDark" alt="{{ framework.title }}" />
<img class="light-only" *ngIf="framework.imgSrcLight" [src]="framework.imgSrcLight" alt="{{ framework.title }}" />
</a>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,121 @@
@mixin quickstart-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$list-background-color: map-get($background, 300);
$card-background-color: map-get($background, cards);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #fff, #000);
.quickstart-header {
display: flex;
flex-direction: row;
margin: 0 -2rem;
padding: 2rem;
margin-bottom: 2rem;
gap: 5rem;
justify-content: space-between;
background-color: map-get($background, metadata-section);
.quickstart-left {
display: flex;
flex-direction: column;
max-width: 400px;
.btn-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin: 1rem 0;
}
}
.quickstart-card-wrapper {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-column-gap: 1rem;
grid-row-gap: 1rem;
grid-auto-columns: 0;
overflow-x: hidden;
box-sizing: border-box;
max-width: 600px;
margin-left: auto;
.quickstart-card {
position: relative;
flex-shrink: 0;
text-decoration: none;
cursor: pointer;
border-radius: 0.5rem;
box-sizing: border-box;
transition: all 0.1s ease-in;
display: flex;
flex-direction: column;
height: 60px;
width: 60px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 3px #0000001a;
border: 1px solid rgba(#8795a1, 0.2);
color: var(--success);
opacity: 0.8;
&:hover {
border: 2px solid var(--success);
opacity: 1;
}
img {
width: 100%;
height: 100%;
max-width: 40px;
max-height: 40px;
object-fit: contain;
object-position: center;
}
.dark-only {
display: if($is-dark-theme, block, none);
}
.light-only {
display: if($is-dark-theme, none, block);
}
span {
margin: 0.5rem;
text-align: center;
color: map-get($foreground, text);
}
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14px;
margin-bottom: 0.5rem;
color: map-get($primary, 400);
.icon {
margin-left: 0rem;
}
}
&:hover {
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import frameworkDefinition from '../../../../../../docs/frameworks.json';
import { MatButtonModule } from '@angular/material/button';
import type { FrameworkName } from '@netlify/framework-info/lib/generated/frameworkNames';
import { OIDC_CONFIGURATIONS } from 'src/app/utils/framework';
export type FrameworkDefinition = {
id?: FrameworkName | string;
title: string;
description?: string;
imgSrcDark: string;
imgSrcLight?: string;
docsLink: string;
external?: boolean;
};
export type Framework = FrameworkDefinition & {
fragment: string;
};
@Component({
standalone: true,
selector: 'cnsl-quickstart',
templateUrl: './quickstart.component.html',
styleUrls: ['./quickstart.component.scss'],
imports: [TranslateModule, RouterModule, CommonModule, MatButtonModule],
})
export class QuickstartComponent {
public frameworks: FrameworkDefinition[] = frameworkDefinition
.filter((f) => f.id && OIDC_CONFIGURATIONS[f.id])
.map((f) => {
return {
...f,
imgSrcDark: `assets${f.imgSrcDark}`,
imgSrcLight: `assets${f.imgSrcLight ? f.imgSrcLight : f.imgSrcDark}`,
};
});
}

View File

@@ -0,0 +1,37 @@
import { Directive, ElementRef, HostListener, Renderer2 } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { take } from 'rxjs';
import { NavigationService } from 'src/app/services/navigation.service';
@Directive({
selector: '[cnslBack]',
})
export class BackDirective {
new: Boolean = false;
@HostListener('click')
onClick(): void {
this.navigation.back();
// Go back again to avoid create dialog starts again
if (this.new) {
this.navigation.back();
}
}
constructor(
private navigation: NavigationService,
private elRef: ElementRef,
private renderer2: Renderer2,
private route: ActivatedRoute,
) {
// Check if a new element was created using a create dialog
this.route.queryParams.pipe(take(1)).subscribe((params) => {
this.new = params['new'];
});
if (navigation.isBackPossible) {
// this.renderer2.removeStyle(this.elRef.nativeElement, 'visibility');
} else {
this.renderer2.setStyle(this.elRef.nativeElement, 'display', 'none');
}
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { BackDirective } from './back.directive';
@NgModule({
declarations: [BackDirective],
imports: [CommonModule],
exports: [BackDirective],
})
export class BackModule {}

View File

@@ -0,0 +1,33 @@
import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
@Directive({
selector: '[cnslCopyToClipboard]',
})
export class CopyToClipboardDirective {
@Input() valueToCopy: string = '';
@Output() copiedValue: EventEmitter<string> = new EventEmitter();
@HostListener('click', ['$event']) onClick($event: any): void {
$event.preventDefault();
$event.stopPropagation();
this.copytoclipboard(this.valueToCopy);
}
public copytoclipboard(value: string): void {
const selBox = document.createElement('textarea');
selBox.style.position = 'fixed';
selBox.style.left = '0';
selBox.style.top = '0';
selBox.style.opacity = '0';
selBox.value = value;
document.body.appendChild(selBox);
selBox.focus();
selBox.select();
document.execCommand('copy');
document.body.removeChild(selBox);
this.copiedValue.emit(value);
setTimeout(() => {
this.copiedValue.emit('');
}, 3000);
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CopyToClipboardDirective } from './copy-to-clipboard.directive';
@NgModule({
declarations: [CopyToClipboardDirective],
imports: [CommonModule],
exports: [CopyToClipboardDirective],
})
export class CopyToClipboardModule {}

View File

@@ -0,0 +1,28 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[cnslDropzone]',
})
export class DropzoneDirective {
@Output() dropped: EventEmitter<FileList> = new EventEmitter<FileList>();
@Output() hovered: EventEmitter<boolean> = new EventEmitter<boolean>();
@HostListener('drop', ['$event'])
onDrop($event: DragEvent): void {
$event.preventDefault();
this.dropped.emit($event.dataTransfer?.files);
this.hovered.emit(false);
}
@HostListener('dragover', ['$event'])
onDragOver($event: any): void {
$event.preventDefault();
this.hovered.emit(true);
}
@HostListener('dragleave', ['$event'])
onDragLeave($event: any): void {
$event.preventDefault();
this.hovered.emit(false);
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { DropzoneDirective } from './dropzone.directive';
@NgModule({
declarations: [DropzoneDirective],
imports: [CommonModule],
exports: [DropzoneDirective],
})
export class DropzoneModule {}

View File

@@ -0,0 +1,42 @@
import { DestroyRef, Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Directive({
selector: '[cnslHasRole]',
})
export class HasRoleDirective {
private hasView: boolean = false;
@Input() public set hasRole(roles: string[] | RegExp[] | undefined) {
if (roles && roles.length > 0) {
this.authService
.isAllowed(roles)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isAllowed) => {
if (isAllowed && !this.hasView) {
if (this.viewContainerRef.length !== 0) {
this.viewContainerRef.clear();
}
this.viewContainerRef.createEmbeddedView(this.templateRef);
} else {
this.viewContainerRef.clear();
this.hasView = false;
}
});
} else {
if (!this.hasView) {
if (this.viewContainerRef.length !== 0) {
this.viewContainerRef.clear();
}
this.viewContainerRef.createEmbeddedView(this.templateRef);
}
}
}
constructor(
private authService: GrpcAuthService,
protected templateRef: TemplateRef<any>,
protected viewContainerRef: ViewContainerRef,
private readonly destroyRef: DestroyRef,
) {}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HasRoleDirective } from './has-role.directive';
@NgModule({
declarations: [HasRoleDirective],
imports: [CommonModule],
exports: [HasRoleDirective],
})
export class HasRoleModule {}

View File

@@ -0,0 +1,30 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
standalone: true,
selector: '[cnslScrollable]',
})
export class ScrollableDirective {
// when using this directive, add overflow-y scroll to css
@Output() scrollPosition: EventEmitter<any> = new EventEmitter();
constructor(public el: ElementRef) {}
@HostListener('scroll', ['$event'])
public onScroll(event: any): void {
try {
const top = event.target.scrollTop;
const height = this.el.nativeElement.scrollHeight;
const offset = this.el.nativeElement.offsetHeight;
// emit bottom event
if (top > height - offset - 1) {
this.scrollPosition.emit('bottom');
}
// emit top event
if (top === 0) {
this.scrollPosition.emit('top');
}
} catch (err) {}
}
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,26 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AuthConfig } from 'angular-oauth2-oidc';
import { AuthenticationService } from '../services/authentication.service';
export const authGuard: CanActivateFn = (route) => {
const auth = inject(AuthenticationService);
if (!auth.authenticated) {
if (route.queryParams && route.queryParams['login_hint']) {
const hint = route.queryParams['login_hint'];
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
login_hint: hint,
},
};
console.log(`authenticate with login_hint: ${hint}`);
auth.authenticate(configWithPrompt).then();
} else {
return auth.authenticate();
}
}
return auth.authenticated;
};

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { GrpcAuthService } from '../services/grpc-auth.service';
export const roleGuard: CanActivateFn = (route) => {
const authService = inject(GrpcAuthService);
return authService.isAllowed(route.data['roles'], route.data['requiresAll']);
};

View File

@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { map, take } from 'rxjs';
import { GrpcAuthService } from '../services/grpc-auth.service';
export const userGuard: CanActivateFn = (route) => {
const authService = inject(GrpcAuthService);
const router = inject(Router);
return authService.user.pipe(
take(1),
map((user) => {
const isMe = user?.id === route.params['id'];
if (isMe) {
router.navigate(['/users', 'me']).then();
}
return !isMe;
}),
);
};

View File

@@ -0,0 +1,51 @@
<div class="accounts-card" *ngIf="user$ | async as user">
<cnsl-avatar
(click)="editUserProfile()"
class="avatar"
[ngClass]="{ 'iam-user': iamuser }"
[forColor]="user.preferredLoginName"
[avatarUrl]="user.human?.profile?.avatarUrl || ''"
[name]="user.human?.profile?.displayName ?? ''"
[size]="80"
>
</cnsl-avatar>
<span class="u-name">{{ user?.human?.profile?.displayName ? user?.human?.profile?.displayName : 'A' }}</span>
<span class="u-email" *ngIf="user?.preferredLoginName">{{ user?.preferredLoginName }}</span>
<button (click)="editUserProfile()" mat-stroked-button>{{ 'USER.EDITACCOUNT' | translate }}</button>
<div class="l-accounts">
<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"
[avatarUrl]="session.avatarUrl || ''"
[name]="session.displayName"
[forColor]="session.loginName"
[size]="32"
>
</cnsl-avatar>
<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="$any(session.authState) === UserState.USER_STATE_INACTIVE">{{
'USER.STATE.' + session.authState | translate
}}</span>
</div>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
<a class="row" (click)="selectNewAccount()">
<div class="icon-wrapper">
<i class="las la-user-plus"></i>
</div>
<span class="col">
<span class="user-title">{{ 'USER.ADDACCOUNT' | translate }}</span>
</span>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
</div>
<button (click)="logout()" color="warn" mat-stroked-button>{{ 'MENU.LOGOUT' | translate }}</button>
</div>

View File

@@ -0,0 +1,142 @@
@mixin accounts-card-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$card-background-color: map-get($background, cards);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$secondary-text: map-get($foreground, secondary-text);
.accounts-card {
border-radius: 0.5rem;
z-index: 300;
background-color: $card-background-color;
transition: background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid $border-color;
box-sizing: border-box;
outline: none;
width: 350px;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
position: relative;
color: map-get($foreground, text);
.avatar {
font-size: 80px;
margin-bottom: 1rem;
border-radius: 50%;
border: 2px solid $border-color;
&.iam-user {
border: 2px solid $primary-color;
}
}
.u-name {
font-size: 1rem;
line-height: 1rem;
}
.u-email {
font-size: 0.8rem;
margin: 0.5rem 0;
}
button {
border-radius: 50vh;
margin: 0.5rem;
}
.l-accounts {
display: flex;
flex-direction: column;
width: 100%;
padding: 0.5rem 0;
max-height: 450px;
overflow-y: auto;
border-top: 1px solid rgba(#8795a1, 0.3);
border-bottom: 1px solid rgba(#8795a1, 0.3);
.row {
padding: 0.5rem;
display: flex;
align-items: center;
color: inherit;
text-decoration: none;
&:hover {
cursor: pointer;
background-color: #00000010;
}
.small-avatar {
height: 35px;
width: 35px;
line-height: 35px;
font-size: 35px;
border-radius: 50%;
margin: 0 1rem;
}
.icon-wrapper {
height: 35px;
width: 35px;
border-radius: 50%;
margin: 0 1rem;
text-align: center;
display: flex;
i {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
display: flex;
flex-direction: column;
overflow-x: hidden;
.user-title {
font-weight: 500;
font-size: 0.9rem;
line-height: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.state,
.loginname {
font-size: 0.8rem;
line-height: 1rem;
white-space: nowrap;
width: fit-content;
}
.loginname {
color: $secondary-text;
text-overflow: ellipsis;
overflow: hidden;
}
.state {
margin-top: 3px;
font-size: 11px;
padding: 1px 0.5rem;
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,174 @@
import { Component, EventEmitter, Input, NgIterable, Output } from '@angular/core';
import { Router } from '@angular/router';
import { AuthConfig } from 'angular-oauth2-oidc';
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 {
@Input({ required: true })
public set user(user: User.AsObject) {
this.user$.next(user);
}
@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[]>;
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 ?? '',
})),
map((s) => [s.loginName, s] as const),
toArray(),
map((sessions) => Array.from(new Map(sessions).values())), // Ensure unique loginNames
);
}
public editUserProfile(): void {
this.router.navigate(['users/me']).then();
this.closedCard.emit();
}
public selectAccount(loginHint: string): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
login_hint: loginHint,
},
};
this.authService.authenticate(configWithPrompt).then();
}
public selectNewAccount(): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
prompt: 'login',
} as any,
};
this.authService.authenticate(configWithPrompt).then();
}
public logout(): void {
const lP = JSON.stringify(this.labelpolicy());
localStorage.setItem('labelPolicyOnSignout', lP);
this.authService.signout();
this.closedCard.emit();
}
}

View File

@@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AvatarModule } from '../avatar/avatar.module';
import { AccountsCardComponent } from './accounts-card.component';
@NgModule({
declarations: [AccountsCardComponent],
imports: [CommonModule, MatIconModule, MatButtonModule, MatProgressBarModule, RouterModule, AvatarModule, TranslateModule],
exports: [AccountsCardComponent],
})
export class AccountsCardModule {}

View File

@@ -0,0 +1,62 @@
<div
*ngIf="type !== ActionKeysType.ORGSWITCHER && (isHandset$ | async) === false"
class="action-keys-wrapper"
[ngSwitch]="type"
[ngClass]="{ 'without-margin': withoutMargin, 'no-contrast-mode': doNotUseContrast }"
>
<div *ngSwitchCase="ActionKeysType.CLEAR" class="action-keys-row">
<div class="action-key esc">
<div class="key-overlay"></div>
<span>ESC</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.ADD" class="action-keys-row" data-e2e="action-key-add">
<div class="action-key">
<div class="key-overlay"></div>
<span>N</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.DELETE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span>BS</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.DEACTIVATE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span></span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.REACTIVATE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span></span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.FILTER" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span>F</span>
</div>
</div>
</div>
<ng-template #otherOS>
<span>crtl</span>
</ng-template>

View File

@@ -0,0 +1,71 @@
@mixin action-keys-theme($theme) {
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$accent: map-get($theme, accent);
$is-dark-theme: map-get($theme, is-dark);
$accent-color: map-get($primary, 500);
$back: map-get($background, background);
.action-keys-wrapper {
display: inline-block;
padding-left: 0.5rem;
margin-right: -0.5rem;
&.without-margin {
padding: 0;
margin: 0.5rem;
}
.action-keys-row {
display: flex;
align-items: center;
margin: 0 -4px;
.action-key {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
height: 20px;
width: 20px;
position: relative;
margin: 0 4px;
&.esc {
font-size: 9px;
}
.key-overlay {
position: absolute;
z-index: -1;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: map-get($primary, default-contrast);
opacity: 0.2;
border-radius: 4px;
}
.span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50% -50%);
opacity: 1;
}
}
}
&.no-contrast-mode {
.action-keys-row {
.key-overlay {
z-index: 0;
background: if($is-dark-theme, #fff, #000);
opacity: 0.15;
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,101 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { AfterViewInit, Component, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { map, Observable } from 'rxjs';
export enum ActionKeysType {
ADD,
DELETE,
DEACTIVATE,
REACTIVATE,
FILTER,
ORGSWITCHER,
CLEAR,
}
@Component({
selector: 'cnsl-action-keys',
templateUrl: './action-keys.component.html',
styleUrls: ['./action-keys.component.scss'],
})
export class ActionKeysComponent implements AfterViewInit {
@Input() type: ActionKeysType = ActionKeysType.ADD;
@Input() withoutMargin: boolean = false;
@Input() doNotUseContrast: boolean = false;
@Output() actionTriggered: EventEmitter<void> = new EventEmitter();
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
const tagname = (event.target as any)?.tagName;
const exclude = ['input', 'textarea'];
if (exclude.indexOf(tagname.toLowerCase()) === -1) {
switch (this.type) {
case ActionKeysType.CLEAR:
if (event.code === 'Escape') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.ORGSWITCHER:
if (event.key === '/') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.ADD:
if (event.code === 'KeyN') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.DELETE:
if ((event.ctrlKey || event.metaKey) && event.code === 'Backspace') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.DEACTIVATE:
if ((event.ctrlKey || event.metaKey) && event.code === 'ArrowDown') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.REACTIVATE:
if ((event.ctrlKey || event.metaKey) && event.code === 'ArrowUp') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.FILTER:
if (event.ctrlKey === false && event.code === 'KeyF') {
this.actionTriggered.emit();
}
break;
}
}
}
public isHandset$: Observable<boolean> = this.breakpointObserver.observe(Breakpoints.Handset).pipe(
map((result) => {
return result.matches;
}),
);
public ActionKeysType: any = ActionKeysType;
constructor(public breakpointObserver: BreakpointObserver) {}
ngAfterViewInit(): void {
window.focus();
if (document.activeElement) {
(document.activeElement as any).blur();
}
}
public get isMacLike(): boolean {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent);
}
public get isIOS(): boolean {
return /(iPhone|iPod|iPad)/i.test(navigator.userAgent);
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ActionKeysComponent } from './action-keys.component';
@NgModule({
declarations: [ActionKeysComponent],
imports: [CommonModule],
exports: [ActionKeysComponent],
})
export class ActionKeysModule {}

View File

@@ -0,0 +1,69 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="loading()">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table 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>
{{ row.execution.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.execution.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 row.mappedTargets; trackBy: trackTarget" [roleName]="target.name">
{{ target.name }}
</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.execution.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.execution)"
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']"
(click)="selected.emit(row.execution)"
></tr>
</table>
</div>
</cnsl-refresh-table>

View File

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

View File

@@ -0,0 +1,103 @@
import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Input, Output } from '@angular/core';
import { combineLatestWith, Observable, ReplaySubject } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { toSignal } from '@angular/core/rxjs-interop';
import { CorrectlyTypedExecution } from '../../actions-two-add-action/actions-two-add-action-dialog.component';
@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 selected = new EventEmitter<CorrectlyTypedExecution>();
@Output()
public readonly delete = new EventEmitter<CorrectlyTypedExecution>();
@Input({ required: true })
public set executions(executions: CorrectlyTypedExecution[] | null) {
this.executions$.next(executions);
}
@Input({ required: true })
public set targets(targets: Target[] | null) {
this.targets$.next(targets);
}
private readonly executions$ = new ReplaySubject<CorrectlyTypedExecution[] | null>(1);
private readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource = this.getDataSource();
protected readonly loading = this.getLoading();
private getDataSource() {
const executions$: Observable<CorrectlyTypedExecution[]> = this.executions$.pipe(filter(Boolean), startWith([]));
const executionsSignal = toSignal(executions$, { requireSync: true });
const targetsMapSignal = this.getTargetsMap();
const dataSignal = computed(() => {
const executions = executionsSignal();
const targetsMap = targetsMapSignal();
if (targetsMap.size === 0) {
return [];
}
return executions.map((execution) => {
const mappedTargets = execution.targets
.map((target) => targetsMap.get(target))
.filter((target): target is NonNullable<typeof target> => !!target);
return { execution, mappedTargets };
});
});
const dataSource = new MatTableDataSource(dataSignal());
effect(() => {
const data = dataSignal();
if (dataSource.data !== data) {
dataSource.data = data;
}
});
return dataSource;
}
private getTargetsMap() {
const targets$ = this.targets$.pipe(filter(Boolean), startWith([] as Target[]));
const targetsSignal = toSignal(targets$, { requireSync: true });
return computed(() => {
const map = new Map<string, Target>();
for (const target of targetsSignal()) {
map.set(target.id, target);
}
return map;
});
}
private getLoading() {
const loading$ = this.executions$.pipe(
combineLatestWith(this.targets$),
map(([executions, targets]) => executions === null || targets === null),
startWith(true),
);
return toSignal(loading$, { requireSync: true });
}
protected trackTarget(_: number, target: Target) {
return target.id;
}
}

View File

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

View File

@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
import { ActionService } from 'src/app/services/action.service';
import { lastValueFrom, Observable, of, Subject } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { ToastService } from 'src/app/services/toast.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActionTwoAddActionDialogComponent,
ActionTwoAddActionDialogData,
ActionTwoAddActionDialogResult,
CorrectlyTypedExecution,
correctlyTypeExecution,
} from '../actions-two-add-action/actions-two-add-action-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { MessageInitShape } from '@bufbuild/protobuf';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { InfoSectionType } from '../../info-section/info-section.component';
import { ExecutionFieldName } from '@zitadel/proto/zitadel/action/v2beta/query_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 {
protected readonly refresh$ = new Subject<true>();
protected readonly executions$: Observable<CorrectlyTypedExecution[]>;
protected readonly targets$: Observable<Target[]>;
constructor(
private readonly actionService: ActionService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
private readonly dialog: MatDialog,
) {
this.executions$ = this.getExecutions$();
this.targets$ = this.getTargets$();
}
private getExecutions$() {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listExecutions({ sortingColumn: ExecutionFieldName.ID, pagination: { asc: true } });
}),
map(({ executions }) => executions.map(correctlyTypeExecution)),
catchError((err) => {
this.toast.showError(err);
return of([]);
}),
);
}
private getTargets$() {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listTargets({});
}),
map(({ targets }) => targets),
catchError((err) => {
this.toast.showError(err);
return of([]);
}),
);
}
public async openDialog(execution?: CorrectlyTypedExecution): Promise<void> {
const request$ = this.dialog
.open<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogData, ActionTwoAddActionDialogResult>(
ActionTwoAddActionDialogComponent,
{
width: '400px',
data: execution
? {
execution,
}
: {},
},
)
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef));
const request = await lastValueFrom(request$);
if (!request) {
return;
}
try {
await this.actionService.setExecution(request);
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
} catch (error) {
console.error(error);
this.toast.showError(error);
}
}
public async deleteExecution(execution: CorrectlyTypedExecution) {
const deleteReq: MessageInitShape<typeof SetExecutionRequestSchema> = {
condition: execution.condition,
targets: [],
};
try {
await this.actionService.setExecution(deleteReq);
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
} catch (error) {
console.error(error);
this.toast.showError(error);
}
}
protected readonly InfoSectionType = InfoSectionType;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
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,
} 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 { Condition, Execution } 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,
}
export type CorrectlyTypedCondition = Condition & { conditionType: Extract<Condition['conditionType'], { case: string }> };
export type CorrectlyTypedExecution = Omit<Execution, 'condition'> & {
condition: CorrectlyTypedCondition;
};
export const correctlyTypeExecution = (execution: Execution): CorrectlyTypedExecution => {
if (!execution.condition?.conditionType?.case) {
throw new Error('Condition is required');
}
const conditionType = execution.condition.conditionType;
const condition = {
...execution.condition,
conditionType,
};
return {
...execution,
condition,
};
};
export type ActionTwoAddActionDialogData = {
execution?: CorrectlyTypedExecution;
};
export type ActionTwoAddActionDialogResult = MessageInitShape<typeof SetExecutionRequestSchema>;
@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 {
protected readonly Page = Page;
protected readonly page = signal<Page>(Page.Type);
protected readonly typeSignal = signal<ConditionType>('request');
protected readonly conditionSignal = signal<MessageInitShape<typeof SetExecutionRequestSchema>['condition']>(undefined);
protected readonly targetsSignal = signal<string[]>([]);
protected readonly continueSubject = new Subject<void>();
protected readonly request = computed<MessageInitShape<typeof SetExecutionRequestSchema>>(() => {
return {
condition: this.conditionSignal(),
targets: this.targetsSignal(),
};
});
protected readonly preselectedTargetIds: string[] = [];
constructor(
protected readonly dialogRef: MatDialogRef<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogResult>,
@Inject(MAT_DIALOG_DATA) protected readonly data: ActionTwoAddActionDialogData,
) {
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"
}
});
if (!data?.execution) {
return;
}
this.targetsSignal.set(data.execution.targets);
this.typeSignal.set(data.execution.condition.conditionType.case);
this.conditionSignal.set(data.execution.condition);
this.preselectedTargetIds = data.execution.targets;
this.page.set(Page.Target); // Set the initial page based on the provided execution data
}
public continue() {
const currentPage = this.page();
if (currentPage === Page.Type) {
this.page.set(Page.Condition);
} else if (currentPage === Page.Condition) {
this.page.set(Page.Target);
} else {
this.dialogRef.close(this.request());
}
}
public back() {
const currentPage = this.page();
if (currentPage === Page.Target) {
this.page.set(Page.Condition);
} else if (currentPage === Page.Condition) {
this.page.set(Page.Type);
} else {
this.dialogRef.close();
}
}
}

View File

@@ -0,0 +1,72 @@
<form *ngIf="form$ | async as form" class="form-grid" [formGroup]="form" (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>
<input
cnslInput
#trigger="matAutocompleteTrigger"
#input
type="text"
[formControl]="form.controls.autocomplete"
[matAutocomplete]="autoservice"
(keydown.enter)="handleEnter($event, form); input.blur(); trigger.closePanel()"
/>
<mat-autocomplete #autoservice="matAutocomplete">
<mat-option *ngIf="targets().state === 'loading'" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option
*ngFor="let target of selectableTargets(); trackBy: trackTarget"
#option
(click)="addTarget(target, form); option.deselect()"
[value]="target.name"
>
{{ target.name }}
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
<table mat-table cdkDropList (cdkDropListDropped)="drop($event, form)" [dataSource]="dataSource" [trackBy]="trackTarget">
<ng-container matColumnDef="order">
<th mat-header-cell *matHeaderCellDef>Reorder</th>
<td mat-cell *cnslCellDef="let row; let i = index; dataSource: dataSource">
<i class="las la-bars"></i>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<cnsl-project-role-chip [roleName]="row.name">{{ row.name }}</cnsl-project-role-chip>
</td>
</ng-container>
<ng-container matColumnDef="deleteAction" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *cnslCellDef="let i = index; dataSource: dataSource">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="removeTarget(i, form)"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['order', 'name', 'deleteAction']"></tr>
<tr class="drag-row" cdkDrag mat-row *matRowDef="let row; columns: ['order', 'name', 'deleteAction']"></tr>
</table>
<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]="form.invalid" mat-raised-button type="submit">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</form>

View File

@@ -0,0 +1,36 @@
.target-description {
margin-bottom: 0;
}
.actions {
display: flex;
justify-content: space-between;
.fill-space {
font: 1;
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drop-list-dragging .mat-row:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.drag-row {
backdrop-filter: blur(10px);
}

View File

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

View File

@@ -0,0 +1,230 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
EventEmitter,
Input,
Output,
signal,
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 { ObservedValueOf, ReplaySubject, shareReplay, switchMap } 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 { MatSelectModule } from '@angular/material/select';
import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { map, startWith } from 'rxjs/operators';
import { TypeSafeCellDefModule } from 'src/app/directives/type-safe-cell-def/type-safe-cell-def.module';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { toSignal } from '@angular/core/rxjs-interop';
import { minArrayLengthValidator } from '../../../form-field/validators/validators';
import { ProjectRoleChipModule } from '../../../project-role-chip/project-role-chip.module';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TableActionsModule } from '../../../table-actions/table-actions.module';
@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,
MatTableModule,
TypeSafeCellDefModule,
CdkDrag,
CdkDropList,
ProjectRoleChipModule,
MatTooltipModule,
TableActionsModule,
],
})
export class ActionsTwoAddActionTargetComponent {
@Input() public hideBackButton = false;
@Input()
public set preselectedTargetIds(preselectedTargetIds: string[]) {
this.preselectedTargetIds$.next(preselectedTargetIds);
}
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<string[]>();
private readonly preselectedTargetIds$ = new ReplaySubject<string[]>(1);
protected readonly form$: ReturnType<typeof this.buildForm>;
protected readonly targets: ReturnType<typeof this.listTargets>;
private readonly selectedTargetIds: Signal<string[]>;
protected readonly selectableTargets: Signal<Target[]>;
protected readonly dataSource: MatTableDataSource<Target>;
constructor(
private readonly fb: FormBuilder,
private readonly actionService: ActionService,
private readonly toast: ToastService,
) {
this.form$ = this.buildForm().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.targets = this.listTargets();
this.selectedTargetIds = this.getSelectedTargetIds(this.form$);
this.selectableTargets = this.getSelectableTargets(this.targets, this.selectedTargetIds, this.form$);
this.dataSource = this.getDataSource(this.targets, this.selectedTargetIds);
}
private buildForm() {
return this.preselectedTargetIds$.pipe(
startWith([] as string[]),
map((preselectedTargetIds) => {
return this.fb.group({
autocomplete: new FormControl('', { nonNullable: true }),
selectedTargetIds: new FormControl(preselectedTargetIds, {
nonNullable: true,
validators: [minArrayLengthValidator(1)],
}),
});
}),
);
}
private listTargets() {
const targetsSignal = signal({ state: 'loading' as 'loading' | 'loaded', targets: new Map<string, Target>() });
this.actionService
.listTargets({})
.then(({ targets }) => {
const result = targets.reduce((acc, target) => {
acc.set(target.id, target);
return acc;
}, new Map<string, Target>());
targetsSignal.set({ state: 'loaded', targets: result });
})
.catch((error) => {
this.toast.showError(error);
});
return computed(targetsSignal);
}
private getSelectedTargetIds(form$: typeof this.form$) {
const selectedTargetIds$ = form$.pipe(
switchMap(({ controls: { selectedTargetIds } }) => {
return selectedTargetIds.valueChanges.pipe(startWith(selectedTargetIds.value));
}),
);
return toSignal(selectedTargetIds$, { requireSync: true });
}
private getSelectableTargets(targets: typeof this.targets, selectedTargetIds: Signal<string[]>, form$: typeof this.form$) {
const autocomplete$ = form$.pipe(
switchMap(({ controls: { autocomplete } }) => {
return autocomplete.valueChanges.pipe(startWith(autocomplete.value));
}),
);
const autocompleteSignal = toSignal(autocomplete$, { requireSync: true });
const unselectedTargets = computed(() => {
const targetsCopy = new Map(targets().targets);
for (const selectedTargetId of selectedTargetIds()) {
targetsCopy.delete(selectedTargetId);
}
return Array.from(targetsCopy.values());
});
return computed(() => {
const autocomplete = autocompleteSignal().toLowerCase();
return unselectedTargets().filter(({ name }) => name.toLowerCase().includes(autocomplete));
});
}
private getDataSource(targetsSignal: typeof this.targets, selectedTargetIdsSignal: Signal<string[]>) {
const selectedTargets = computed(() => {
// get this out of the loop so angular can track this dependency
// even if targets is empty
const { targets, state } = targetsSignal();
const selectedTargetIds = selectedTargetIdsSignal();
if (state === 'loading') {
return [];
}
return selectedTargetIds.map((id) => {
const target = targets.get(id);
if (!target) {
throw new Error(`Target with id ${id} not found`);
}
return target;
});
});
const dataSource = new MatTableDataSource<Target>(selectedTargets());
effect(() => {
dataSource.data = selectedTargets();
});
return dataSource;
}
protected addTarget(target: Target, form: ObservedValueOf<typeof this.form$>) {
const { selectedTargetIds } = form.controls;
selectedTargetIds.setValue([target.id, ...selectedTargetIds.value]);
form.controls.autocomplete.setValue('');
}
protected removeTarget(index: number, form: ObservedValueOf<typeof this.form$>) {
const { selectedTargetIds } = form.controls;
const data = [...selectedTargetIds.value];
data.splice(index, 1);
selectedTargetIds.setValue(data);
}
protected drop(event: CdkDragDrop<undefined>, form: ObservedValueOf<typeof this.form$>) {
const { selectedTargetIds } = form.controls;
const data = [...selectedTargetIds.value];
moveItemInArray(data, event.previousIndex, event.currentIndex);
selectedTargetIds.setValue(data);
}
protected handleEnter(event: Event, form: ObservedValueOf<typeof this.form$>) {
const selectableTargets = this.selectableTargets();
if (selectableTargets.length !== 1) {
return;
}
event.preventDefault();
this.addTarget(selectableTargets[0], form);
}
protected submit() {
this.continue.emit(this.selectedTargetIds());
}
protected trackTarget(_: number, target: Target) {
return target.id;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
<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>
<span class="name-hint cnsl-secondary-text types-description">
{{ 'ACTIONSTWO.TARGET.CREATE.TYPES_DESCRIPTION' | translate }}
</span>
</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>
{{ (data.target ? 'ACTIONS.CHANGE' : 'ACTIONS.CREATE') | translate }}
</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,29 @@
.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;
}
.types-description {
white-space: pre-line;
}

View File

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

View File

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

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