mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 14:37:34 +00:00
WIP: chore(ci): test nx
This commit is contained in:
13
apps/console/.editorconfig
Normal file
13
apps/console/.editorconfig
Normal 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
40
apps/console/.eslintrc.js
Normal 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
51
apps/console/.gitignore
vendored
Normal 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
|
||||
|
||||
|
7
apps/console/.prettierignore
Normal file
7
apps/console/.prettierignore
Normal 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
5
apps/console/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 125,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
137
apps/console/README.md
Normal file
137
apps/console/README.md
Normal 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
130
apps/console/angular.json
Normal 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
14
apps/console/buf.gen.yaml
Normal 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
|
32
apps/console/karma.conf.js
Normal file
32
apps/console/karma.conf.js
Normal 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
|
||||
});
|
||||
};
|
45
apps/console/ngsw-config.json
Normal file
45
apps/console/ngsw-config.json
Normal 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
113
apps/console/package.json
Normal 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"
|
||||
}
|
||||
}
|
28
apps/console/prebuild.development.js
Normal file
28
apps/console/prebuild.development.js
Normal 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);
|
||||
});
|
191
apps/console/src/app/animations.ts
Normal file
191
apps/console/src/app/animations.ts
Normal 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,
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
150
apps/console/src/app/app-routing.module.ts
Normal file
150
apps/console/src/app/app-routing.module.ts
Normal 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 {}
|
27
apps/console/src/app/app.component.html
Normal file
27
apps/console/src/app/app.component.html
Normal 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>
|
57
apps/console/src/app/app.component.scss
Normal file
57
apps/console/src/app/app.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
32
apps/console/src/app/app.component.spec.ts
Normal file
32
apps/console/src/app/app.component.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
326
apps/console/src/app/app.component.ts
Normal file
326
apps/console/src/app/app.component.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
253
apps/console/src/app/app.module.ts
Normal file
253
apps/console/src/app/app.module.ts
Normal 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() {}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 = '';
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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),
|
||||
);
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
173
apps/console/src/app/components/features/features.component.ts
Normal file
173
apps/console/src/app/components/features/features.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -0,0 +1,10 @@
|
||||
.framework-change-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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}`,
|
||||
};
|
||||
});
|
||||
}
|
37
apps/console/src/app/directives/back/back.directive.ts
Normal file
37
apps/console/src/app/directives/back/back.directive.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
11
apps/console/src/app/directives/back/back.module.ts
Normal file
11
apps/console/src/app/directives/back/back.module.ts
Normal 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 {}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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 {}
|
@@ -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);
|
||||
}
|
||||
}
|
11
apps/console/src/app/directives/dropzone/dropzone.module.ts
Normal file
11
apps/console/src/app/directives/dropzone/dropzone.module.ts
Normal 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 {}
|
@@ -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,
|
||||
) {}
|
||||
}
|
11
apps/console/src/app/directives/has-role/has-role.module.ts
Normal file
11
apps/console/src/app/directives/has-role/has-role.module.ts
Normal 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 {}
|
@@ -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) {}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 {}
|
BIN
apps/console/src/app/favicon-96x96.ico
Normal file
BIN
apps/console/src/app/favicon-96x96.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
26
apps/console/src/app/guards/auth.guard.ts
Normal file
26
apps/console/src/app/guards/auth.guard.ts
Normal 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;
|
||||
};
|
9
apps/console/src/app/guards/role-guard.ts
Normal file
9
apps/console/src/app/guards/role-guard.ts
Normal 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']);
|
||||
};
|
21
apps/console/src/app/guards/user-guard.ts
Normal file
21
apps/console/src/app/guards/user-guard.ts
Normal 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;
|
||||
}),
|
||||
);
|
||||
};
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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 {}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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 {}
|
@@ -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>
|
@@ -0,0 +1,12 @@
|
||||
.target-key {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-right: 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
}
|
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
@@ -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>
|
@@ -0,0 +1,21 @@
|
||||
.execution-condition-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.description {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.condition-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.name-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActionsTwoAddActionConditionComponent } from './actions-two-add-action-condition.component';
|
||||
|
||||
describe('ActionsTwoAddActionConditionComponent', () => {
|
||||
let component: ActionsTwoAddActionConditionComponent;
|
||||
let fixture: ComponentFixture<ActionsTwoAddActionConditionComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ActionsTwoAddActionConditionComponent],
|
||||
});
|
||||
fixture = TestBed.createComponent(ActionsTwoAddActionConditionComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,343 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { InputModule } from 'src/app/modules/input/input.module';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
Observable,
|
||||
catchError,
|
||||
defer,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
ReplaySubject,
|
||||
ObservedValueOf,
|
||||
switchMap,
|
||||
combineLatestWith,
|
||||
OperatorFunction,
|
||||
} from 'rxjs';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { ActionService } from 'src/app/services/action.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { atLeastOneFieldValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators';
|
||||
import { Message } from '@bufbuild/protobuf';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Condition } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
|
||||
import { startWith } from 'rxjs/operators';
|
||||
|
||||
export type ConditionType = NonNullable<Condition['conditionType']['case']>;
|
||||
export type ConditionTypeValue<T extends ConditionType> = Omit<
|
||||
NonNullable<Extract<Condition['conditionType'], { case: T }>['value']>,
|
||||
// we remove the message keys so $typeName is not required
|
||||
keyof Message
|
||||
>;
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'cnsl-actions-two-add-action-condition',
|
||||
templateUrl: './actions-two-add-action-condition.component.html',
|
||||
styleUrls: ['./actions-two-add-action-condition.component.scss'],
|
||||
imports: [
|
||||
TranslateModule,
|
||||
MatRadioModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
InputModule,
|
||||
MatAutocompleteModule,
|
||||
MatCheckboxModule,
|
||||
FormsModule,
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
})
|
||||
export class ActionsTwoAddActionConditionComponent<T extends ConditionType = ConditionType> {
|
||||
@Input({ required: true }) public set conditionType(conditionType: T) {
|
||||
this.conditionType$.next(conditionType);
|
||||
}
|
||||
@Output() public readonly back = new EventEmitter<void>();
|
||||
@Output() public readonly continue = new EventEmitter<ConditionTypeValue<T>>();
|
||||
|
||||
private readonly conditionType$ = new ReplaySubject<T>(1);
|
||||
protected readonly form$: ReturnType<typeof this.buildForm>;
|
||||
|
||||
protected readonly executionServices$: Observable<string[]>;
|
||||
protected readonly executionMethods$: Observable<string[]>;
|
||||
protected readonly executionFunctions$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
private readonly fb: FormBuilder,
|
||||
private readonly actionService: ActionService,
|
||||
private readonly toast: ToastService,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
) {
|
||||
this.form$ = this.buildForm().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
this.executionServices$ = this.listExecutionServices(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
this.executionMethods$ = this.listExecutionMethods(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
this.executionFunctions$ = this.listExecutionFunctions(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
||||
}
|
||||
|
||||
public buildForm() {
|
||||
return this.conditionType$.pipe(
|
||||
switchMap((conditionType) => {
|
||||
if (conditionType === 'event') {
|
||||
return this.buildEventForm();
|
||||
}
|
||||
if (conditionType === 'function') {
|
||||
return this.buildFunctionForm();
|
||||
}
|
||||
return this.buildRequestOrResponseForm(conditionType);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private buildRequestOrResponseForm<T extends 'request' | 'response'>(requestOrResponse: T) {
|
||||
const formFactory = () => ({
|
||||
case: requestOrResponse,
|
||||
form: this.fb.group(
|
||||
{
|
||||
all: new FormControl<boolean>(false, { nonNullable: true }),
|
||||
service: new FormControl<string>('', { nonNullable: true }),
|
||||
method: new FormControl<string>('', { nonNullable: true }),
|
||||
},
|
||||
{
|
||||
validators: atLeastOneFieldValidator(['all', 'service', 'method']),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return new Observable<ReturnType<typeof formFactory>>((obs) => {
|
||||
const form = formFactory();
|
||||
obs.next(form);
|
||||
|
||||
const { all, service, method } = form.form.controls;
|
||||
return all.valueChanges
|
||||
.pipe(
|
||||
map(() => all.value),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((all) => {
|
||||
this.toggleFormControl(service, !all);
|
||||
this.toggleFormControl(method, !all);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public buildFunctionForm() {
|
||||
return of({
|
||||
case: 'function' as const,
|
||||
form: this.fb.group({
|
||||
name: new FormControl<string>('', { nonNullable: true, validators: [requiredValidator] }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public buildEventForm() {
|
||||
const formFactory = () => ({
|
||||
case: 'event' as const,
|
||||
form: this.fb.group({
|
||||
all: new FormControl<boolean>(false, { nonNullable: true }),
|
||||
group: new FormControl<string>('', { nonNullable: true }),
|
||||
event: new FormControl<string>('', { nonNullable: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
return new Observable<ReturnType<typeof formFactory>>((obs) => {
|
||||
const form = formFactory();
|
||||
obs.next(form);
|
||||
|
||||
const { all, group, event } = form.form.controls;
|
||||
return all.valueChanges
|
||||
.pipe(
|
||||
map(() => all.value),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((all) => {
|
||||
this.toggleFormControl(group, !all);
|
||||
this.toggleFormControl(event, !all);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private toggleFormControl(control: FormControl, enabled: boolean) {
|
||||
if (enabled) {
|
||||
control.enable();
|
||||
} else {
|
||||
control.disable();
|
||||
}
|
||||
}
|
||||
|
||||
private listExecutionServices(form$: typeof this.form$) {
|
||||
return defer(() => this.actionService.listExecutionServices({})).pipe(
|
||||
map(({ services }) => services),
|
||||
this.formFilter(form$, (form) => {
|
||||
if ('service' in form.form.controls) {
|
||||
return form.form.controls.service;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.toast.showError(error);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private listExecutionFunctions(form$: typeof this.form$) {
|
||||
return defer(() => this.actionService.listExecutionFunctions({})).pipe(
|
||||
map(({ functions }) => functions),
|
||||
this.formFilter(form$, (form) => {
|
||||
if (form.case !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return form.form.controls.name;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.toast.showError(error);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private listExecutionMethods(form$: typeof this.form$) {
|
||||
return defer(() => this.actionService.listExecutionMethods({})).pipe(
|
||||
map(({ methods }) => methods),
|
||||
this.formFilter(form$, (form) => {
|
||||
if ('method' in form.form.controls) {
|
||||
return form.form.controls.method;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
// we also filter by service name
|
||||
this.formFilter(form$, (form) => {
|
||||
if ('service' in form.form.controls) {
|
||||
return form.form.controls.service;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.toast.showError(error);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private formFilter(
|
||||
form$: typeof this.form$,
|
||||
getter: (form: ObservedValueOf<typeof this.form$>) => FormControl<string> | undefined,
|
||||
): OperatorFunction<string[], string[]> {
|
||||
const filterValue$ = form$.pipe(
|
||||
map(getter),
|
||||
switchMap((control) => {
|
||||
if (!control) {
|
||||
return of('');
|
||||
}
|
||||
|
||||
return control.valueChanges.pipe(
|
||||
startWith(control.value),
|
||||
map((value) => value.toLowerCase()),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return (obs) =>
|
||||
obs.pipe(
|
||||
combineLatestWith(filterValue$),
|
||||
map(([values, filterValue]) => values.filter((v) => v.toLowerCase().includes(filterValue))),
|
||||
);
|
||||
}
|
||||
|
||||
protected submit(form: ObservedValueOf<typeof this.form$>) {
|
||||
if (form.case === 'request' || form.case === 'response') {
|
||||
(this as unknown as ActionsTwoAddActionConditionComponent<'request' | 'response'>).submitRequestOrResponse(form);
|
||||
} else if (form.case === 'event') {
|
||||
(this as unknown as ActionsTwoAddActionConditionComponent<'event'>).submitEvent(form);
|
||||
} else if (form.case === 'function') {
|
||||
(this as unknown as ActionsTwoAddActionConditionComponent<'function'>).submitFunction(form);
|
||||
}
|
||||
}
|
||||
|
||||
private submitRequestOrResponse(
|
||||
this: ActionsTwoAddActionConditionComponent<'request' | 'response'>,
|
||||
{ form }: ObservedValueOf<ReturnType<typeof this.buildRequestOrResponseForm>>,
|
||||
) {
|
||||
const { all, service, method } = form.getRawValue();
|
||||
|
||||
if (all) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'all',
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
} else if (method) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'method',
|
||||
value: method,
|
||||
},
|
||||
});
|
||||
} else if (service) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'service',
|
||||
value: service,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private submitEvent(
|
||||
this: ActionsTwoAddActionConditionComponent<'event'>,
|
||||
{ form }: ObservedValueOf<ReturnType<typeof this.buildEventForm>>,
|
||||
) {
|
||||
const { all, event, group } = form.getRawValue();
|
||||
if (all) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'all',
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
} else if (event) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'event',
|
||||
value: event,
|
||||
},
|
||||
});
|
||||
} else if (group) {
|
||||
this.continue.emit({
|
||||
condition: {
|
||||
case: 'group',
|
||||
value: group,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private submitFunction(
|
||||
this: ActionsTwoAddActionConditionComponent<'function'>,
|
||||
{ form }: ObservedValueOf<ReturnType<typeof this.buildFunctionForm>>,
|
||||
) {
|
||||
const { name } = form.getRawValue();
|
||||
this.continue.emit({
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
<h2 *ngIf="!data.execution" mat-dialog-title>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CREATE_TITLE' | translate }}</h2>
|
||||
<h2 *ngIf="data.execution" mat-dialog-title>{{ 'ACTIONSTWO.EXECUTION.DIALOG.UPDATE_TITLE' | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="framework-change-block" [ngSwitch]="page()">
|
||||
<cnsl-actions-two-add-action-type
|
||||
*ngSwitchCase="Page.Type"
|
||||
[initialValue]="typeSignal()"
|
||||
(back)="back()"
|
||||
(continue)="typeSignal.set($event); continue()"
|
||||
></cnsl-actions-two-add-action-type>
|
||||
|
||||
<cnsl-actions-two-add-action-condition
|
||||
*ngSwitchCase="Page.Condition"
|
||||
[conditionType]="typeSignal()"
|
||||
(back)="back()"
|
||||
(continue)="conditionSignal.set({ 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>
|
@@ -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;
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target.component';
|
||||
|
||||
describe('ActionsTwoAddActionTargetComponent', () => {
|
||||
let component: ActionsTwoAddActionTargetComponent;
|
||||
let fixture: ComponentFixture<ActionsTwoAddActionTargetComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ActionsTwoAddActionTargetComponent],
|
||||
});
|
||||
fixture = TestBed.createComponent(ActionsTwoAddActionTargetComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.DESCRIPTION' | translate }}</p>
|
||||
|
||||
<form class="form-grid" [formGroup]="typeForm" (ngSubmit)="submit()">
|
||||
<div class="executionType">
|
||||
<mat-radio-group class="execution-radio-group" aria-label="Select an option" formControlName="executionType">
|
||||
<mat-radio-button class="execution-radio-button" [value]="'request'">
|
||||
<div class="execution-type-text">
|
||||
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.REQUEST.TITLE' | translate }}</span>
|
||||
<span class="description cnsl-secondary-text">{{
|
||||
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.REQUEST.DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
<mat-radio-button class="execution-radio-button" [value]="'response'"
|
||||
><div class="execution-type-text">
|
||||
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.RESPONSE.TITLE' | translate }}</span>
|
||||
<span class="description cnsl-secondary-text">{{
|
||||
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.RESPONSE.DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div></mat-radio-button
|
||||
>
|
||||
<mat-radio-button class="execution-radio-button" [value]="'event'"
|
||||
><div class="execution-type-text">
|
||||
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.EVENTS.TITLE' | translate }}</span>
|
||||
<span class="description cnsl-secondary-text">{{
|
||||
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.EVENTS.DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div></mat-radio-button
|
||||
>
|
||||
<mat-radio-button class="execution-radio-button" [value]="'function'"
|
||||
><div class="execution-type-text">
|
||||
<span>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TYPE.FUNCTIONS.TITLE' | translate }}</span>
|
||||
<span class="description cnsl-secondary-text">{{
|
||||
'ACTIONSTWO.EXECUTION.DIALOG.TYPE.FUNCTIONS.DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div></mat-radio-button
|
||||
>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button mat-stroked-button (click)="back.emit()">
|
||||
{{ 'ACTIONS.CANCEL' | translate }}
|
||||
</button>
|
||||
<button color="primary" mat-raised-button type="submit">
|
||||
{{ 'ACTIONS.CONTINUE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@@ -0,0 +1,20 @@
|
||||
.execution-radio-group {
|
||||
.execution-radio-button {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.execution-type-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.description {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActionsTwoAddActionTypeComponent } from './actions-two-add-action-type.component';
|
||||
|
||||
describe('ActionsTwoAddActionTypeComponent', () => {
|
||||
let component: ActionsTwoAddActionTypeComponent;
|
||||
let fixture: ComponentFixture<ActionsTwoAddActionTypeComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ActionsTwoAddActionTypeComponent],
|
||||
});
|
||||
fixture = TestBed.createComponent(ActionsTwoAddActionTypeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,53 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, signal } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Observable, Subject, map, of, startWith, switchMap, tap } from 'rxjs';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { ConditionType } from '../actions-two-add-action-condition/actions-two-add-action-condition.component';
|
||||
|
||||
// export enum ExecutionType {
|
||||
// REQUEST = 'request',
|
||||
// RESPONSE = 'response',
|
||||
// EVENTS = 'event',
|
||||
// FUNCTIONS = 'function',
|
||||
// }
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'cnsl-actions-two-add-action-type',
|
||||
templateUrl: './actions-two-add-action-type.component.html',
|
||||
styleUrls: ['./actions-two-add-action-type.component.scss'],
|
||||
imports: [TranslateModule, MatRadioModule, RouterModule, ReactiveFormsModule, FormsModule, CommonModule, MatButtonModule],
|
||||
})
|
||||
export class ActionsTwoAddActionTypeComponent {
|
||||
protected readonly typeForm: ReturnType<typeof this.buildActionTypeForm> = this.buildActionTypeForm();
|
||||
@Output() public readonly typeChanges$: Observable<ConditionType>;
|
||||
|
||||
@Output() public readonly back = new EventEmitter<void>();
|
||||
@Output() public readonly continue = new EventEmitter<ConditionType>();
|
||||
@Input() public set initialValue(type: ConditionType) {
|
||||
this.typeForm.get('executionType')!.setValue(type);
|
||||
}
|
||||
|
||||
constructor(private readonly fb: FormBuilder) {
|
||||
this.typeChanges$ = this.typeForm.get('executionType')!.valueChanges.pipe(
|
||||
startWith(this.typeForm.get('executionType')!.value), // Emit the initial value
|
||||
);
|
||||
}
|
||||
|
||||
public buildActionTypeForm() {
|
||||
return this.fb.group({
|
||||
executionType: new FormControl<ConditionType>('request', {
|
||||
nonNullable: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.continue.emit(this.typeForm.get('executionType')!.value);
|
||||
}
|
||||
}
|
@@ -0,0 +1,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>
|
@@ -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;
|
||||
}
|
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@@ -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
Reference in New Issue
Block a user