feat(console): integrate frontend (#95)

* feat: console frontend

* chore(dependabot): cycle and npm

* chore: rename citadel to zitadel, remove generated files

* chore: delete go files

* chore(frontend): ci steps

* chore: remove docker and envoy files

* chore: remove docker file

* chore: working dir

* chore: run proto build

* add console start

* chore: restructure folders

* chore: remove gui build

* statikFs

* generate proto for console

* add statik import

* import

* chore: try statik

* chore: path

* chore: path

* chore: script in root

* chore: order build steps

* chore: go get

* chore: folder traversal

* chore: non empty test file

* chore: gitignore

* chore: gitignore

* chore: statik path

* chore: switch to failing FE build

* fix: build

* fix: project-grant-test

* fix: rm test

* add statik.go

* go mod tidy

* chore: place test, seperate test from build

* chore: lint all the world

* chore: ci the world instead

* chore: tune docker

* chore: undo container test

* chore: fix run

* chore: docker build

* chore: test docker build

* chore: go build flags

* finaly

* fix caos_local

* go mod

Co-authored-by: Livio Amstutz <livio.a@gmail.com>
Co-authored-by: Max Peintner <max@caos.ch>
This commit is contained in:
Florian Forster 2020-05-13 14:41:43 +02:00 committed by GitHub
parent 9e32740eb8
commit 92a294f5c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
375 changed files with 97826 additions and 52 deletions

View File

@ -2,7 +2,13 @@ version: 1
update_configs:
- package_manager: "go:modules"
directory: "/"
update_schedule: "daily"
update_schedule: "weekly"
commit_message:
prefix: "chore"
include_scope: true
- package_manager: "javascript"
directory: "/console"
update_schedule: "weekly"
commit_message:
prefix: "chore"
include_scope: true

View File

@ -10,24 +10,51 @@ env:
jobs:
angular: # TODO Implement proper build and cache and coverage upload
angular-test: # will be added later on
runs-on: ubuntu-18.04
defaults:
run:
working-directory: ./console
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VERSION }}
- run: echo "hodor" > hodor.txt
# - run: npm ci
# - run: npm run lint
# - run: npm run prodbuild
# - run: npm test
- run: npm ci
#- run: npm test
- run: echo "replace me with real test"
angular-lint: # will be added later on
runs-on: ubuntu-18.04
defaults:
run:
working-directory: ./console
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run lint
angular-build:
runs-on: ubuntu-18.04
defaults:
run:
working-directory: ./console
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run prodbuild
- uses: actions/upload-artifact@v1
with:
name: angular
path: hodor.txt
go: # TODO Implement proper build and cache
path: console/dist/console
go-test:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
@ -35,37 +62,59 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- run: go test -race -v -coverprofile=profile.cov ./...
- run: go build -o zitadel cmd/zitadel/main.go
- uses: actions/upload-artifact@v1
with:
name: go-coverage
path: profile.cov
- uses: actions/upload-artifact@v1
with:
name: go-binary
path: zitadel
- uses: codecov/codecov-action@v1
with:
file: ./profile.cov
name: codecov-go
container-prod: # Artifact paths need better place
go-lint:
runs-on: ubuntu-18.04
needs: [angular, go]
steps:
- name: Source checkout
uses: actions/checkout@v2
- uses: actions/checkout@v2
- uses: actions/setup-go@v2-beta
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
- run: echo "replace me with real lint"
go-build:
runs-on: ubuntu-18.04
needs: [angular-build, angular-test, angular-lint, go-test] ### We need the artifact from the angular build and that's why we wait here
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2-beta
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/download-artifact@v1
with:
name: angular
path: .build/angular
path: console/dist/app
- run: go get github.com/rakyll/statik
- run: ./build/console/generate-static.sh
- run: cat pkg/console/statik/statik.go
- run: CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o zitadel cmd/zitadel/main.go
- uses: actions/upload-artifact@v1
with:
name: go-binary
path: zitadel
container-prod:
runs-on: ubuntu-18.04
needs: go-build
steps:
- name: Source checkout
uses: actions/checkout@v2
- uses: actions/download-artifact@v1
with:
name: go-binary
path: .build/go
- uses: docker/build-push-action@v1
with:
dockerfile: build/dockerfile-prod
dockerfile: build/docker/prod
username: ${{ github.actor }}
password: ${{ github.token }}
registry: ${{ env.REGISTRY }}
@ -75,7 +124,7 @@ jobs:
container-vulnerability-scan:
runs-on: ubuntu-18.04
needs: [container-prod]
needs: container-prod
steps:
- name: Source checkout
uses: actions/checkout@v2
@ -89,7 +138,7 @@ jobs:
- uses: anchore/scan-action@master
with:
image-reference: "${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE }}:${{ steps.vars.outputs.sha_short }}"
dockerfile-path: "./build/dockerfile-prod"
dockerfile-path: "./build/docker/prod"
fail-build: false
- name: anchore inline scan JSON results
run: for j in `ls ./anchore-reports/*.json`; do echo "---- ${j} ----"; cat ${j}; echo; done
@ -98,9 +147,9 @@ jobs:
name: anchore-reports
path: ./anchore-reports/
container-test: # TODO Implement proper test
container-test:
runs-on: ubuntu-18.04
needs: [container-prod]
needs: container-prod
steps:
- name: Source checkout
uses: actions/checkout@v2
@ -112,7 +161,7 @@ jobs:
- name: Docker Login
run: docker login $REGISTRY -u $GITHUB_ACTOR -p $GITHUB_TOKEN
- name: Docker Run Test
run: docker run $REGISTRY/$GITHUB_REPOSITORY/$IMAGE:${{ steps.vars.outputs.sha_short }} /bin/sh -c "ls -la ./app"
run: echo "replace me with real test"
release:
runs-on: ubuntu-18.04

1
.gitignore vendored
View File

@ -28,6 +28,7 @@ key.json
.keys/*
cockroach-data/*
.build/
#binaries
cmd/zitadel/zitadel

5
build/build.md Normal file
View File

@ -0,0 +1,5 @@
# Build
## Console
## Docker

46
build/console/generate-grpc.sh Executable file
View File

@ -0,0 +1,46 @@
#! /bin/sh
set -eux
GEN_PATH=${GOPATH}/src/github.com/caos/zitadel/console/src/app/proto/generated
echo "Remove old files"
rm -rf $GEN_PATH
echo "Create folders"
mkdir -p $GEN_PATH
echo "Generate grpc"
protoc \
-I=/usr/local/include \
-I=${GOPATH}/src/github.com/caos/zitadel/pkg/management/api/proto \
-I=${GOPATH}/src/github.com/caos/zitadel/pkg/auth/api/proto \
-I=${GOPATH}/src/github.com/caos/zitadel/pkg/admin/api/proto \
-I=${GOPATH}/src/github.com/caos/zitadel/internal/protoc/protoc-gen-authoption \
-I=${GOPATH}/src/github.com/caos/zitadel/console/node_modules/google-proto-files \
-I=${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \
-I=${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway \
--js_out=import_style=commonjs,binary:$GEN_PATH \
--grpc-web_out=import_style=commonjs+dts,mode=grpcweb:$GEN_PATH \
${GOPATH}/src/github.com/caos/zitadel/pkg/management/api/proto/*.proto \
${GOPATH}/src/github.com/caos/zitadel/pkg/admin/api/proto/*.proto \
${GOPATH}/src/github.com/caos/zitadel/pkg/auth/api/proto/*.proto
echo "Generate annotations js file (compatibility)"
mkdir -p $GEN_PATH/google/api/
touch $GEN_PATH/google/api/annotations_pb.js
echo "export {}" > $GEN_PATH/google/api/annotations_pb.d.ts
mkdir -p $GEN_PATH/validate
touch $GEN_PATH/validate/validate_pb.js
echo "export {}" > $GEN_PATH/validate/validate_pb.d.ts
mkdir -p $GEN_PATH/protoc-gen-swagger/options
touch $GEN_PATH/protoc-gen-swagger/options/annotations_pb.js
echo "export {}" > $GEN_PATH/protoc-gen-swagger/options/annotations_pb.d.ts
mkdir -p $GEN_PATH/authoption
touch $GEN_PATH/authoption/options_pb.js
echo "export {}" > $GEN_PATH/authoption/options_pb.d.ts

View File

@ -0,0 +1,5 @@
#! /bin/sh
set -eux
go generate pkg/console/console.go

18
build/docker/prod Normal file
View File

@ -0,0 +1,18 @@
#
FROM alpine:latest as prepare
RUN adduser -D zitadel
COPY .build/go/zitadel /
COPY cmd/zitadel/*.yaml /
RUN chmod a+x /zitadel
#
FROM scratch as final
COPY --from=prepare /etc/passwd /etc/passwd
COPY --from=prepare / /
USER zitadel
HEALTHCHECK NONE
ENTRYPOINT ["/zitadel"]
## TODO enable CMD

View File

@ -1,4 +0,0 @@
FROM alpine:latest
COPY .build/angular /app/console
COPY .build/go /app

View File

@ -1,10 +0,0 @@
# FROM sratch
FROM alpine:latest
RUN addgroup -S zitadel && adduser -S zitadel -G zitadel
USER zitadel
COPY .build/angular /app/console
COPY .build/go /app

13
console/.editorconfig Normal file
View File

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

45
console/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# 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
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.vscode/settings.json

5
console/.prettierrc Normal file
View File

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

27
console/README.md Normal file
View File

@ -0,0 +1,27 @@
# Console
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.3.20.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

137
console/angular.json Normal file
View File

@ -0,0 +1,137 @@
{
"$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": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/console",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
"src/404.html"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "4mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "console:build"
},
"configurations": {
"production": {
"browserTarget": "console:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "console:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/pink-bluegrey.css",
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "console:serve"
},
"configurations": {
"production": {
"devServerTarget": "console:serve:production"
}
}
}
}
}
},
"defaultProject": "console",
"cli": {
"analytics": "2b4e8e6c-f053-4562-b7a6-00c6c06a6791"
}
}

12
console/browserslist Normal file
View File

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

View File

@ -0,0 +1,32 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -0,0 +1,26 @@
import { browser, logging } from 'protractor';
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('console app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(
jasmine.objectContaining({
level: logging.Level.SEVERE
} as logging.Entry)
);
});
});

11
console/e2e/src/app.po.ts Normal file
View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get(browser.baseUrl) as Promise<any>;
}
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>;
}
}

13
console/e2e/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

32
console/karma.conf.js Normal file
View File

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

43
console/ngsw-config.json Normal file
View File

@ -0,0 +1,43 @@
{
"$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",
"/*.css",
"/*.js"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(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"
}
}
]
}

19886
console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

71
console/package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "console",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"prestart": "npm run proto",
"start": "ng serve",
"build": "ng build",
"prodbuild": "ng build --prod",
"test": "ng test",
"lint": "ng lint && stylelint './projects/**/*.scss' --syntax scss",
"e2e": "ng e2e",
"proto": "./etc/generate-grpc.sh || echo 'could not generate grpc'"
},
"private": true,
"dependencies": {
"@angular/animations": "~9.1.0",
"@angular/cdk": "~9.0.1",
"@angular/common": "~9.1.0",
"@angular/compiler": "~9.1.0",
"@angular/core": "~9.1.0",
"@angular/forms": "~9.1.0",
"@angular/material": "^9.0.1",
"@angular/platform-browser": "~9.1.0",
"@angular/platform-browser-dynamic": "~9.1.0",
"@angular/router": "~9.1.0",
"@angular/service-worker": "~9.1.0",
"@ngx-translate/core": "^12.1.2",
"@ngx-translate/http-loader": "^4.0.0",
"@types/google-protobuf": "^3.7.2",
"@types/uuid": "^7.0.0",
"angular-oauth2-oidc": "^8.0.4",
"angularx-qrcode": "^2.1.0",
"cors": "^2.8.5",
"google-proto-files": "^1.1.1",
"google-protobuf": "^3.11.4",
"grpc": "^1.24.2",
"grpc-web": "^1.0.7",
"hammerjs": "^2.0.8",
"moment": "^2.24.0",
"ngx-moment": "^3.5.0",
"prettier-stylelint": "^0.4.2",
"rxjs": "~6.5.4",
"ts-protoc-gen": "^0.12.0",
"tslib": "^1.10.0",
"uuid": "^7.0.1",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.901.0",
"@angular/cli": "~9.1.0",
"@angular/compiler-cli": "~9.1.0",
"@angular/language-service": "~9.1.0",
"@types/jasmine": "~3.5.5",
"@types/jasminewd2": "~2.0.3",
"@types/node": "^13.7.4",
"codelyzer": "^5.1.2",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.4.1",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~2.1.1",
"karma-jasmine": "~3.1.1",
"karma-jasmine-html-reporter": "^1.4.0",
"prettier": "^1.19.1",
"protractor": "~5.4.0",
"ts-node": "~8.6.2",
"tslint": "~6.1.0",
"typescript": "^3.7.5"
}
}

23
console/src/404.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>caos console</title>
<script>
sessionStorage.redirect = location.href;
</script>
<meta http-equiv="refresh" content="0;URL='/'"></meta>
</head>
<body>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</body>
</html>

View File

@ -0,0 +1,56 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';
import { RoleGuard } from './guards/role.guard';
const routes: Routes = [
{
path: '',
loadChildren: () => import('./pages/home/home.module').then(m => m.HomeModule),
canActivate: [AuthGuard],
},
{
path: 'projects',
loadChildren: () => import('./pages/projects/projects.module').then(m => m.ProjectsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['project.read'],
},
},
{
path: 'user',
loadChildren: () => import('./pages/user-detail/user-detail.module').then(m => m.UserDetailModule),
canActivate: [AuthGuard],
},
{
path: 'users',
loadChildren: () => import('./pages/user-list/user-list.module').then(m => m.UserListModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['user.read'],
},
},
{
path: 'orgs',
loadChildren: () => import('./pages/orgs/orgs.module').then(m => m.OrgsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['org.read'],
},
},
{
path: 'signedout',
loadChildren: () => import('./pages/signedout/signedout.module').then(m => m.SignedoutModule),
},
{
path: '**',
redirectTo: '/',
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule { }

View File

@ -0,0 +1,101 @@
<mat-toolbar class="root-header">
<button aria-label="Toggle sidenav" mat-icon-button (click)="drawer.toggle()">
<mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
</button>
<a *ngIf="(isHandset$ | async) == false" class="title ailerons" [routerLink]="['/']">
<img class="logo" *ngIf="componentCssClass == 'dark-theme'; else lighttheme"
src="../assets/images/zitadel-logo-oneline-darkdesign.svg" />
<ng-template #lighttheme>
<img class="logo" src="../assets/images/zitadel-logo-oneline-lightdesign.svg" />
</ng-template>
</a>
<button (click)="loadOrgs()" *ngIf="profile?.id && org" mat-button
[matMenuTriggerFor]="menu">{{org?.name ? org.name : 'NO NAME'}}
<mat-icon>
arrow_drop_down</mat-icon>
</button>
<mat-menu #menu="matMenu">
<mat-progress-bar *ngIf="orgLoading" color="accent" mode="indeterminate"></mat-progress-bar>
<button class="show-all" mat-menu-item [routerLink]="[ '/orgs' ]">Show all organizations</button>
<button [ngClass]="{'active': temporg.id === org?.id}" [disabled]="!temporg.id" *ngFor="let temporg of orgs"
mat-menu-item (click)="setActiveOrg(temporg)">
<mat-icon class="avatar">business</mat-icon>
{{temporg?.name ? temporg.name : 'NO NAME'}}
</button>
<ng-template appHasRole [appHasRole]="['iam.write']">
<button mat-menu-item [routerLink]="[ '/orgs/create' ]">
<mat-icon class="avatar">add</mat-icon>
{{'MENU.NEWORG' | translate}}
</button>
</ng-template>
</mat-menu>
<span class="fill-space"></span>
<div matTooltip="IAM user" class="iamreadwrite"></div>
<div (clickOutside)="closeAccountCard()" class="icon-container">
<div class="avatar-wrapper dontcloseonclick" (click)="showAccount = !showAccount">
<div class="avatar-circle dontcloseonclick" [ngClass]="{'active': showAccount}">
<img class="avatar dontcloseonclick" *ngIf="componentCssClass == 'dark-theme'; else lighttheme"
src="../assets/images/account-circle-outline.png" />
<ng-template #lighttheme>
<img class="avatar dontcloseonclick" src="../assets/images/account-circle-outline-dark.png" />
</ng-template>
</div>
</div>
<app-accounts-card @accounts class="a_card mat-elevation-z5" *ngIf="showAccount" (close)="showAccount = false"
[profile]="profile" [iamuser]="iamreadwrite">
</app-accounts-card>
</div>
</mat-toolbar>
<mat-drawer-container *ngIf="(authService.user | async) || {} as user" class="main-container">
<mat-drawer #drawer class="side" [mode]="(isHandset$ | async) ? 'over' : 'side'" [opened]="!(isHandset$ | async)">
<div class="side-column">
<div class="list">
<a *ngIf="authService.authenticationChanged | async" class="nav-item" [routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: true }" [routerLink]="['/user/me']">
<mat-icon class="icon" svgIcon="mdi_account_circle_outline"></mat-icon>
<span class="label">{{ 'MENU.PERSONAL_INFO' | translate }}</span>
</a>
<a *ngIf="showOrgSection && org?.id" class="nav-item" [routerLinkActive]="['active']"
[routerLink]="[ '/orgs', org.id]">
<mat-icon class="icon">business</mat-icon>
<span class="label">{{org?.name ? org.name : 'MENU.ORGANIZATION' | translate}}</span>
</a>
<a *ngIf="showProjectSection" class="nav-item" [routerLinkActive]="['active']"
[routerLink]="[ '/projects']">
<mat-icon class="icon">folder_open</mat-icon>
<span class="label">{{ 'MENU.PROJECT' | translate }}</span>
</a>
<a *ngIf="showUserSection" class="nav-item" [routerLinkActive]="['active']" [routerLink]="[ '/users']"
[routerLinkActiveOptions]="{ exact: true }">
<mat-icon class="icon">people_outline</mat-icon>
<span class="label">{{ 'MENU.USER' | translate }}</span>
</a>
<span class="fill-space"></span>
<a class="nav-item" (click)="authService.signout()">
<mat-icon class="icon" svgIcon="mdi_logout"></mat-icon>
<span class="label">{{ 'MENU.LOGOUT' | translate }}</span>
</a>
</div>
<span class="fill-space"></span>
<div class="footer">
<a href="https://caos.ch/impressum/" target="_blank" rel="noreferrer">AGB</a>
<a href="https://caos.ch/impressum/" target="_blank" rel="noreferrer">Impressum</a></div>
</div>
</mat-drawer>
<mat-drawer-content class="content">
<div class="router" [@routeAnimations]="prepareRoute(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
</mat-drawer-content>
</mat-drawer-container>

View File

@ -0,0 +1,247 @@
.root-header {
position: relative;
z-index: 100;
display: flex;
height: 60px;
align-items: center;
padding: 0 1rem;
.logo {
height: 40px;
width: auto;
}
.title {
text-decoration: none;
color: white;
font-size: 1.2rem;
font-weight: 400;
margin-left: 1rem;
line-height: 1.2rem;
font-family: 'Rubik';
margin-right: 1rem;
}
.context-menu {
border-radius: .5rem;
background-color: #2d2e30;
}
.fill-space {
flex: 1;
}
.iamreadwrite {
height: 8px;
width: 8px;
border-radius: 50%;
background: linear-gradient(to bottom right, rgb(240,140,53), rgb(233, 60, 231));
}
.icon-container {
display: flex;
justify-content: space-between;
position: relative;
user-select: none;
.docs {
text-decoration: none;
font-size: 1.4rem;
font-family: 'ailerons', sans-serif;
}
.avatar-wrapper {
display: flex;
align-items: center;
color: white;
.avatar-circle {
height: 35px;
width: 35px;
background-color: transparent;
border-radius: 50%;
animation: background-color .2s ease-in;
display: flex;
flex-direction: column;
align-items: center;
.avatar {
display: block;
margin: auto auto;
height: 30px;
width: 30px;
line-height: 35px;
font-size: 30px;
border-radius: 50%;
text-align: center;
fill: white;
* {
fill: white;
color: white;
}
}
&:hover, &.active {
cursor: pointer;
background-color: #ffffff20;
}
}
.name {
font-size: 1rem;
font-weight: 400;
}
}
.a_card {
position: absolute;
top: 60px;
right: 0;
overflow: hidden;
}
}
}
.main-container {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
width: 100%;
.side {
width: 300px;
border-right: none;
.side-column {
height: calc(100vh - 70px);
display: flex;
flex-direction: column;
align-items: stretch;
.list {
width: 100%;
display: flex;
flex-direction: column;
height: 100%;
margin-top: 2rem;
.logout-icon {
margin-left: 1rem;
}
.fill-space {
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
text-decoration: none;
cursor: pointer;
padding: 0.2rem 1rem;
// color: inherit;
margin-right: 0.5rem;
padding-right: 2rem;
.icon {
margin: 0.5rem 1rem;
}
.label {
margin-bottom: 0;
font-family: 'Rubik';
font-weight: 500;
font-size: .9rem;
}
&:hover {
background-color: #00000010;
border-top-right-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
}
&.active {
border-top-right-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
}
}
.project-status {
padding: 1rem;
}
}
.fill-space {
flex: 1 1 auto;
}
.logout-button {
margin-bottom: 1rem;
}
}
.primary-button {
margin: 1rem;
border-radius: 1.5rem;
height: 2.5rem;
padding: 0 1rem;
}
}
.content {
display: flex;
flex-direction: column;
.router {
height: 100%;
overflow: auto;
}
}
.theme-section {
display: block;
padding: 0 .5rem;
margin-top: 2rem;
align-self: flex-start;
border-radius: 1rem;
.round-light {
display: inline-block;
border-radius: 50%;
height: 30px;
width: 30px;
margin: .5rem;
cursor: pointer;
background: linear-gradient(315deg, #e6e6e6, #ffffff);
}
.round-dark {
display: inline-block;
border-radius: 50%;
height: 30px;
width: 30px;
margin: .5rem;
cursor: pointer;
background: linear-gradient(315deg, #000000, #000000);
}
}
}
.footer {
padding: 1rem;
a {
cursor: pointer;
text-decoration: none;
color: #81868a;
font-size: .8rem;
display: block;
margin-bottom: 1px;
font-weight: 300;
&:hover {
text-decoration: underline;
}
}
}

View File

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

View File

@ -0,0 +1,299 @@
import { animate, group, query, style, transition, trigger } from '@angular/animations';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, HostBinding, OnDestroy, ViewChild } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser';
import { Router, RouterOutlet } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { Org, UserProfile } from './proto/generated/auth_pb';
import { AuthUserService } from './services/auth-user.service';
import { AuthService } from './services/auth.service';
import { ThemeService } from './services/theme.service';
import { ToastService } from './services/toast.service';
import { UpdateService } from './services/update.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [
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,
}),
),
]),
]),
trigger('routeAnimations', [
transition('HomePage => AddPage', [
style({ transform: 'translateX(100%)' }),
animate('250ms ease-in-out', style({ transform: 'translateX(0%)' })),
]),
transition('AddPage => HomePage', [animate('250ms', style({ transform: 'translateX(100%)' }))]),
transition('HomePage => DetailPage', [
query(':enter, :leave', style({ position: 'absolute', left: 0, right: 0 }), {
optional: true,
}),
group([
query(
':enter',
[
style({
transform: 'translateX(20%)',
opacity: 0.5,
}),
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,
},
),
]),
]),
]),
],
})
export class AppComponent implements OnDestroy {
@ViewChild('drawer')
public drawer!: MatDrawer;
public isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
.pipe(map(result => {
return result.matches;
}));
@HostBinding('class') public componentCssClass: string = 'dark-theme';
public showAccount: boolean = false;
public org!: Org.AsObject;
public orgs: Org.AsObject[] = [];
public profile!: UserProfile.AsObject;
public isDarkTheme: Observable<boolean> = of(true);
public orgLoading: boolean = false;
public showProjectSection: boolean = false;
public showOrgSection: boolean = false;
public showUserSection: boolean = false;
public iamreadwrite: boolean = false;
private authSub: Subscription = new Subscription();
private orgSub: Subscription = new Subscription();
constructor(
public translate: TranslateService,
public authService: AuthService,
private breakpointObserver: BreakpointObserver,
public overlayContainer: OverlayContainer,
private themeService: ThemeService,
public userService: AuthUserService,
public matIconRegistry: MatIconRegistry,
public domSanitizer: DomSanitizer,
private toast: ToastService,
private router: Router,
update: UpdateService,
) {
console.log('%cWait!', 'text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; color: #5282c1; font-size: 50px');
console.log('%cInserting something here could give attackers access to your caos 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_logout',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/logout.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(
'mdi_radar',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/radar.svg'),
);
this.matIconRegistry.addSvgIcon(
'mdi_account_circle_outline',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/account-circle-outline.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.orgSub = this.authService.activeOrgChanged.subscribe(org => {
this.org = org;
this.loadPermissions();
});
this.authSub = this.authService.authenticationChanged.subscribe((authenticated) => {
if (authenticated) {
// this.userService.GetMyzitadelPermissions().pipe(take(1)).subscribe(perm => console.log(perm.toObject()));
this.loadPermissions();
this.authService.GetActiveOrg().then(org => {
this.org = org;
});
}
});
const theme = localStorage.getItem('theme');
if (theme) {
this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = theme;
}
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.subscribe(thema => this.onSetTheme(thema ? 'dark-theme' : 'light-theme'));
}
public ngOnDestroy(): void {
this.authSub.unsubscribe();
this.orgSub.unsubscribe();
}
public loadPermissions(): void {
this.userService.isAllowed(['iam.read', 'iam.write'], true).subscribe(allowed => this.iamreadwrite = allowed);
this.userService.isAllowed(['org.read']).subscribe(allowed => this.showOrgSection = allowed);
this.userService.isAllowed(['project.read']).subscribe(allowed => this.showProjectSection = allowed);
this.userService.isAllowed(['user.read']).subscribe(allowed => this.showUserSection = allowed);
}
public loadOrgs(): void {
this.orgLoading = true;
this.userService.SearchMyProjectOrgs(10, 0).then(res => {
this.orgs = res.toObject().resultList;
this.orgLoading = false;
}).catch(error => {
this.toast.showError(error.message);
this.orgLoading = false;
});
}
public prepareRoute(outlet: RouterOutlet): boolean {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData.animation;
}
public closeAccountCard(): void {
if (this.showAccount) {
this.showAccount = false;
}
}
public onSetTheme(theme: string): void {
localStorage.setItem('theme', theme);
this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = theme;
}
private setLanguage(): void {
this.translate.addLangs(['en', 'de']);
this.translate.setDefaultLang('en');
this.authService.user.subscribe(userprofile => {
console.log(userprofile);
this.profile = userprofile;
const lang = userprofile.preferredLanguage.match(/en|de/) ? userprofile.preferredLanguage : 'en';
this.translate.use(lang);
});
}
public setActiveOrg(org: Org.AsObject): void {
this.org = org;
this.authService.setActiveOrg(org);
this.router.navigate(['/']);
}
}

View File

@ -0,0 +1,153 @@
import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import localeDe from '@angular/common/locales/de';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
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 { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { AuthConfig, OAuthModule, OAuthStorage } from 'angular-oauth2-oidc';
import { environment } from '../environments/environment';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HasRoleModule } from './directives/has-role/has-role.module';
import { OutsideClickModule } from './directives/outside-click/outside-click.module';
import { AccountsCardModule } from './modules/accounts-card/accounts-card.module';
import { SignedoutComponent } from './pages/signedout/signedout.component';
import { AuthUserService } from './services/auth-user.service';
import { AuthService } from './services/auth.service';
import { GrpcAuthInterceptor } from './services/grpc-auth.interceptor';
import { GRPC_INTERCEPTORS } from './services/grpc-interceptor';
import { GrpcOrgInterceptor } from './services/grpc-org.interceptor';
import { GrpcService } from './services/grpc.service';
import { StatehandlerProcessorService, StatehandlerProcessorServiceImpl } from './services/statehandler-processor.service';
import { StatehandlerService, StatehandlerServiceImpl } from './services/statehandler.service';
import { StorageService } from './services/storage.service';
import { ThemeService } from './services/theme.service';
registerLocaleData(localeDe);
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
return new TranslateHttpLoader(http);
}
const appInitializerFn = (grpcServ: GrpcService) => {
return () => {
return grpcServ.loadAppEnvironment();
};
};
const stateHandlerFn = (stateHandler: StatehandlerService) => {
return () => {
return stateHandler.initStateHandler();
};
};
export const authConfig: AuthConfig = {
redirectUri: window.location.origin + '/auth/callback',
scope: 'openid profile email', // offline_access
responseType: 'code',
// showDebugInformation: true,
oidc: true,
postLogoutRedirectUri: window.location.origin + '/signedout',
};
@NgModule({
declarations: [
AppComponent,
SignedoutComponent,
],
imports: [
AppRoutingModule,
CommonModule,
BrowserModule,
OverlayModule,
OAuthModule.forRoot({
resourceServer: {
allowedUrls: ['https://test.api.zitadel.caos.ch/caos.zitadel.auth.api.v1.AuthService', 'https://test.api.zitadel.caos.ch/oauth/v2/userinfo', 'https://test.api.zitadel.caos.ch/caos.zitadel.management.api.v1.ManagementService/', 'https://preview.api.zitadel.caos.ch'],
sendAccessToken: true,
},
}),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient],
},
}),
AccountsCardModule,
HasRoleModule,
BrowserAnimationsModule,
HttpClientModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatSidenavModule,
MatCardModule,
OutsideClickModule,
MatProgressBarModule,
MatToolbarModule,
MatMenuModule,
MatSnackBarModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
],
providers: [
ThemeService,
{
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: GRPC_INTERCEPTORS,
multi: true,
useClass: GrpcAuthInterceptor,
},
{
provide: GRPC_INTERCEPTORS,
multi: true,
useClass: GrpcOrgInterceptor,
},
GrpcService,
AuthService,
AuthUserService,
],
bootstrap: [AppComponent],
})
export class AppModule { }

View File

@ -0,0 +1,31 @@
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthUserService } from 'src/app/services/auth-user.service';
@Directive({
selector: '[appHasRole]',
})
export class HasRoleDirective {
private hasView: boolean = false;
@Input() public set appHasRole(roles: string[]) {
if (roles && roles.length > 0) {
this.userService.isAllowed(roles).subscribe(isAllowed => {
if (isAllowed && !this.hasView) {
this.viewContainerRef.clear();
this.viewContainerRef.createEmbeddedView(this.templateRef);
} else if (this.hasView) {
console.log('User blocked!', roles, isAllowed);
this.viewContainerRef.clear();
this.hasView = false;
}
});
}
}
constructor(
private userService: AuthUserService,
protected templateRef: TemplateRef<any>,
protected viewContainerRef: ViewContainerRef,
) { }
}

View File

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

View File

@ -0,0 +1,17 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[appOutsideClick]',
})
export class OutsideClickDirective {
constructor(private elementRef: ElementRef) { }
@Output() public clickOutside: EventEmitter<HTMLElement> = new EventEmitter();
@HostListener('document:click', ['$event.target']) onMouseEnter(targetElement: HTMLElement): void {
const clickedInside = this.elementRef.nativeElement.contains(targetElement);
if (!clickedInside) {
this.clickOutside.emit(targetElement);
}
}
}

View File

@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { OutsideClickDirective } from './outside-click.directive';
@NgModule({
declarations: [
OutsideClickDirective,
],
imports: [
CommonModule,
],
exports: [
OutsideClickDirective,
],
})
export class OutsideClickModule { }

View File

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

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ScrollableDirective } from './scrollable.directive';
@NgModule({
declarations: [
ScrollableDirective,
],
imports: [
CommonModule,
],
exports: [
ScrollableDirective,
],
})
export class ScrollableModule { }

View File

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) { }
public canActivate(
_: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Observable<boolean> | Promise<boolean> | boolean {
if (!this.auth.authenticated) {
return this.auth.authenticate();
}
return this.auth.authenticated;
}
}

View File

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthUserService } from '../services/auth-user.service';
@Injectable({
providedIn: 'root',
})
export class RoleGuard implements CanActivate {
constructor(private userService: AuthUserService) { }
public canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Observable<boolean> {
return this.userService.isAllowed(route.data['roles']);
}
}

View File

@ -0,0 +1,32 @@
<div class="card" appOutsideClick (clickOutside)="closeCard($event)">
<mat-icon class="avatar">account_circle</mat-icon>
<span class="u-name">{{profile?.firstName}} {{profile?.lastName}}</span>
<span class="u-email">{{profile?.userName}}</span>
<span class="iamuser" *ngIf="iamuser">IAM USER</span>
<button color="accent" (click)="editUserProfile()" mat-stroked-button>{{'USER.EDITACCOUNT' | translate}}</button>
<div class="l-accounts">
<mat-progress-bar *ngIf="loadingUsers" color="accent" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let user of users" (click)="selectAccount(user.userName)">
<mat-icon class="small-avatar" svgIcon="mdi_account_circle_outline"></mat-icon>
<div class="col">
<span class="title">{{user.userName}}</span>
<span class="email">{{'USER.STATE.'+user.authState | translate}}</span>
</div>
<span class="fill-space"></span>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
<a class="row" (click)="selectAccount()">
<div class="icon-wrapper">
<mat-icon>add</mat-icon>
</div>
<span class="col">
<span class="title">{{'USER.ADDACCOUNT' | translate}}</span>
</span>
<span class="fill-space"></span>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
</div>
<button color="accent" (click)="logout()" mat-stroked-button>logout everywhere</button>
</div>

View File

@ -0,0 +1,112 @@
.card {
border-radius: .5rem;
z-index: 200;
border: 1px solid #ffffff30;
width: 350px;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
.avatar {
height: 80px;
width: 80px;
border-radius: 50%;
line-height: 80px;
font-size: 80px;
margin-bottom: 1rem;
}
.u-name {
font-size: 1rem;
line-height: 1rem;
}
.u-email {
font-size: .8rem;
margin-top: 0;
}
.iamuser {
font-size: 1rem;
background: -webkit-linear-gradient(rgb(240,140,53), rgb(233, 60, 231));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
button {
border-radius: 1rem;
margin: .5rem;
.mat-button-wrapper {
font-size: .8rem;
}
}
.l-accounts {
display: flex;
flex-direction: column;
width: 100%;
border-top: 1px solid #ffffff30;
border-bottom: 1px solid #ffffff30;
padding: .5rem 0;
.row {
padding: .5rem;
display: flex;
align-items: center;
color: inherit;
text-decoration: none;
&:hover {
cursor: pointer;
background-color: #ffffff10;
}
.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;
mat-icon {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
display: flex;
flex-direction: column;
.title {
font-weight: 500;
font-size: .9rem;
line-height: 1rem;
}
.email {
color: #81868a;
font-size: .8rem;
line-height: 1rem;
}
}
.fill-space {
flex: 1;
}
}
}
}

View File

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

View File

@ -0,0 +1,66 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Router } from '@angular/router';
import { AuthConfig } from 'angular-oauth2-oidc';
import { UserProfile, UserSessionView } from 'src/app/proto/generated/auth_pb';
import { AuthUserService } from 'src/app/services/auth-user.service';
import { AuthService } from 'src/app/services/auth.service';
@Component({
selector: 'app-accounts-card',
templateUrl: './accounts-card.component.html',
styleUrls: ['./accounts-card.component.scss'],
})
export class AccountsCardComponent implements OnInit {
@Input() public profile!: UserProfile.AsObject;
@Input() public iamuser: boolean = false;
@Output() public close: EventEmitter<void> = new EventEmitter();
public users: UserSessionView.AsObject[] = [];
public loadingUsers: boolean = false;
constructor(public authService: AuthService, private router: Router, private userService: AuthUserService) { }
public ngOnInit(): void {
this.loadingUsers = true;
this.userService.getMyUserSessions().then(sessions => {
this.users = sessions.toObject().userSessionsList;
const index = this.users.findIndex(user => user.userName === this.profile.userName);
this.users.splice(index, 1);
this.loadingUsers = false;
}).catch(() => {
this.loadingUsers = false;
});
}
public editUserProfile(): void {
this.router.navigate(['user/me']);
this.close.emit();
}
public closeCard(element: HTMLElement): void {
if (!element.classList.contains('dontcloseonclick')) {
this.close.emit();
}
}
public selectAccount(loginHint?: string, idToken?: string): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
prompt: 'select_account',
} as any,
};
if (loginHint) {
(configWithPrompt as any).customQueryParams['login_hint'] = loginHint;
}
if (idToken) {
(configWithPrompt as any).customQueryParams['id_token_hint'] = idToken;
}
this.authService.authenticate(configWithPrompt);
}
public logout(): void {
this.router.navigate(['/']);
this.close.emit();
}
}

View File

@ -0,0 +1,27 @@
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 { TranslateModule } from '@ngx-translate/core';
import { OutsideClickModule } from 'src/app/directives/outside-click/outside-click.module';
import { AccountsCardComponent } from './accounts-card.component';
@NgModule({
declarations: [
AccountsCardComponent,
],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
OutsideClickModule,
TranslateModule,
],
exports: [
AccountsCardComponent,
],
})
export class AccountsCardModule { }

View File

@ -0,0 +1,33 @@
<h1 mat-dialog-title>
<span class="title">{{'MEMBER.ADD' | translate}}</span>
</h1>
<p class="desc"> {{'ORG_DETAIL.MEMBER.ADDDESCRIPTION' | translate}}</p>
<div mat-dialog-content>
<app-search-user-autocomplete (selectionChanged)="users = $event"></app-search-user-autocomplete>
<mat-form-field class="full-width" appearance="outline"
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<mat-label>{{ 'PROJECT.GRANT.TITLE' | translate }}</mat-label>
<mat-select [(ngModel)]="roles" multiple>
<mat-option *ngFor="let role of memberRoleOptions" [value]="role">
{{ 'ROLES.'+role | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container *ngIf="creationType === CreationType.ORG">
<app-org-member-roles-autocomplete (selectionChanged)="setOrgMemberRoles($event)">
</app-org-member-roles-autocomplete>
</ng-container>
</div>
<div mat-dialog-actions class="action">
<button mat-button (click)="closeDialog()">
cancel
</button>
<button [disabled]="users.length == 0 || roles.length == 0" color="primary" mat-raised-button class="ok-button"
(click)="closeDialogWithSuccess()">
Add
</button>
</div>

View File

@ -0,0 +1,25 @@
.title {
font-size: 1.2rem;
}
.desc {
color: #81868a;
font-size: .9rem;
}
.full-width {
width: 100%;
}
.action {
display: flex;
justify-content: flex-end;
.ok-button {
margin-left: 0.5rem;
}
button {
border-radius: 0.5rem;
}
}

View File

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

View File

@ -0,0 +1,60 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ProjectRole, User } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
export enum CreationType {
PROJECT_OWNED = 0,
PROJECT_GRANTED = 1,
ORG = 2,
}
@Component({
selector: 'app-project-member-create-dialog',
templateUrl: './project-member-create-dialog.component.html',
styleUrls: ['./project-member-create-dialog.component.scss'],
})
export class ProjectMemberCreateDialogComponent {
public projectId: string = '';
public creationType!: CreationType;
public users: Array<User.AsObject> = [];
public roles: Array<ProjectRole.AsObject> | string[] = [];
public CreationType: any = CreationType;
public memberRoleOptions: string[] = [];
constructor(
private projectService: ProjectService,
public dialogRef: MatDialogRef<ProjectMemberCreateDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
toastService: ToastService,
) {
this.creationType = data.creationType;
this.projectId = data.projectId;
if (this.creationType === CreationType.PROJECT_GRANTED) {
this.projectService.GetProjectGrantMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
toastService.showError(error.message);
});
} else if (this.creationType === CreationType.PROJECT_OWNED) {
this.projectService.GetProjectMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
console.log(this.memberRoleOptions);
}).catch(error => {
toastService.showError(error.message);
});
}
}
public closeDialog(): void {
this.dialogRef.close(false);
}
public closeDialogWithSuccess(): void {
this.dialogRef.close({ users: this.users, roles: this.roles });
}
public setOrgMemberRoles(roles: string[]): void {
this.roles = roles;
}
}

View File

@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { SearchUserAutocompleteModule } from 'src/app/modules/search-user-autocomplete/search-user-autocomplete.module';
import {
OrgMemberRolesAutocompleteModule,
} from '../../pages/orgs/org-member-roles-autocomplete/org-member-roles-autocomplete.module';
import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module';
import { ProjectMemberCreateDialogComponent } from './project-member-create-dialog.component';
@NgModule({
declarations: [ProjectMemberCreateDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
TranslateModule,
MatFormFieldModule,
MatSelectModule,
FormsModule,
SearchUserAutocompleteModule,
SearchRolesAutocompleteModule,
OrgMemberRolesAutocompleteModule,
],
entryComponents: [
ProjectMemberCreateDialogComponent,
],
})
export class ProjectMemberCreateDialogModule { }

View File

@ -0,0 +1,17 @@
<div class="card">
<div *ngIf="title || description" class="header" [ngClass]="{'bottom-margin': expanded}">
<div *ngIf="title" class="row">
<h2 class="title">{{title}}</h2>
<span class="fill-space"></span>
<ng-content select="card-actions"></ng-content>
<button class="button" matTooltip="Expand or collapse" mat-icon-button (click)="expanded = !expanded">
<mat-icon *ngIf="!expanded">keyboard_arrow_down</mat-icon>
<mat-icon *ngIf="expanded">keyboard_arrow_up</mat-icon>
</button>
</div>
<p *ngIf="description" class="desc">{{description}}</p>
</div>
<div class="card-content" *ngIf="expanded" [@openClose]="animate">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,46 @@
.card {
margin: 1rem 0;
padding: 1.5rem;
border-radius: .5rem;
padding-top: 1rem;
.header {
&.bottom-margin {
margin-bottom: 1rem;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
.title {
margin: 0;
font-weight: 400;
font-family: 'Rubik';
font-size: 1.2rem;
// margin-top: .3rem;
}
.fill-space {
flex: 1;
}
.button {
margin-right: -.5rem;
}
}
.desc {
font-size: .9rem;
color: #81868a;
}
}
.card-content {
display: flex;
flex-direction: column;
width: 100%;
}
}

View File

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

View File

@ -0,0 +1,25 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.scss'],
animations: [
trigger('openClose', [
transition(':enter', [
style({ height: '0', opacity: 0 }),
animate('150ms ease-in-out', style({ height: '*', opacity: 1 })),
]),
transition(':leave', [
animate('150ms ease-in-out', style({ height: '0', opacity: 0 })),
]),
]),
],
})
export class CardComponent {
public expanded: boolean = true;
@Input() public title: string = '';
@Input() public description: string = '';
@Input() public animate: boolean = false;
}

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CardComponent } from './card.component';
@NgModule({
declarations: [CardComponent],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
],
exports: [
CardComponent,
],
})
export class CardModule { }

View File

@ -0,0 +1,13 @@
<span class="header">{{ 'CHANGES.LISTTITLE' | translate }}</span>
<div class="scroll-container" appScrollable (scrollPosition)="scrollHandler($event)">
<li class="item change-item-back" *ngFor="let event of data | async">
<span class="seq">
{{dateFromTimestamp(event.changeDate) | localizedDate: 'EEE dd. MMM, HH:mm'}}
</span>
<span class="desc">{{'CHANGES.EVENTS.'+event.eventType | translate}}</span>
</li>
<div class="sp-wrapper">
<mat-spinner *ngIf="loading | async" diameter="25"></mat-spinner>
</div>
</div>

View File

@ -0,0 +1,40 @@
.header {
display: block;
margin-bottom: 1rem;
font-weight: 400;
color: #81868a;
margin-top: 1rem;
}
.scroll-container {
max-height: 540px;
overflow-y: scroll;
.item {
box-sizing: border-box;
height: 60px;
padding: .5rem;
margin: .25rem 0;
border-radius: .5rem;
display: flex;
flex-direction: column;
.seq {
color: #81868a;
font-size: 12px;
align-self: flex-end;
}
.desc {
overflow-x: auto;
font-size: .9rem;
margin: 4px 0;
}
}
.sp-wrapper {
padding: .5rem;
display: flex;
justify-content: center;
}
}

View File

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

View File

@ -0,0 +1,124 @@
import { Component, Input, OnInit } from '@angular/core';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, from, Observable } from 'rxjs';
import { scan, take, tap } from 'rxjs/operators';
import { Change, Changes } from 'src/app/proto/generated/management_pb';
import { MgmtUserService } from 'src/app/services/mgmt-user.service';
export enum ChangeType {
USER = 'user',
ORG = 'org',
PROJECT = 'project',
}
@Component({
selector: 'app-changes',
templateUrl: './changes.component.html',
styleUrls: ['./changes.component.scss'],
})
export class ChangesComponent implements OnInit {
@Input() public changeType: ChangeType = ChangeType.USER;
@Input() public id: string = '';
// Source data
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
private _loading: BehaviorSubject<any> = new BehaviorSubject(false);
private _data: BehaviorSubject<any> = new BehaviorSubject([]);
// Observable data
loading: Observable<boolean> = this._loading.asObservable();
public data!: Observable<Change.AsObject[]>;
public changes!: Changes.AsObject;
constructor(private mgmtUserService: MgmtUserService) { }
ngOnInit(): void {
this.init();
}
public scrollHandler(e: any): void {
if (e === 'bottom') {
this.more();
}
}
private init(): void {
let first: Promise<Changes>;
switch (this.changeType) {
case ChangeType.USER: first = this.mgmtUserService.UserChanges(this.id, 10, 0);
break;
case ChangeType.PROJECT: first = this.mgmtUserService.ProjectChanges(this.id, 20, 0);
break;
case ChangeType.ORG: first = this.mgmtUserService.OrgChanges(this.id, 10, 0);
break;
}
this.mapAndUpdate(first);
// Create the observable array for consumption in components
this.data = this._data.asObservable().pipe(
scan((acc, val) => {
return false ? val.concat(acc) : acc.concat(val);
}));
}
private more(): void {
const cursor = this.getCursor();
let more: Promise<Changes>;
switch (this.changeType) {
case ChangeType.USER: more = this.mgmtUserService.UserChanges(this.id, 10, cursor);
break;
case ChangeType.PROJECT: more = this.mgmtUserService.ProjectChanges(this.id, 10, cursor);
break;
case ChangeType.ORG: more = this.mgmtUserService.OrgChanges(this.id, 10, cursor);
break;
}
this.mapAndUpdate(more);
}
// Determines the snapshot to paginate query
private getCursor(): number {
const current = this._data.value;
if (current.length) {
// return true ? current[0].sequence :
return current[current.length - 1].sequence;
}
return 0;
}
// Maps the snapshot to usable format the updates source
private mapAndUpdate(col: Promise<Changes>): any {
if (this._done.value || this._loading.value) { return; }
// loading
this._loading.next(true);
// Map snapshot with doc ref (needed for cursor)
return from(col).pipe(
tap((res: Changes) => {
let values = res.toObject().changesList;
// If prepending, reverse the batch order
values = false ? values.reverse() : values;
// update source with new values, done loading
this._data.next(values);
// console.log(values);
this._loading.next(false);
// no more values, mark done
if (!values.length) {
this._done.next(true);
}
}),
take(1)).subscribe();
}
public dateFromTimestamp(date: Timestamp.AsObject): any {
const ts: Date = new Date(date.seconds * 1000 + date.nanos / 1000);
return ts;
}
}

View File

@ -0,0 +1,30 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { ScrollableModule } from 'src/app/directives/scrollable/scrollable.module';
import { PipesModule } from 'src/app/pipes/pipes.module';
import { ChangesComponent } from './changes.component';
@NgModule({
declarations: [
ChangesComponent,
],
imports: [
CommonModule,
ScrollableModule,
MatProgressSpinnerModule,
TranslateModule,
PipesModule,
ScrollingModule,
],
exports: [
ChangesComponent,
ScrollableModule,
],
})
export class ChangesModule { }

View File

@ -0,0 +1,8 @@
<div class="meta-wrapper">
<div class="main-content">
<ng-content></ng-content>
</div>
<div class="meta">
<ng-content class="meta-content" select="metainfo"></ng-content>
</div>
</div>

View File

@ -0,0 +1,33 @@
.meta-wrapper {
display: flex;
height: 100%;
.main-content {
display: relative;
width: 100%;
overflow-y: auto;
}
.meta {
flex: 1 0 300px;
@media only screen and (min-width: 1500px) {
flex-basis: 400px;
}
// overflow-y: auto;
padding: 1rem;
.meta-content {
max-height: calc(100vh - 60px);
display: flex;
flex-direction: column;
}
}
@media only screen and (max-width: 700px) {
flex-direction: column;
.main-content, .meta {
overflow-y: visible;
}
}
}

View File

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

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-meta-layout',
templateUrl: './meta-layout.component.html',
styleUrls: ['./meta-layout.component.scss'],
})
export class MetaLayoutComponent {
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MetaLayoutComponent } from './meta-layout.component';
@NgModule({
declarations: [MetaLayoutComponent],
imports: [
CommonModule,
],
exports: [MetaLayoutComponent],
})
export class MetaLayoutModule { }

View File

@ -0,0 +1,56 @@
import { DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { ProjectRole } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
/**
* Data source for the ProjectMembers view. This class should
* encapsulate all logic for fetching and manipulating the displayed data
* (including sorting, pagination, and filtering).
*/
export class ProjectRolesDataSource extends DataSource<ProjectRole.AsObject> {
public totalResult: number = 0;
public rolesSubject: BehaviorSubject<ProjectRole.AsObject[]> = new BehaviorSubject<ProjectRole.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
constructor(private projectService: ProjectService) {
super();
}
public loadRoles(projectId: string, pageIndex: number, pageSize: number, sortDirection?: string): void {
const offset = pageIndex * pageSize;
this.loadingSubject.next(true);
from(this.projectService.SearchProjectRoles(projectId, pageSize, offset)).pipe(
map(resp => {
this.totalResult = resp.toObject().totalResult;
return resp.toObject().resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
).subscribe(roles => {
this.rolesSubject.next(roles);
});
}
/**
* Connect this data source to the table. The table will only update when
* the returned stream emits new items.
* @returns A stream of the items to be rendered.
*/
public connect(): Observable<ProjectRole.AsObject[]> {
return this.rolesSubject.asObservable();
}
/**
* Called when the table is being destroyed. Use this function, to clean up
* any open connections or free any held resources that were set up during connect.
*/
public disconnect(): void {
this.rolesSubject.complete();
this.loadingSubject.complete();
}
}

View File

@ -0,0 +1,72 @@
<div class="table-header-row" *ngIf="projectId">
<div class="col">
<ng-container *ngIf="!selection.hasValue()">
<span class="desc">{{'ORG_DETAIL.TABLE.TOTAL' | translate}}</span>
<span class="count">{{dataSource?.rolesSubject.value.length}}</span>
</ng-container>
<ng-container *ngIf="selection.hasValue()">
<span class="desc">{{'ORG_DETAIL.TABLE.SELECTION' | translate}}</span>
<span class="count">{{selection?.selected?.length}}</span>
</ng-container>
</div>
<span class="fill-space"></span>
<ng-template appHasRole [appHasRole]="['project.role.delete']">
<button [disabled]="disabled" matTooltip="{{'PROJECT.ROLE.DELETE' | translate}}" class="icon-button"
(click)="deleteSelectedRoles()" mat-icon-button *ngIf="selection.hasValue() && actionsVisible">
<mat-icon>delete_outline</mat-icon>
</button>
</ng-template>
<ng-template appHasRole [appHasRole]="['project.role.write:' + projectId, 'project.role.write']">
<a *ngIf="actionsVisible" [disabled]="disabled" class="add-button"
[routerLink]="[ '/projects', projectId, 'roles', 'create']" color="primary" mat-raised-button>
<mat-icon class="icon">add</mat-icon>{{ 'ACTIONS.NEW' | translate }}
</a>
</ng-template>
</div>
<div class="table-wrapper">
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner diameter="50"></mat-spinner>
</div>
<table [dataSource]="dataSource" mat-table class="full-width-table" matSort aria-label="Elements">
<ng-container matColumnDef="select">
<th class="selection" mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td class="selection" mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.NAME' | translate }} </th>
<td mat-cell *matCellDef="let role"> {{role.name}} </td>
</ng-container>
<ng-container matColumnDef="displayname">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.DISPLAY_NAME' | translate }} </th>
<td mat-cell *matCellDef="let role"> {{role.displayName}} </td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.GROUP' | translate }} </th>
<td mat-cell *matCellDef="let role">
<span class="role app-label" *ngIf="role.group"
matTooltip="Add all of group {{role.group}} to selection"
(click)="selectAllOfGroup(role.group)">{{role.group}}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator #paginator [length]="dataSource.totalResult" [pageSize]="50" [pageSizeOptions]="[25, 50, 100, 250]">
</mat-paginator>
</div>

View File

@ -0,0 +1,84 @@
.table-header-row {
display: flex;
align-items: center;
.col {
display: flex;
flex-direction: column;
.desc {
font-size: .8rem;
color: #81868a;
}
.count {
font-size: 2rem;
}
}
.fill-space {
flex: 1;
}
.icon-button {
margin-right: .5rem;
}
.add-button {
border-radius: .5rem;
}
}
.table-wrapper {
overflow: auto;
.spinner-container {
display: flex;
align-items: center;
justify-content: center;
}
table, mat-paginator {
width: 100%;
background-color: #2d2e30;
td, th {
&:first-child {
padding-left: 0;
padding-right: 1rem;
}
&:last-child {
padding-right: 0;
}
}
.action {
width: 40px;
}
.data-row {
&:hover {
background-color: #ffffff05;
}
}
.selection {
width: 50px;
max-width: 50px;
}
.margin-neg {
margin-left: -1rem;
}
.role {
display: inline-block;
margin: .25rem;
}
}
}
.pointer {
outline: none;
cursor: pointer;
}

View File

@ -0,0 +1,34 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ProjectRolesComponent } from './project-roles.component';
describe('ProjectRolesComponent', () => {
let component: ProjectRolesComponent;
let fixture: ComponentFixture<ProjectRolesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProjectRolesComponent],
imports: [
NoopAnimationsModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectRolesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should compile', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,110 @@
import { SelectionModel } from '@angular/cdk/collections';
import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatTable } from '@angular/material/table';
import { tap } from 'rxjs/operators';
import { ProjectRole } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
import { ProjectRolesDataSource } from './project-roles-datasource';
@Component({
selector: 'app-project-roles',
templateUrl: './project-roles.component.html',
styleUrls: ['./project-roles.component.scss'],
})
export class ProjectRolesComponent implements AfterViewInit, OnInit {
@Input() public projectId: string = '';
@Input() public disabled: boolean = false;
@Input() public actionsVisible: boolean = false;
@ViewChild(MatPaginator) public paginator!: MatPaginator;
@ViewChild(MatTable) public table!: MatTable<ProjectRole.AsObject>;
public dataSource!: ProjectRolesDataSource;
public selection: SelectionModel<ProjectRole.AsObject> = new SelectionModel<ProjectRole.AsObject>(true, []);
@Output() public changedSelection: EventEmitter<Array<ProjectRole.AsObject>> = new EventEmitter();
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
public displayedColumns: string[] = ['select', 'name', 'displayname', 'group'];
constructor(private projectService: ProjectService, private toast: ToastService) { }
public ngOnInit(): void {
this.dataSource = new ProjectRolesDataSource(this.projectService);
this.dataSource.loadRoles(this.projectId, 0, 25, 'asc');
this.selection.changed.subscribe(() => {
this.changedSelection.emit(this.selection.selected);
});
}
public ngAfterViewInit(): void {
this.paginator.page
.pipe(
tap(() => this.loadRolesPage()),
)
.subscribe();
}
public selectAllOfGroup(group: string): void {
const groupRoles: ProjectRole.AsObject[] = this.dataSource.rolesSubject.getValue()
.filter(role => role.group === group);
this.selection.select(...groupRoles);
}
private loadRolesPage(): void {
this.dataSource.loadRoles(
this.projectId,
this.paginator.pageIndex,
this.paginator.pageSize,
);
}
public isAllSelected(): boolean {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.rolesSubject.value.length;
return numSelected === numRows;
}
public masterToggle(): void {
this.isAllSelected() ?
this.selection.clear() :
this.dataSource.rolesSubject.value.forEach((row: ProjectRole.AsObject) => this.selection.select(row));
}
public deleteSelectedRoles(): Promise<any> {
const oldState = this.dataSource.rolesSubject.value;
const indexes = this.selection.selected.map(sel => {
return oldState.findIndex(iter => iter.key === sel.key);
});
return Promise.all(this.selection.selected.map(role => {
return this.projectService.RemoveProjectRole(role.projectId, role.key);
})).then(() => {
this.toast.showInfo('Deleted');
indexes.forEach(index => {
if (index > -1) {
oldState.splice(index, 1);
this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value);
}
});
this.selection.clear();
}).catch(error => {
this.toast.showError(error?.message || 'Error');
});
}
public removeRole(role: ProjectRole.AsObject, index: number): void {
this.projectService
.RemoveProjectRole(role.projectId, role.key)
.then(() => {
this.toast.showInfo('Role removed');
this.dataSource.rolesSubject.value.splice(index, 1);
this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value);
})
.catch(data => {
this.toast.showError(data.message);
});
}
}

View File

@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ProjectRolesComponent } from './project-roles.component';
@NgModule({
declarations: [ProjectRolesComponent],
imports: [
CommonModule,
MatButtonModule,
HasRoleModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
MatProgressSpinnerModule,
MatCheckboxModule,
RouterModule,
MatTooltipModule,
TranslateModule,
MatMenuModule,
],
exports: [
ProjectRolesComponent,
],
})
export class ProjectRolesModule { }

View File

@ -0,0 +1,28 @@
<form>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Project Name</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the project name" #nameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="name selection">
<mat-chip class="chip" *ngFor="let selectedProject of projects" [selectable]="selectable"
[removable]="removable" (removed)="remove(selectedProject)">
{{selectedProject.name}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="{{'PROJECT.NAME' | translate}}" #nameInput [formControl]="myControl"
[matAutocomplete]="auto" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let project of filteredProjects" [value]="project">
{{project.name}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>

View File

@ -0,0 +1,3 @@
.full-width {
width: 100%;
}

View File

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

View File

@ -0,0 +1,108 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from } from 'rxjs';
import { debounceTime, switchMap, tap } from 'rxjs/operators';
import { Project, ProjectSearchKey, ProjectSearchQuery, SearchMethod } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
@Component({
selector: 'app-search-project-autocomplete',
templateUrl: './search-project-autocomplete.component.html',
styleUrls: ['./search-project-autocomplete.component.scss'],
})
export class SearchProjectAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public names: string[] = [];
public projects: Array<Project.AsObject> = [];
public filteredProjects: Array<Project.AsObject> = [];
public isLoading: boolean = false;
@ViewChild('nameInput') public nameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Input() public singleOutput: boolean = false;
@Output() public selectionChanged: EventEmitter<Project.AsObject[] | Project.AsObject> = new EventEmitter();
constructor(private projectService: ProjectService, private toast: ToastService) {
this.myControl.valueChanges
.pipe(
debounceTime(200),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new ProjectSearchQuery();
query.setKey(ProjectSearchKey.PROJECTSEARCHKEY_PROJECT_NAME);
query.setValue(value);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
return from(this.projectService.SearchProjects(10, 0, [query]));
}),
// finalize(() => this.isLoading = false),
).subscribe((projects) => {
console.log(projects.toObject().resultList);
this.isLoading = false;
this.filteredProjects = projects.toObject().resultList;
});
}
public displayFn(project?: Project.AsObject): string | undefined {
return project ? `${project.name}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredProjects.findIndex((project) => {
if (project.name) {
return project.name === value;
}
});
if (index > -1) {
if (this.projects && this.projects.length > 0) {
this.projects.push(this.filteredProjects[index]);
} else {
this.projects = [this.filteredProjects[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(project: Project.AsObject): void {
const index = this.projects.indexOf(project);
if (index >= 0) {
this.projects.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredProjects.findIndex((project) => project === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredProjects[index]);
} else {
if (this.projects && this.projects.length > 0) {
this.projects.push(this.filteredProjects[index]);
} else {
this.projects = [this.filteredProjects[index]];
}
this.selectionChanged.emit(this.projects);
this.nameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
}
}
}

View File

@ -0,0 +1,34 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchProjectAutocompleteComponent } from './search-project-autocomplete.component';
@NgModule({
declarations: [SearchProjectAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [
SearchProjectAutocompleteComponent,
],
})
export class SearchProjectAutocompleteModule { }

View File

@ -0,0 +1,27 @@
<form>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Role Name</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the role name" #nameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="name selection">
<mat-chip class="chip" *ngFor="let selectedRole of roles" [selectable]="selectable" [removable]="removable"
(removed)="remove(selectedRole)">
{{selectedRole.displayName}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="Role Name" #nameInput [formControl]="myControl" [matAutocomplete]="auto"
[matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur" (matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let role of filteredRoles" [value]="role.key">
{{role.displayName}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>

View File

@ -0,0 +1,3 @@
.full-width {
width: 100%;
}

View File

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

View File

@ -0,0 +1,117 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from } from 'rxjs';
import { debounceTime, switchMap, tap } from 'rxjs/operators';
import {
ProjectRole,
ProjectRoleSearchKey,
ProjectRoleSearchQuery,
SearchMethod,
} from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
@Component({
selector: 'app-search-roles-autocomplete',
templateUrl: './search-roles-autocomplete.component.html',
styleUrls: ['./search-roles-autocomplete.component.scss'],
})
export class SearchRolesAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public names: string[] = [];
public roles: Array<ProjectRole.AsObject> = [];
public filteredRoles: Array<ProjectRole.AsObject> = [];
public isLoading: boolean = false;
@ViewChild('nameInput') public nameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Input() public projectId: string = '';
@Input() public singleOutput: boolean = false;
@Output() public selectionChanged: EventEmitter<ProjectRole.AsObject[] | ProjectRole.AsObject> = new EventEmitter();
constructor(private projectService: ProjectService, private toast: ToastService) {
this.myControl.valueChanges
.pipe(
debounceTime(200),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new ProjectRoleSearchQuery();
query.setKey(ProjectRoleSearchKey.PROJECTROLESEARCHKEY_DISPLAY_NAME);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
query.setValue(value);
return from(this.projectService.SearchProjectRoles(this.projectId, 10, 0, [query]));
}),
).subscribe((roles) => {
console.log(roles.toObject().resultList);
this.isLoading = false;
this.filteredRoles = roles.toObject().resultList;
}, error => {
console.log(error);
this.isLoading = false;
});
}
public displayFn(project?: ProjectRole.AsObject): string | undefined {
return project ? `${project.displayName}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredRoles.findIndex((role) => {
if (role.key) {
return role.key === value;
}
});
if (index > -1) {
if (this.roles && this.roles.length > 0) {
this.roles.push(this.filteredRoles[index]);
} else {
this.roles = [this.filteredRoles[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(role: ProjectRole.AsObject): void {
const index = this.roles.indexOf(role);
if (index >= 0) {
this.roles.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredRoles.findIndex((role) => role.key === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredRoles[index]);
} else {
if (this.roles && this.roles.length > 0) {
this.roles.push(this.filteredRoles[index]);
} else {
this.roles = [this.filteredRoles[index]];
}
this.selectionChanged.emit(this.roles);
this.nameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
}
}
}

View File

@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchRolesAutocompleteComponent } from './search-roles-autocomplete.component';
@NgModule({
declarations: [SearchRolesAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [
SearchRolesAutocompleteComponent,
],
})
export class SearchRolesAutocompleteModule { }

View File

@ -0,0 +1,54 @@
<form>
<mat-form-field *ngIf="target == UserTarget.SELF" appearance="outline" class="full-width">
<mat-label>Organizations User Email</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the user email" #usernameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="useremail selection">
<mat-chip class="chip" *ngFor="let selecteduser of users" [selectable]="selectable" [removable]="removable"
(removed)="remove(selecteduser)">
{{ selecteduser?.firstName }} {{selecteduser.lastName}} | <small> {{selecteduser.email}}</small>
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="{{'ORG_DETAIL.MEMBER.EMAIL' | translate}}" #usernameInput [formControl]="myControl"
[matAutocomplete]="auto" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<!-- <mat-hint *ngIf="hint">
{{hint}}
<a (click)=" changeTarget()">{{'USER.TARGET.CLICKHERE' | translate}}</a>
</mat-hint> -->
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let user of filteredUsers" [value]="user">
{{user.firstName}} {{user.lastName}}
<small>{{user.email}}</small>
</mat-option>
</mat-autocomplete>
</mat-form-field>
<div *ngIf="target == UserTarget.EXTERNAL" class="line">
<mat-form-field appearance="outline">
<mat-label>Global User Email</mat-label>
<input matInput type="text" [formControl]="globalEmailControl" />
</mat-form-field>
<button mat-icon-button (click)="getGlobalUser()">
<mat-icon>search</mat-icon>
</button>
</div>
<p *ngIf="target == UserTarget.EXTERNAL && users.length > 0">{{'USER.SEARCH.FOUND' | translate}}: <span
*ngFor="let user of users; index as i">{{user.email}} <mat-icon class="sm-dlt" (click)="users.splice(i, 1)">
remove_circle</mat-icon></span></p>
<p class="target-desc">{{(target == UserTarget.SELF ? 'USER.TARGET.SELF' : 'USER.TARGET.EXTERNAL') | translate}}
<a (click)="changeTarget()">{{'USER.TARGET.CLICKHERE' | translate}}</a>
</p>
</form>

View File

@ -0,0 +1,36 @@
.full-width {
width: 100%;
}
.target-desc {
color: #81868a;
font-size: .8rem;
margin: 0;
margin-bottom: 1rem;
a {
color: white;
&:hover {
cursor: pointer;
color: white;
text-decoration: underline;
}
}
}
.line {
width: 100%;
display: flex;
mat-form-field {
flex: 1;
}
button {
margin-top: 1rem;
}
}
.sm-dlt {
cursor: pointer;
font-size: .8rem;
}

View File

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

View File

@ -0,0 +1,146 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from, of, Subject } from 'rxjs';
import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators';
import { SearchMethod, User, UserSearchKey, UserSearchQuery } from 'src/app/proto/generated/management_pb';
import { MgmtUserService } from 'src/app/services/mgmt-user.service';
import { ToastService } from 'src/app/services/toast.service';
export enum UserTarget {
SELF = 'self',
EXTERNAL = 'external',
}
@Component({
selector: 'app-search-user-autocomplete',
templateUrl: './search-user-autocomplete.component.html',
styleUrls: ['./search-user-autocomplete.component.scss'],
})
export class SearchUserAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public globalEmailControl: FormControl = new FormControl();
public emails: string[] = [];
public users: Array<User.AsObject> = [];
public filteredUsers: Array<User.AsObject> = [];
public isLoading: boolean = false;
public target: UserTarget = UserTarget.SELF;
public hint: string = '';
public UserTarget: any = UserTarget;
@ViewChild('usernameInput') public usernameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Output() public selectionChanged: EventEmitter<User.AsObject | User.AsObject[]> = new EventEmitter();
@Input() public singleOutput: boolean = false;
private unsubscribed$: Subject<void> = new Subject();
constructor(private userService: MgmtUserService, private toast: ToastService) {
this.getFilteredResults();
}
private getFilteredResults(): void {
this.myControl.valueChanges.pipe(debounceTime(200),
takeUntil(this.unsubscribed$),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new UserSearchQuery();
query.setKey(UserSearchKey.USERSEARCHKEY_EMAIL);
query.setValue(value);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
if (this.target === UserTarget.SELF) {
return from(this.userService.SearchUsers(10, 0, [query]));
} else {
return of(); // from(this.userService.GetUserByEmailGlobal(value));
}
}),
).subscribe((userresp: any) => {
this.isLoading = false;
if (this.target === UserTarget.SELF && userresp) {
this.filteredUsers = userresp.toObject().resultList;
}
});
}
public displayFn(user?: User.AsObject): string | undefined {
return user ? `${user.email}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredUsers.findIndex((user) => {
if (user.email) {
return user.email === value;
}
});
if (index > -1) {
if (this.users && this.users.length > 0) {
this.users.push(this.filteredUsers[index]);
} else {
this.users = [this.filteredUsers[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(user: User.AsObject): void {
const index = this.users.indexOf(user);
if (index >= 0) {
this.users.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredUsers.findIndex((user) => user === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredUsers[index]);
} else {
if (this.users && this.users.length > 0) {
this.users.push(this.filteredUsers[index]);
} else {
this.users = [this.filteredUsers[index]];
}
this.selectionChanged.emit(this.users);
}
}
this.usernameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
public changeTarget(): void {
if (this.target === UserTarget.SELF) {
this.target = UserTarget.EXTERNAL;
this.filteredUsers = [];
this.unsubscribed$.next(); // clear old subscription
} else if (this.target === UserTarget.EXTERNAL) {
this.target = UserTarget.SELF;
this.getFilteredResults(); // new subscription
}
}
public getGlobalUser(): void {
this.userService.GetUserByEmailGlobal(this.globalEmailControl.value).then(user => {
this.users = [user.toObject()];
this.selectionChanged.emit(this.users);
}).catch(error => {
this.toast.showError(error.message);
});
}
}

View File

@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchUserAutocompleteComponent } from './search-user-autocomplete.component';
@NgModule({
declarations: [SearchUserAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [SearchUserAutocompleteComponent],
})
export class SearchUserAutocompleteModule { }

View File

@ -0,0 +1,86 @@
<div class="container">
<div class="abort-container">
<button (click)="close()" mat-icon-button>
<mat-icon>close</mat-icon>
</button>
<span class="abort">{{ 'APP.PAGES.CREATE_OIDC' | translate }}</span><span class="abort-2">Step
{{ currentCreateStep }} of
{{ createSteps }}</span>
</div>
<h1>{{'APP.PAGES.CREATE_OIDC_DESc' | translate}}</h1>
<form [formGroup]="form" (ngSubmit)="saveOIDCApp()">
<div class="content">
<mat-form-field appearance="outline" class="formfield">
<mat-label>{{ 'APP.NAME' | translate }}</mat-label>
<input matInput formControlName="name" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'APP.OIDC.RESPONSE' | translate }}</mat-label>
<mat-select formControlName="responseTypesList" multiple>
<mat-option *ngFor="let type of oidcResponseTypes" [value]="type">
{{ 'APP.OIDC.RESPONSE'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'APP.OIDC.GRANT' | translate }}</mat-label>
<mat-select formControlName="grantTypesList" multiple>
<mat-option *ngFor="let grant of oidcGrantTypes" [value]="grant">
{{ 'APP.OIDC.GRANT'+grant | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="formfield">
<mat-label>{{ 'APP.OIDC.APPTYPE' | translate }}</mat-label>
<mat-select formControlName="applicationType">
<mat-option *ngFor="let type of oidcAppTypes" [value]="type">
{{ 'APP.OIDC.APPTYPE'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="formfield">
<mat-label>{{ 'APP.OIDC.AUTHMETHOD' | translate }}</mat-label>
<mat-select formControlName="authMethodType">
<mat-option *ngFor="let type of oidcAuthMethodType" [value]="type">
{{ 'APP.OIDC.AUTHMETHOD'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'APP.OIDC.REDIRECT' | translate }}</mat-label>
<mat-chip-list #chipRedirectList aria-label="uri selection">
<mat-chip *ngFor="let uri of oidcApp.redirectUrisList" removable
(removed)="removeUri(uri, 'REDIRECT')">
{{uri}} <mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
<input [matChipInputFor]="chipRedirectList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur" (matChipInputTokenEnd)="addUri($event, 'REDIRECT')">
</mat-chip-list>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}</mat-label>
<mat-chip-list #chipPostRedirectList aria-label="uri selection">
<mat-chip *ngFor="let uri of oidcApp.postLogoutRedirectUrisList" removable
(removed)="removeUri(uri, 'POSTREDIRECT')">
{{uri}} <mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
<input [matChipInputFor]="chipPostRedirectList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur" (matChipInputTokenEnd)="addUri($event, 'POSTREDIRECT')">
</mat-chip-list>
</mat-form-field>
</div>
<button color="primary" mat-raised-button class="continue-button" [disabled]="form.invalid" cdkFocusInitial
type="submit">
{{ 'ACTIONS.SAVE' | translate }}
</button>
</form>
</div>

View File

@ -0,0 +1,75 @@
h1 {
font-weight: 500;
}
.container {
padding: 4rem 4rem 2rem 4rem;
.abort-container {
display: flex;
align-items: center;
margin-bottom: 2rem;
.abort {
font-size: 1.2rem;
margin-left: 2rem;
}
.abort-2 {
font-size: 1.2rem;
margin-left: 2rem;
}
}
}
.margin-right {
margin-right: 0.5rem;
}
.column {
display: flex;
flex-direction: column;
.formfield {
width: 400px;
input {
font-size: 1.5rem;
}
&.autocomplete {
margin-top: 1rem;
}
}
.slider {
margin-top: 2rem;
margin-bottom: 1rem;
font-size: 0.8rem;
}
.access-token {
width: 400px;
}
}
.content {
display: flex;
margin: 0 -.5rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 0 40%;
margin: 0 .5rem;
}
.full-width {
flex-basis: 80%;
}
}
.continue-button {
margin-top: 3rem;
display: block;
padding: 0.5rem 4rem;
border-radius: 0.5rem;
}

View File

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

View File

@ -0,0 +1,186 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Location } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatChipInputEvent } from '@angular/material/chips';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import {
Application,
OIDCApplicationCreate,
OIDCApplicationType,
OIDCAuthMethodType,
OIDCGrantType,
OIDCResponseType,
} from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
import { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog.component';
@Component({
selector: 'app-app-create',
templateUrl: './app-create.component.html',
styleUrls: ['./app-create.component.scss'],
})
export class AppCreateComponent implements OnInit, OnDestroy {
private subscription?: Subscription;
public projectId: string = '';
public oidcApp: OIDCApplicationCreate.AsObject = new OIDCApplicationCreate().toObject();
public oidcResponseTypes: OIDCResponseType[] = [
OIDCResponseType.OIDCRESPONSETYPE_CODE,
OIDCResponseType.OIDCRESPONSETYPE_ID_TOKEN,
OIDCResponseType.OIDCRESPONSETYPE_TOKEN,
];
public oidcGrantTypes: OIDCGrantType[] = [
OIDCGrantType.OIDCGRANTTYPE_AUTHORIZATION_CODE,
OIDCGrantType.OIDCGRANTTYPE_IMPLICIT,
OIDCGrantType.OIDCGRANTTYPE_REFRESH_TOKEN,
];
public oidcAppTypes: OIDCApplicationType[] = [
OIDCApplicationType.OIDCAPPLICATIONTYPE_WEB,
OIDCApplicationType.OIDCAPPLICATIONTYPE_USER_AGENT,
OIDCApplicationType.OIDCAPPLICATIONTYPE_NATIVE,
];
public oidcAuthMethodType: OIDCAuthMethodType[] = [
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_BASIC,
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_NONE,
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_POST,
];
public form!: FormGroup;
public createSteps: number = 1;
public currentCreateStep: number = 1;
public postLogoutRedirectUrisList: string[] = [];
public addOnBlur: boolean = true;
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
constructor(
private router: Router,
private route: ActivatedRoute,
private toast: ToastService,
private dialog: MatDialog,
private projectService: ProjectService,
private fb: FormBuilder,
private _location: Location,
) {
this.form = this.fb.group({
name: ['', [Validators.required]],
responseTypesList: ['', []],
grantTypesList: ['', []],
applicationType: ['', []],
authMethodType: [],
});
}
public ngOnInit(): void {
this.subscription = this.route.params.subscribe(params => this.getData(params));
}
public ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
private async getData({ projectid }: Params): Promise<void> {
this.projectId = projectid;
this.oidcApp.projectId = projectid;
}
public close(): void {
this._location.back();
}
public saveOIDCApp(): void {
this.oidcApp.name = this.name?.value;
this.oidcApp.applicationType = this.applicationType?.value;
this.oidcApp.grantTypesList = this.grantTypesList?.value;
this.oidcApp.responseTypesList = this.responseTypesList?.value;
this.oidcApp.authMethodType = this.authMethodType?.value;
console.log(this.oidcApp);
this.projectService
.CreateOIDCApp(this.oidcApp)
.then((data: Application) => {
this.showSavedDialog(data.toObject());
})
.catch(data => {
this.toast.showError(data.message);
});
}
public showSavedDialog(app: Application.AsObject): void {
if (app.oidcConfig !== undefined) {
const dialogRef = this.dialog.open(AppSecretDialogComponent, {
data: app.oidcConfig,
});
dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed');
this.router.navigate(['projects', this.projectId, 'apps', app.id]);
});
} else {
this.router.navigate(['projects', this.projectId, 'apps', app.id]);
}
}
public addUri(event: MatChipInputEvent, target: string): void {
const input = event.input;
const value = event.value.trim();
if (value !== '') {
if (target === 'REDIRECT') {
this.oidcApp.redirectUrisList.push(value);
} else if (target === 'POSTREDIRECT') {
this.oidcApp.postLogoutRedirectUrisList.push(value);
}
}
if (input) {
input.value = '';
}
}
public removeUri(uri: string, target: string): void {
if (target === 'REDIRECT') {
const index = this.oidcApp.redirectUrisList.indexOf(uri);
if (index !== undefined && index >= 0) {
this.oidcApp.redirectUrisList.splice(index, 1);
}
} else if (target === 'POSTREDIRECT') {
const index = this.oidcApp.postLogoutRedirectUrisList.indexOf(uri);
if (index !== undefined && index >= 0) {
this.oidcApp.postLogoutRedirectUrisList.splice(index, 1);
}
}
}
get name(): AbstractControl | null {
return this.form.get('name');
}
get responseTypesList(): AbstractControl | null {
return this.form.get('responseTypesList');
}
get grantTypesList(): AbstractControl | null {
return this.form.get('grantTypesList');
}
get applicationType(): AbstractControl | null {
return this.form.get('applicationType');
}
get authMethodType(): AbstractControl | null {
return this.form.get('authMethodType');
}
}

View File

@ -0,0 +1,121 @@
<div class="max-width-container">
<div class="head">
<a (click)="navigateBack()" mat-icon-button>
<mat-icon class="icon">arrow_back</mat-icon>
</a>
<h1>{{ 'APP.PAGES.TITLE' | translate }} {{app?.name}}</h1>
<p class="desc">{{ 'APP.PAGES.DESCRIPTION' | translate }}</p>
</div>
<app-card title="{{ 'APP.PAGES.DETAIL.TITLE' | translate }}" *ngIf="app">
<form [formGroup]="appNameForm" (ngSubmit)="saveOIDCApp()">
<div class="content">
<mat-button-toggle-group formControlName="state" class="toggle" (change)="changeState($event)">
<mat-button-toggle [value]="AppState.APPSTATE_INACTIVE" matTooltip="Deactivate Org">
<mat-icon svgIcon="mdi_light_off"></mat-icon>
{{'APP.PAGES.DETAIL.STATE.'+AppState.APPSTATE_INACTIVE | translate}}
</mat-button-toggle>
<mat-button-toggle [value]="AppState.APPSTATE_ACTIVE" matTooltip="Activate Org">
<mat-icon svgIcon="mdi_light_on"></mat-icon>
{{'APP.PAGES.DETAIL.STATE.'+AppState.APPSTATE_ACTIVE | translate}}
</mat-button-toggle>
</mat-button-toggle-group>
<mat-form-field class="formfield">
<mat-label>{{ 'APP.NAME' | translate }}</mat-label>
<input matInput formControlName="name" />
</mat-form-field>
</div>
<div class="btn-container">
<button type="submit" color="primary" [disabled]="appNameForm.invalid || name?.disabled"
mat-raised-button>{{ 'ACTIONS.SAVE' | translate }}</button>
</div>
</form>
</app-card>
<app-card title="{{ 'APP.OIDC.TITLE' | translate }}" *ngIf="app && app.oidcConfig">
<card-actions class="card-actions">
<button mat-stroked-button
(click)="regenerateOIDCClientSecret()">{{'APP.OIDC.REGENERATESECRET' | translate}}</button>
</card-actions>
<form *ngIf="appForm" [formGroup]="appForm" (ngSubmit)="saveOIDCApp()">
<div class="content">
<mat-form-field appearance="outline">
<mat-label>{{ 'APP.OIDC.CLIENTID' | translate }}</mat-label>
<input matInput formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'APP.OIDC.RESPONSE' | translate }}</mat-label>
<mat-select formControlName="responseTypesList" multiple>
<mat-option *ngFor="let type of oidcResponseTypes" [value]="type">
{{ 'APP.OIDC.RESPONSE'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'APP.OIDC.GRANT' | translate }}</mat-label>
<mat-select formControlName="grantTypesList" multiple>
<mat-option *ngFor="let grant of oidcGrantTypes" [value]="grant">
{{ 'APP.OIDC.GRANT'+grant | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="formfield">
<mat-label>{{ 'APP.OIDC.APPTYPE' | translate }}</mat-label>
<mat-select formControlName="applicationType">
<mat-option *ngFor="let type of oidcAppTypes" [value]="type">
{{ 'APP.OIDC.APPTYPE'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="formfield">
<mat-label>{{ 'APP.OIDC.AUTHMETHOD' | translate }}</mat-label>
<mat-select formControlName="authMethodType">
<mat-option *ngFor="let type of oidcAuthMethodType" [value]="type">
{{ 'APP.OIDC.AUTHMETHOD'+type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="full-width" appearance="outline">
<mat-label>{{ 'APP.OIDC.REDIRECT' | translate }}</mat-label>
<mat-chip-list #chipRedirectList>
<mat-chip *ngFor="let redirect of redirectUrisList" [selectable]="selectable"
(removed)="remove(redirect, RedirectType.REDIRECT)">
{{redirect}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input [matChipInputFor]="chipRedirectList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event, RedirectType.REDIRECT)">
</mat-chip-list>
</mat-form-field>
<mat-form-field class="full-width" appearance="outline">
<mat-label>{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}</mat-label>
<mat-chip-list #chipPostRedirectList>
<mat-chip *ngFor="let redirect of postLogoutRedirectUrisList" [selectable]="selectable"
(removed)="remove(redirect, RedirectType.POSTREDIRECT)">
{{redirect}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input [matChipInputFor]="chipPostRedirectList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event, RedirectType.POSTREDIRECT)">
</mat-chip-list>
</mat-form-field>
</div>
<div class="btn-container">
<button type="submit" color="primary" [disabled]="appForm.invalid"
mat-raised-button>{{ 'ACTIONS.SAVE' | translate }}</button>
</div>
</form>
</app-card>
</div>

View File

@ -0,0 +1,73 @@
.head {
display: flex;
align-items: center;
border-bottom: 1px solid #ffffff20;
flex-wrap: wrap;
a {
display: block;
}
h1 {
font-size: 1.2rem;
margin: 0 1rem;
margin-left: 2rem;
font-weight: normal;
}
.desc {
width: 100%;
display: block;
font-size: .9rem;
color: #81868a;
}
}
.card-actions {
button {
border-radius: .5rem;
}
}
.content {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -.5rem;
&.nowrap{
flex-wrap: nowrap;
}
&.center {
align-items: center;
}
mat-form-field {
flex: 1 1 30%;
margin: 0 .5rem;
}
.full-width {
flex-basis: 100%;
}
.toggle {
align-self: flex-start;
margin-bottom: 1rem;
margin-right: 1rem;
border-radius: .5rem;
}
}
.btn-container {
display: flex;
justify-content: flex-end;
margin: 0 -.5rem;
button {
border-radius: .5rem;
margin: 0 .5rem;
}
}

View File

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

View File

@ -0,0 +1,262 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Location } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatButtonToggleChange } from '@angular/material/button-toggle';
import { MatChipInputEvent } from '@angular/material/chips';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import {
Application,
AppState,
OIDCApplicationType,
OIDCAuthMethodType,
OIDCConfig,
OIDCGrantType,
OIDCResponseType,
} from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
import { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog.component';
enum RedirectType {
REDIRECT = 'redirect',
POSTREDIRECT = 'postredirect',
}
@Component({
selector: 'app-app-detail',
templateUrl: './app-detail.component.html',
styleUrls: ['./app-detail.component.scss'],
})
export class AppDetailComponent implements OnInit, OnDestroy {
public selectable: boolean = false;
public removable: boolean = true;
public addOnBlur: boolean = true;
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
private subscription?: Subscription;
public projectId: string = '';
public app!: Application.AsObject;
public oidcResponseTypes: OIDCResponseType[] = [
OIDCResponseType.OIDCRESPONSETYPE_CODE,
OIDCResponseType.OIDCRESPONSETYPE_ID_TOKEN,
OIDCResponseType.OIDCRESPONSETYPE_TOKEN,
];
public oidcGrantTypes: OIDCGrantType[] = [
OIDCGrantType.OIDCGRANTTYPE_AUTHORIZATION_CODE,
OIDCGrantType.OIDCGRANTTYPE_IMPLICIT,
OIDCGrantType.OIDCGRANTTYPE_REFRESH_TOKEN,
];
public oidcAppTypes: OIDCApplicationType[] = [
OIDCApplicationType.OIDCAPPLICATIONTYPE_WEB,
OIDCApplicationType.OIDCAPPLICATIONTYPE_USER_AGENT,
OIDCApplicationType.OIDCAPPLICATIONTYPE_NATIVE,
];
public oidcAuthMethodType: OIDCAuthMethodType[] = [
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_BASIC,
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_POST,
OIDCAuthMethodType.OIDCAUTHMETHODTYPE_NONE,
];
public AppState: any = AppState;
public appNameForm!: FormGroup;
public appForm!: FormGroup;
public redirectUrisList: string[] = [];
public postLogoutRedirectUrisList: string[] = [];
public RedirectType: any = RedirectType;
constructor(
public translate: TranslateService,
private route: ActivatedRoute,
private toast: ToastService,
private projectService: ProjectService,
private fb: FormBuilder,
private _location: Location,
private dialog: MatDialog,
) {
this.appNameForm = this.fb.group({
state: ['', []],
name: ['', [Validators.required]],
});
this.appForm = this.fb.group({
clientId: [{ value: '', disabled: true }],
responseTypesList: [],
grantTypesList: [],
applicationType: [],
authMethodType: [],
});
}
public ngOnInit(): void {
this.subscription = this.route.params.subscribe(params => this.getData(params));
}
public ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
private async getData({ projectid, id }: Params): Promise<void> {
this.projectId = projectid;
this.app = (await this.projectService.GetApplicationById(projectid, id)).toObject();
this.appNameForm.patchValue(this.app);
if (this.app.state !== AppState.APPSTATE_ACTIVE) {
this.appNameForm.controls['name'].disable();
this.appForm.disable();
} else {
this.appNameForm.controls['name'].enable();
this.appForm.enable();
this.clientId?.disable();
}
if (this.app.oidcConfig?.redirectUrisList) {
this.redirectUrisList = this.app.oidcConfig.redirectUrisList;
}
if (this.app.oidcConfig?.postLogoutRedirectUrisList) {
this.postLogoutRedirectUrisList = this.app.oidcConfig.postLogoutRedirectUrisList;
}
if (this.app.oidcConfig) {
this.appForm.patchValue(this.app.oidcConfig);
}
}
public changeState(event: MatButtonToggleChange): void {
if (event.value === AppState.APPSTATE_ACTIVE) {
this.projectService.ReactivateApplication(this.app.id).then(() => {
this.toast.showInfo('Reactivated Application');
}).catch((error: any) => {
this.toast.showError(error.message);
});
} else if (event.value === AppState.APPSTATE_INACTIVE) {
this.projectService.DectivateApplication(this.app.id).then(() => {
this.toast.showInfo('Deactivated Application');
}).catch((error: any) => {
this.toast.showError(error.message);
});
}
if (event.value !== AppState.APPSTATE_ACTIVE) {
this.appNameForm.controls['name'].disable();
this.appForm.disable();
} else {
this.appNameForm.controls['name'].enable();
this.appForm.enable();
this.clientId?.disable();
}
}
public add(event: MatChipInputEvent, target: RedirectType): void {
if (target === RedirectType.POSTREDIRECT) {
const input = event.input;
if (event.value !== '' && event.value !== ' ' && event.value !== '/') {
this.postLogoutRedirectUrisList.push(event.value);
}
if (input) {
input.value = '';
}
} else if (target === RedirectType.REDIRECT) {
const input = event.input;
if (event.value !== '' && event.value !== ' ' && event.value !== '/') {
this.redirectUrisList.push(event.value);
}
if (input) {
input.value = '';
}
}
}
public remove(redirect: any, target: RedirectType): void {
if (target === RedirectType.POSTREDIRECT) {
const index = this.postLogoutRedirectUrisList.indexOf(redirect);
if (index >= 0) {
this.postLogoutRedirectUrisList.splice(index, 1);
}
} else if (target === RedirectType.REDIRECT) {
const index = this.redirectUrisList.indexOf(redirect);
if (index >= 0) {
this.redirectUrisList.splice(index, 1);
}
}
}
public saveOIDCApp(): void {
if (this.appNameForm.valid) {
this.app.name = this.name?.value;
}
if (this.appForm.valid) {
if (this.app.oidcConfig) {
this.app.oidcConfig.responseTypesList = this.responseTypesList?.value;
this.app.oidcConfig.grantTypesList = this.grantTypesList?.value;
this.app.oidcConfig.applicationType = this.applicationType?.value;
this.app.oidcConfig.authMethodType = this.authMethodType?.value;
this.app.oidcConfig.redirectUrisList = this.redirectUrisList;
this.app.oidcConfig.postLogoutRedirectUrisList = this.postLogoutRedirectUrisList;
console.log(this.app.oidcConfig);
this.projectService
.UpdateOIDCAppConfig(this.projectId, this.app.id, this.app.oidcConfig)
.then((data: OIDCConfig) => {
this.toast.showInfo('OIDC Config saved');
})
.catch(data => {
this.toast.showError(data.message);
});
}
}
}
public regenerateOIDCClientSecret(): void {
this.projectService.RegenerateOIDCClientSecret(this.app.id).then((data: OIDCConfig) => {
console.log(data.toObject());
this.toast.showInfo('OIDC Secret Regenerated');
this.dialog.open(AppSecretDialogComponent, {
data: {
clientId: data.toObject().clientId,
clientSecret: data.toObject().clientSecret,
},
width: '400px',
});
}).catch(data => {
this.toast.showError(data.message);
});
}
public navigateBack(): void {
this._location.back();
}
public get name(): AbstractControl | null {
return this.appNameForm.get('name');
}
public get clientId(): AbstractControl | null {
return this.appForm.get('clientId');
}
public get responseTypesList(): AbstractControl | null {
return this.appForm.get('responseTypesList');
}
public get grantTypesList(): AbstractControl | null {
return this.appForm.get('grantTypesList');
}
public get applicationType(): AbstractControl | null {
return this.appForm.get('applicationType');
}
public get authMethodType(): AbstractControl | null {
return this.appForm.get('authMethodType');
}
}

View File

@ -0,0 +1,22 @@
<h1 mat-dialog-title>
<span class="title">{{'APP.OIDC.CLIENTSECRET' | translate}}</span>
</h1>
<p class="desc">{{'APP.OIDC.CLIENTSECRET_DESCRIPTION' | translate}}</p>
<div mat-dialog-content>
<p *ngIf="data.clientId">ClientId: {{data.clientId}}</p>
<div *ngIf="data.clientSecret" class="flex">
<button matTooltip="copy to clipboard" (click)="copytoclipboard(data.clientSecret)" mat-icon-button>
<mat-icon *ngIf="!copied" svgIcon="mdi_content_copy"></mat-icon>
<mat-icon *ngIf="copied">check</mat-icon>
</button>
<span class="secret">{{data.clientSecret}}</span>
</div>
</div>
<div mat-dialog-actions class="action">
<button cdkFocusInitial color="primary" mat-raised-button class="ok-button" (click)="closeDialog()">
{{'ACTIONS.CLOSE' | translate}}
</button>
</div>

View File

@ -0,0 +1,37 @@
.title {
font-size: 1.2rem;
}
.desc {
color: #81868a;
font-size: .9rem;
}
.full-width {
width: 100%;
}
.action {
display: flex;
justify-content: flex-end;
.ok-button {
margin-left: 0.5rem;
}
button {
border-radius: 0.5rem;
}
}
.flex {
display: flex;
align-items: center;
padding: .5rem;
border: 1px solid #ffffff20;
border-radius: .5rem;
.secret {
overflow: auto;
}
}

View File

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

View File

@ -0,0 +1,35 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-app-secret-dialog',
templateUrl: './app-secret-dialog.component.html',
styleUrls: ['./app-secret-dialog.component.scss'],
})
export class AppSecretDialogComponent {
public copied: boolean = false;
constructor(public dialogRef: MatDialogRef<AppSecretDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) { }
public closeDialog(): void {
this.dialogRef.close(false);
}
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.copied = true;
setTimeout(() => {
this.copied = false;
}, 3000);
}
}

View File

@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AppCreateComponent } from '../apps/app-create/app-create.component';
import { AppDetailComponent } from '../apps/app-detail/app-detail.component';
const routes: Routes = [
{
path: 'create',
component: AppCreateComponent,
data: { animation: 'AddPage' },
},
{
path: ':id',
component: AppDetailComponent,
data: { animation: 'HomePage' },
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppsRoutingModule { }

View File

@ -0,0 +1,69 @@
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { HttpLoaderFactory } from 'src/app/app.module';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { CardModule } from 'src/app/modules/card/card.module';
import { AppCreateComponent } from './app-create/app-create.component';
import { AppDetailComponent } from './app-detail/app-detail.component';
import { AppSecretDialogComponent } from './app-secret-dialog/app-secret-dialog.component';
import { AppsRoutingModule } from './apps-routing.module';
@NgModule({
declarations: [
AppCreateComponent,
AppDetailComponent,
AppSecretDialogComponent,
],
imports: [
CommonModule,
AppsRoutingModule,
FormsModule,
TranslateModule,
ReactiveFormsModule,
HasRoleModule,
MatFormFieldModule,
MatInputModule,
MatMenuModule,
MatChipsModule,
MatIconModule,
MatSelectModule,
MatButtonToggleModule,
MatButtonModule,
MatProgressSpinnerModule,
MatProgressBarModule,
MatDialogModule,
MatCheckboxModule,
CardModule,
MatTooltipModule,
TranslateModule.forChild({
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient],
},
}),
],
entryComponents: [
AppSecretDialogComponent,
],
exports: [TranslateModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA],
})
export class AppsModule { }

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home.component';
const routes: Routes = [
{
path: '',
component: HomeComponent,
data: { animation: 'HomePage' },
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomeRoutingModule { }

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