From 9f68929b34d962cdde1bc39cb8b7492d89aed4bd Mon Sep 17 00:00:00 2001 From: edraft Date: Sat, 8 Mar 2025 08:20:58 +0100 Subject: [PATCH] Changed to asgi --- .gitea/workflows/test_api_before_merge.yaml | 29 ++ .gitea/workflows/test_before_merge.yaml | 39 -- .gitea/workflows/test_web_before_merge.yaml | 79 ++++ api/requirements.txt | 12 +- api/src/api/api.py | 95 ++--- api/src/api/broadcast.py | 5 + api/src/api/errors.py | 6 +- api/src/api/middleware/__init__.py | 0 api/src/api/middleware/logging.py | 73 ++++ api/src/api/middleware/request.py | 23 ++ api/src/api/route.py | 63 ++-- api/src/api/route_user_extension.py | 85 +---- api/src/api/routes/file.py | 29 +- api/src/api/routes/graphql.py | 11 +- api/src/api/routes/ui.py | 9 +- api/src/api/routes/version.py | 13 +- .../api_graphql/abc/db_model_filter_abc.py | 3 + api/src/api_graphql/abc/query_abc.py | 105 ++++-- api/src/api_graphql/abc/subscription_abc.py | 50 +++ api/src/api_graphql/definition.py | 3 +- api/src/api_graphql/field/dao_field.py | 6 + .../api_graphql/field/dao_field_builder.py | 6 + .../field/mutation_field_builder.py | 34 ++ api/src/api_graphql/field/resolver_field.py | 6 + .../field/resolver_field_builder.py | 6 + .../api_graphql/field/subscription_field.py | 32 ++ .../field/subscription_field_builder.py | 46 +++ api/src/api_graphql/filter/fuzzy_filter.py | 15 + .../api_graphql/filter/short_url_filter.py | 4 +- api/src/api_graphql/graphql/api_key.gql | 12 +- api/src/api_graphql/graphql/domain.gql | 12 + api/src/api_graphql/graphql/feature_flag.gql | 19 + api/src/api_graphql/graphql/group.gql | 12 + api/src/api_graphql/graphql/query.gql | 4 + api/src/api_graphql/graphql/role.gql | 17 +- api/src/api_graphql/graphql/setting.gql | 19 + api/src/api_graphql/graphql/short_url.gql | 14 + api/src/api_graphql/graphql/subscription.gql | 16 + api/src/api_graphql/graphql/user.gql | 18 +- api/src/api_graphql/graphql/user_setting.gql | 19 + api/src/api_graphql/query.py | 45 +++ api/src/api_graphql/service/schema.py | 2 + api/src/api_graphql/subscription.py | 66 ++++ api/src/core/configuration/__init__.py | 0 api/src/core/configuration/feature_flags.py | 20 + .../core/configuration/feature_flags_enum.py | 6 + api/src/core/const.py | 1 + .../database/abc/data_access_object_abc.py | 180 ++++++++- api/src/core/logger.py | 48 ++- api/src/core/string.py | 8 + api/src/data/schemas/public/user_setting.py | 48 +++ .../data/schemas/public/user_setting_dao.py | 24 ++ api/src/data/schemas/system/feature_flag.py | 34 ++ .../data/schemas/system/feature_flag_dao.py | 20 + api/src/data/schemas/system/setting.py | 34 ++ api/src/data/schemas/system/setting_dao.py | 20 + .../scripts/2025-03-08-08-10-settings.sql | 24 ++ .../2025-03-08-08-15-feature-flags.sql | 24 ++ .../2025-03-08-08-15-user-settings.sql | 25 ++ api/src/data/seeder/feature_flags_seeder.py | 40 ++ api/src/data/seeder/settings_seeder.py | 25 ++ api/src/main.py | 21 +- .../service/permission/permissions_enum.py | 7 + api/src/startup.py | 118 +++++- web/ngx-translate-lint.json | 26 ++ web/package-lock.json | 209 +++++++++++ web/package.json | 4 + web/src/app/app-routing.module.ts | 9 +- web/src/app/app.module.ts | 16 +- .../error/not-found/not-found.component.html | 12 +- .../not-found/not-found.component.spec.ts | 10 +- .../error/not-found/not-found.component.ts | 8 +- .../server-unavailable.component.html | 12 + .../server-unavailable.component.scss | 4 + .../server-unavailable.component.spec.ts | 26 ++ .../server-unavailable.component.ts | 15 + .../app/components/footer/footer.component.ts | 18 +- .../app/components/header/header.component.ts | 9 +- web/src/app/core/base/form-page-base.ts | 24 +- web/src/app/core/base/page-base.ts | 53 +-- web/src/app/core/base/page.columns.ts | 56 +-- web/src/app/core/base/page.data.service.ts | 22 +- web/src/app/core/init-keycloak.ts | 10 +- web/src/app/model/auth/permissionsEnum.ts | 2 + web/src/app/model/config/app-settings.ts | 1 + web/src/app/model/entities/feature-flag.ts | 4 + web/src/app/model/entities/setting.ts | 4 + .../administration/administration.module.ts | 56 ++- .../api-keys/api-keys.columns.ts | 20 +- .../api-keys/api-keys.data.service.ts | 86 +++-- .../api-keys/api-keys.module.ts | 28 +- .../api-keys/api-keys.page.spec.ts | 30 +- .../administration/api-keys/api-keys.page.ts | 42 +-- .../api-key-form-page.component.spec.ts | 28 +- .../form-page/api-key-form-page.component.ts | 68 ++-- .../feature-flags.data.service.ts | 105 ++++++ .../feature-flags/feature-flags.module.ts | 21 ++ .../feature-flags/feature-flags.page.html | 10 + .../feature-flags/feature-flags.page.scss | 0 .../feature-flags/feature-flags.page.spec.ts | 47 +++ .../feature-flags/feature-flags.page.ts | 71 ++++ .../form-page/role-form-page.component.html | 132 +++---- .../role-form-page.component.spec.ts | 28 +- .../form-page/role-form-page.component.ts | 2 +- .../administration/roles/roles.columns.ts | 8 +- .../roles/roles.data.service.ts | 13 +- .../administration/roles/roles.module.ts | 28 +- .../administration/roles/roles.page.spec.ts | 30 +- .../admin/administration/roles/roles.page.ts | 42 +-- .../settings/settings.data.service.ts | 105 ++++++ .../settings/settings.module.ts | 21 ++ .../settings/settings.page.html | 20 + .../settings/settings.page.scss | 0 .../settings/settings.page.spec.ts | 47 +++ .../administration/settings/settings.page.ts | 94 +++++ .../user-form-page.component.spec.ts | 28 +- .../form-page/user-form-page.component.ts | 10 +- .../administration/users/users.columns.ts | 20 +- .../users/users.data.service.ts | 35 ++ .../administration/users/users.module.ts | 28 +- .../administration/users/users.page.spec.ts | 30 +- .../admin/administration/users/users.page.ts | 42 +-- .../modules/admin/domains/domains.columns.ts | 2 +- .../admin/domains/domains.data.service.ts | 12 + .../modules/admin/groups/groups.columns.ts | 14 +- .../admin/groups/groups.data.service.ts | 12 + .../app/modules/admin/public/public.module.ts | 12 - .../admin/short-urls/short-urls.columns.ts | 26 +- .../short-urls/short-urls.data.service.ts | 12 + .../admin/short-urls/short-urls.page.ts | 9 +- .../column-selector.component.html | 20 + .../column-selector.component.scss | 0 .../column-selector.component.spec.ts | 45 +++ .../column-selector.component.ts | 36 ++ .../components/table/table.component.html | 346 ++++++++++-------- .../components/table/table.component.spec.ts | 26 +- .../components/table/table.component.ts | 148 ++++---- .../shared/components/table/table.model.ts | 16 +- .../modules/shared/components/table/table.ts | 17 + web/src/app/modules/shared/shared.module.ts | 92 ++++- web/src/app/service/config.service.ts | 62 ++++ web/src/app/service/gui.service.ts | 8 +- web/src/app/service/settings.service.ts | 100 +++-- web/src/app/service/sidebar.service.ts | 30 +- web/src/assets/config/config.json | 1 + 145 files changed, 3728 insertions(+), 1136 deletions(-) create mode 100644 .gitea/workflows/test_api_before_merge.yaml delete mode 100644 .gitea/workflows/test_before_merge.yaml create mode 100644 .gitea/workflows/test_web_before_merge.yaml create mode 100644 api/src/api/broadcast.py create mode 100644 api/src/api/middleware/__init__.py create mode 100644 api/src/api/middleware/logging.py create mode 100644 api/src/api/middleware/request.py create mode 100644 api/src/api_graphql/abc/subscription_abc.py create mode 100644 api/src/api_graphql/field/subscription_field.py create mode 100644 api/src/api_graphql/field/subscription_field_builder.py create mode 100644 api/src/api_graphql/filter/fuzzy_filter.py create mode 100644 api/src/api_graphql/graphql/feature_flag.gql create mode 100644 api/src/api_graphql/graphql/setting.gql create mode 100644 api/src/api_graphql/graphql/subscription.gql create mode 100644 api/src/api_graphql/graphql/user_setting.gql create mode 100644 api/src/api_graphql/subscription.py create mode 100644 api/src/core/configuration/__init__.py create mode 100644 api/src/core/configuration/feature_flags.py create mode 100644 api/src/core/configuration/feature_flags_enum.py create mode 100644 api/src/core/const.py create mode 100644 api/src/core/string.py create mode 100644 api/src/data/schemas/public/user_setting.py create mode 100644 api/src/data/schemas/public/user_setting_dao.py create mode 100644 api/src/data/schemas/system/feature_flag.py create mode 100644 api/src/data/schemas/system/feature_flag_dao.py create mode 100644 api/src/data/schemas/system/setting.py create mode 100644 api/src/data/schemas/system/setting_dao.py create mode 100644 api/src/data/scripts/2025-03-08-08-10-settings.sql create mode 100644 api/src/data/scripts/2025-03-08-08-15-feature-flags.sql create mode 100644 api/src/data/scripts/2025-03-08-08-15-user-settings.sql create mode 100644 api/src/data/seeder/feature_flags_seeder.py create mode 100644 api/src/data/seeder/settings_seeder.py create mode 100755 web/ngx-translate-lint.json create mode 100644 web/src/app/components/error/server-unavailable/server-unavailable.component.html create mode 100644 web/src/app/components/error/server-unavailable/server-unavailable.component.scss create mode 100644 web/src/app/components/error/server-unavailable/server-unavailable.component.spec.ts create mode 100644 web/src/app/components/error/server-unavailable/server-unavailable.component.ts create mode 100644 web/src/app/model/entities/feature-flag.ts create mode 100644 web/src/app/model/entities/setting.ts create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.data.service.ts create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.module.ts create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.page.html create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.page.scss create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.page.spec.ts create mode 100644 web/src/app/modules/admin/administration/feature-flags/feature-flags.page.ts create mode 100644 web/src/app/modules/admin/administration/settings/settings.data.service.ts create mode 100644 web/src/app/modules/admin/administration/settings/settings.module.ts create mode 100644 web/src/app/modules/admin/administration/settings/settings.page.html create mode 100644 web/src/app/modules/admin/administration/settings/settings.page.scss create mode 100644 web/src/app/modules/admin/administration/settings/settings.page.spec.ts create mode 100644 web/src/app/modules/admin/administration/settings/settings.page.ts delete mode 100644 web/src/app/modules/admin/public/public.module.ts create mode 100644 web/src/app/modules/shared/components/table/column-selector/column-selector.component.html create mode 100644 web/src/app/modules/shared/components/table/column-selector/column-selector.component.scss create mode 100644 web/src/app/modules/shared/components/table/column-selector/column-selector.component.spec.ts create mode 100644 web/src/app/modules/shared/components/table/column-selector/column-selector.component.ts create mode 100644 web/src/app/modules/shared/components/table/table.ts create mode 100644 web/src/app/service/config.service.ts diff --git a/.gitea/workflows/test_api_before_merge.yaml b/.gitea/workflows/test_api_before_merge.yaml new file mode 100644 index 0000000..403bd2f --- /dev/null +++ b/.gitea/workflows/test_api_before_merge.yaml @@ -0,0 +1,29 @@ +name: Test before pr merge +run-name: Test before pr merge +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +jobs: + test-lint: + runs-on: [ runner ] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Installing dependencies + working-directory: ./api + run: | + python3.12 -m pip install -r requirements-dev.txt + + - name: Checking black + working-directory: ./api + run: python3.12 -m black src --check \ No newline at end of file diff --git a/.gitea/workflows/test_before_merge.yaml b/.gitea/workflows/test_before_merge.yaml deleted file mode 100644 index a457902..0000000 --- a/.gitea/workflows/test_before_merge.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Test before pr merge -run-name: Test before pr merge -on: - pull_request: - types: - - opened - - edited - - reopened - - synchronize - - ready_for_review - -jobs: - test-before-merge: - runs-on: [ runner ] - container: git.sh-edraft.de/sh-edraft.de/act-runner:latest - steps: - - name: Clone Repository - uses: https://github.com/actions/checkout@v3 - with: - token: ${{ secrets.CI_ACCESS_TOKEN }} - - - name: Setup node - uses: https://github.com/actions/setup-node@v3 - - - name: Installing dependencies - run: npm ci - - - name: Checking eslint - run: npm run lint - - - name: Setup chrome - run: | - wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - - echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list - apt-get update - apt-get install -y google-chrome-stable xvfb - - - name: Testing - run: npm run test:ci diff --git a/.gitea/workflows/test_web_before_merge.yaml b/.gitea/workflows/test_web_before_merge.yaml new file mode 100644 index 0000000..23259f9 --- /dev/null +++ b/.gitea/workflows/test_web_before_merge.yaml @@ -0,0 +1,79 @@ +name: Test before pr merge +run-name: Test before pr merge +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +jobs: + test-lint: + runs-on: [ runner ] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Setup node + uses: https://github.com/actions/setup-node@v3 + + - name: Installing dependencies + working-directory: ./web + run: npm ci + + - name: Checking eslint + working-directory: ./web + run: npm run lint + + test-translation-lint: + runs-on: [ runner ] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Setup node + uses: https://github.com/actions/setup-node@v3 + + - name: Installing dependencies + working-directory: ./web + run: npm ci + + - name: Checking translations + working-directory: ./web + run: npm run lint:translations + + test-before-merge: + runs-on: [ runner ] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Setup node + uses: https://github.com/actions/setup-node@v3 + + - name: Installing dependencies + working-directory: ./web + run: npm ci + + - name: Setup chrome + working-directory: ./web + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - + echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list + apt-get update + apt-get install -y google-chrome-stable xvfb + + - name: Testing + working-directory: ./web + run: npm run test:ci diff --git a/api/requirements.txt b/api/requirements.txt index d78cd8d..600d243 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,10 +1,12 @@ ariadne==0.23.0 -eventlet==0.37.0 +broadcaster==0.3.1 graphql-core==3.2.5 -Flask[async]==3.1.0 -Flask-Cors==5.0.0 async-property==0.2.2 -python-keycloak==4.7.3 psycopg[binary]==3.2.3 psycopg-pool==3.2.4 -Werkzeug==3.1.3 +uvicorn==0.34.0 +starlette==0.46.0 +requests==2.32.3 +python-keycloak==5.3.1 +python-multipart==0.0.20 +websockets==15.0 diff --git a/api/src/api/api.py b/api/src/api/api.py index c9d6ec5..a5cfd3d 100644 --- a/api/src/api/api.py +++ b/api/src/api/api.py @@ -1,78 +1,45 @@ import importlib import os -import time -from uuid import uuid4 +from typing import Optional -from flask import Flask, request, g +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse -from api.route import Route +from core.environment import Environment from core.logger import APILogger -app = Flask(__name__) logger = APILogger(__name__) -def filter_relevant_headers(headers: dict) -> dict: - relevant_keys = { - "Content-Type", - "Host", - "Connection", - "User-Agent", - "Origin", - "Referer", - "Accept", - } - return {key: value for key, value in headers.items() if key in relevant_keys} +class API: + app: Optional[Starlette] = None + @classmethod + def create(cls, app: Starlette): + cls.app = app -@app.before_request -async def log_request(): - g.request_id = uuid4() - g.start_time = time.time() - logger.debug( - f"Request {g.request_id}: {request.method}@{request.path} from {request.remote_addr}" - ) - user = await Route.get_user() + @staticmethod + async def handle_exception(request: Request, exc: Exception): + logger.error(f"Request {request.state.request_id}", exc) + return JSONResponse({"error": str(exc)}, status_code=500) - request_info = { - "headers": filter_relevant_headers(dict(request.headers)), - "args": request.args.to_dict(), - "form-data": request.form.to_dict(), - "payload": request.get_json(silent=True), - "user": f"{user.id}-{user.keycloak_id}" if user else None, - "files": ( - {key: file.filename for key, file in request.files.items()} - if request.files - else None - ), - } + @staticmethod + def get_allowed_origins(): + client_urls = Environment.get("CLIENT_URLS", str) + if client_urls is None or client_urls == "": + allowed_origins = ["*"] + logger.warning("No allowed origins specified, allowing all origins") + else: + allowed_origins = client_urls.split(",") - logger.trace(f"Request {g.request_id}: {request_info}") + return allowed_origins - -@app.after_request -def log_after_request(response): - # calc the time it took to process the request - duration = (time.time() - g.start_time) * 1000 - logger.info( - f"Request finished {g.request_id}: {response.status_code}-{request.method}@{request.path} from {request.remote_addr} in {duration:.2f}ms" - ) - return response - - -@app.errorhandler(Exception) -def handle_exception(e): - logger.error(f"Request {g.request_id}", e) - return {"error": str(e)}, 500 - - -# used to import all routes -routes_dir = os.path.join(os.path.dirname(__file__), "routes") -for filename in os.listdir(routes_dir): - if filename.endswith(".py") and filename != "__init__.py": - module_name = f"api.routes.{filename[:-3]}" - importlib.import_module(module_name) - -# Explicitly register the routes -for route, (view_func, options) in Route.registered_routes.items(): - app.add_url_rule(route, view_func=view_func, **options) + @staticmethod + def import_routes(): + # used to import all routes + routes_dir = os.path.join(os.path.dirname(__file__), "routes") + for filename in os.listdir(routes_dir): + if filename.endswith(".py") and filename != "__init__.py": + module_name = f"api.routes.{filename[:-3]}" + importlib.import_module(module_name) diff --git a/api/src/api/broadcast.py b/api/src/api/broadcast.py new file mode 100644 index 0000000..1de3a92 --- /dev/null +++ b/api/src/api/broadcast.py @@ -0,0 +1,5 @@ +from typing import Optional + +from broadcaster import Broadcast + +broadcast: Optional[Broadcast] = Broadcast("memory://") diff --git a/api/src/api/errors.py b/api/src/api/errors.py index 5b070d3..e52a4c3 100644 --- a/api/src/api/errors.py +++ b/api/src/api/errors.py @@ -1,9 +1,9 @@ -from flask import jsonify +from starlette.responses import JSONResponse def unauthorized(): - return jsonify({"error": "Unauthorized"}), 401 + return JSONResponse({"error": "Unauthorized"}, 401) def forbidden(): - return jsonify({"error": "Unauthorized"}), 401 + return JSONResponse({"error": "Unauthorized"}, 401) diff --git a/api/src/api/middleware/__init__.py b/api/src/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/api/middleware/logging.py b/api/src/api/middleware/logging.py new file mode 100644 index 0000000..1a6d7a6 --- /dev/null +++ b/api/src/api/middleware/logging.py @@ -0,0 +1,73 @@ +import time +from uuid import uuid4 + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from api.route import Route +from core.logger import APILogger + +logger = APILogger("api.api") + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + await self._log_request(request) + response = await call_next(request) + await self._log_after_request(request, response) + + return response + + @staticmethod + def _filter_relevant_headers(headers: dict) -> dict: + relevant_keys = { + "content-type", + "host", + "connection", + "user-agent", + "origin", + "referer", + "accept", + } + return {key: value for key, value in headers.items() if key in relevant_keys} + + @classmethod + async def _log_request(cls, request: Request): + request.state.request_id = uuid4() + request.state.start_time = time.time() + logger.debug( + f"Request {request.state.request_id}: {request.method}@{request.url.path} from {request.client.host}" + ) + user = await Route.get_user() + + request_info = { + "headers": cls._filter_relevant_headers(dict(request.headers)), + "args": dict(request.query_params), + "form-data": ( + await request.form() + if request.headers.get("content-type") + == "application/x-www-form-urlencoded" + else None + ), + "payload": ( + await request.json() + if request.headers.get("content-length") == "0" + else None + ), + "user": f"{user.id}-{user.keycloak_id}" if user else None, + "files": ( + {key: file.filename for key, file in (await request.form()).items()} + if await request.form() + else None + ), + } + + logger.trace(f"Request {request.state.request_id}: {request_info}") + + @staticmethod + async def _log_after_request(request: Request, response: Response): + duration = (time.time() - request.state.start_time) * 1000 + logger.info( + f"Request finished {request.state.request_id}: {response.status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms" + ) diff --git a/api/src/api/middleware/request.py b/api/src/api/middleware/request.py new file mode 100644 index 0000000..e2b5ee0 --- /dev/null +++ b/api/src/api/middleware/request.py @@ -0,0 +1,23 @@ +from contextvars import ContextVar +from typing import Optional, Union + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +_request_context: ContextVar[Union[Request, None]] = ContextVar("request", default=None) + + +class RequestMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + _request_context.set(request) + + from core.logger import APILogger + + logger = APILogger(__name__) + logger.trace("Set new current request") + response = await call_next(request) + return response + + +def get_request() -> Optional[Request]: + return _request_context.get() diff --git a/api/src/api/route.py b/api/src/api/route.py index 007eeff..6347fcd 100644 --- a/api/src/api/route.py +++ b/api/src/api/route.py @@ -2,12 +2,12 @@ import functools from functools import wraps from inspect import iscoroutinefunction from typing import Callable, Union, Optional -from urllib.request import Request -from flask import request -from flask_cors import cross_origin +from starlette.requests import Request +from starlette.routing import Route as StarletteRoute from api.errors import unauthorized +from api.middleware.request import get_request from api.route_user_extension import RouteUserExtension from core.environment import Environment from data.schemas.administration.api_key import ApiKey @@ -16,10 +16,10 @@ from data.schemas.administration.user import User class Route(RouteUserExtension): - registered_routes = {} + registered_routes: list[StarletteRoute] = [] @classmethod - async def get_api_key(cls) -> ApiKey: + async def get_api_key(cls, request: Request) -> ApiKey: auth_header = request.headers.get("Authorization", None) api_key = auth_header.split(" ")[1] return await apiKeyDao.find_by_key(api_key) @@ -35,41 +35,55 @@ class Route(RouteUserExtension): return api_key_from_db is not None and not api_key_from_db.deleted @classmethod - async def _get_auth_type(cls, auth_header: str) -> Optional[Union[User, ApiKey]]: + async def _get_auth_type( + cls, request: Request, auth_header: str + ) -> Optional[Union[User, ApiKey]]: if auth_header.startswith("Bearer "): return await cls.get_user() elif auth_header.startswith("API-Key "): - return await cls.get_api_key() + return await cls.get_api_key(request) elif ( - auth_header.startswith("DEV-User ") - and Environment.get_environment() == "development" + auth_header.startswith("DEV-User ") + and Environment.get_environment() == "development" ): return await cls.get_dev_user() return None @classmethod async def get_authenticated_user_or_api_key(cls) -> Union[User, ApiKey]: + request = get_request() + if request is None: + raise ValueError("No request found") + auth_header = request.headers.get("Authorization", None) if not auth_header: raise Exception("No Authorization header found") - user_or_api_key = await cls._get_auth_type(auth_header) + user_or_api_key = await cls._get_auth_type(request, auth_header) if user_or_api_key is None: raise Exception("Invalid Authorization header") return user_or_api_key @classmethod async def get_authenticated_user_or_api_key_or_default( - cls, + cls, ) -> Optional[Union[User, ApiKey]]: + request = get_request() + if request is None: + return None + auth_header = request.headers.get("Authorization", None) if not auth_header: return None - return await cls._get_auth_type(auth_header) + return await cls._get_auth_type(request, auth_header) @classmethod async def is_authorized(cls) -> bool: + request = get_request() + if request is None: + return False + auth_header = request.headers.get("Authorization", None) if not auth_header: return False @@ -79,8 +93,8 @@ class Route(RouteUserExtension): elif auth_header.startswith("API-Key "): return await cls._verify_api_key(request) elif ( - auth_header.startswith("DEV-User ") - and Environment.get_environment() == "development" + auth_header.startswith("DEV-User ") + and Environment.get_environment() == "development" ): user = await cls.get_dev_user() return user is not None @@ -88,10 +102,10 @@ class Route(RouteUserExtension): @classmethod def authorize( - cls, - f: Callable = None, - skip_in_dev=False, - by_api_key=False, + cls, + f: Callable = None, + skip_in_dev=False, + by_api_key=False, ): if f is None: return functools.partial( @@ -99,26 +113,25 @@ class Route(RouteUserExtension): ) @wraps(f) - async def decorator(*args, **kwargs): + async def decorator(request: Request, *args, **kwargs): if skip_in_dev and Environment.get_environment() == "development": if iscoroutinefunction(f): - return await f(*args, **kwargs) - return f(*args, **kwargs) + return await f(request, *args, **kwargs) + return f(request, *args, **kwargs) if not await cls.is_authorized(): return unauthorized() if iscoroutinefunction(f): - return await f(*args, **kwargs) - return f(*args, **kwargs) + return await f(request, *args, **kwargs) + return f(request, *args, **kwargs) return decorator @classmethod def route(cls, path=None, **kwargs): def inner(fn): - cross_origin(fn) - cls.registered_routes[path] = (fn, kwargs) + cls.registered_routes.append(StarletteRoute(path, fn, **kwargs)) return fn return inner diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index bcd1263..b728a92 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -1,10 +1,11 @@ from typing import Optional -from flask import request, Request, has_request_context from keycloak import KeycloakAuthenticationError, KeycloakConnectionError +from starlette.requests import Request from api.auth.keycloak_client import Keycloak from api.auth.keycloak_user import KeycloakUser +from api.middleware.request import get_request from core.get_value import get_value from core.logger import Logger from data.schemas.administration.user import User @@ -19,8 +20,8 @@ logger = Logger(__name__) class RouteUserExtension: @classmethod - def _get_user_id_from_token(cls) -> Optional[str]: - token = cls.get_token() + def _get_user_id_from_token(cls, request: Request) -> Optional[str]: + token = cls.get_token(request) if not token: return None @@ -34,7 +35,7 @@ class RouteUserExtension: return get_value(user_info, "sub", str) @staticmethod - def get_token() -> Optional[str]: + def get_token(request: Request) -> Optional[str]: if "Authorization" not in request.headers: return None @@ -45,23 +46,24 @@ class RouteUserExtension: @classmethod async def get_user(cls) -> Optional[User]: - if not has_request_context(): + request = get_request() + if request is None: return None - user_id = cls._get_user_id_from_token() + user_id = cls._get_user_id_from_token(request) if not user_id: return None - user = await userDao.find_by_keycloak_id(user_id) - if user is None: - return None - - return user + return await userDao.find_by_keycloak_id(user_id) @classmethod async def get_dev_user(cls) -> Optional[User]: + request = get_request() + if request is None: + return None + return await userDao.find_single_by( - [{User.keycloak_id: cls.get_token()}, {User.deleted: False}] + [{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}] ) @classmethod @@ -71,56 +73,6 @@ class RouteUserExtension: user = await cls.get_dev_user() return user - @classmethod - def _flatten_groups(cls, groups): - flat_list = [] - for group in groups: - flat_list.append(group) - if "subGroups" in group and group["subGroups"]: - flat_list.extend(cls._flatten_groups(group["subGroups"])) - return flat_list - - @classmethod - async def _map_keycloak_groups_with_roles(cls, user: User): - try: - roles = {x.name: x for x in await roleDao.get_all()} - groups = cls._flatten_groups(Keycloak.admin.get_groups(full_hierarchy=True)) - groups_with_role = [x["name"] for x in groups if x["name"] in roles.keys()] - - user_groups_with_role = [ - x["name"] - for x in Keycloak.admin.get_user_groups(user.keycloak_id) - if x["name"] in roles.keys() - ] - user_roles = set( - x.name for x in await user.roles if x.name in groups_with_role - ) - missing_groups = set(user_groups_with_role) - set(user_roles) - missing_roles = set(user_roles) - set(user_groups_with_role) - - if len(missing_groups) > 0: - await roleUserDao.create_many( - [ - RoleUser(0, (await roleDao.get_by_name(group)).id, user.id) - for group in missing_groups - ] - ) - - if len(missing_roles) > 0: - await roleUserDao.delete_many( - [ - await roleUserDao.get_single_by( - [ - {RoleUser.role_id: roles[role].id}, - {RoleUser.user_id: user.id}, - ] - ) - for role in missing_roles - ] - ) - except Exception as e: - logger.error("Failed to map user groups", e) - @classmethod async def _create_user(cls, kc_user: KeycloakUser): try: @@ -140,8 +92,8 @@ class RouteUserExtension: logger.error("Failed to find or create user", e) @classmethod - async def verify_login(cls, req: Request) -> bool: - auth_header = req.headers.get("Authorization", None) + async def verify_login(cls, request: Request) -> bool: + auth_header = request.headers.get("Authorization", None) if not auth_header or not auth_header.startswith("Bearer "): return False @@ -155,11 +107,8 @@ class RouteUserExtension: user = await cls.get_user() if user is None: - u_id = await cls._create_user(KeycloakUser(user_info)) - await cls._map_keycloak_groups_with_roles(await userDao.get_by_id(u_id)) + await cls._create_user(KeycloakUser(user_info)) return True - else: - await cls._map_keycloak_groups_with_roles(user) if user.deleted: return False diff --git a/api/src/api/routes/file.py b/api/src/api/routes/file.py index 253102f..ecbc60e 100644 --- a/api/src/api/routes/file.py +++ b/api/src/api/routes/file.py @@ -1,7 +1,8 @@ from uuid import uuid4 -from flask import send_file -from werkzeug.exceptions import NotFound +from starlette.requests import Request +from starlette.responses import FileResponse +from starlette.exceptions import HTTPException from api.route import Route from core.logger import APILogger @@ -9,19 +10,23 @@ from core.logger import APILogger logger = APILogger(__name__) -@Route.get(f"/api/files/") -def get_file(file_path: str): +@Route.get("/api/files/{file_path:path}") +async def get_file(request: Request): + file_path = request.path_params["file_path"] name = file_path if "/" in file_path: name = file_path.split("/")[-1] try: - return send_file( - f"../files/{file_path}", download_name=name, as_attachment=True + return FileResponse( + path=f"files/{file_path}", + filename=name, + media_type="application/octet-stream", ) - except NotFound: - return {"error": "File not found"}, 404 - except Exception as e: - error_id = uuid4() - logger.error(f"Error {error_id} getting file {file_path}", e) - return {"error": f"File error. ErrorId: {error_id}"}, 500 + except HTTPException as e: + if e.status_code == 404: + return {"error": "File not found"}, 404 + else: + error_id = uuid4() + logger.error(f"Error {error_id} getting file {file_path}", e) + return {"error": f"File error. ErrorId: {error_id}"}, 500 diff --git a/api/src/api/routes/graphql.py b/api/src/api/routes/graphql.py index ceec9e4..52920d8 100644 --- a/api/src/api/routes/graphql.py +++ b/api/src/api/routes/graphql.py @@ -1,5 +1,6 @@ from ariadne import graphql -from flask import request, jsonify +from starlette.requests import Request +from starlette.responses import JSONResponse from api.route import Route from api_graphql.service.schema import schema @@ -10,11 +11,11 @@ logger = Logger(__name__) @Route.post(f"{BasePath}") -async def graphql_endpoint(): - data = request.get_json() +async def graphql_endpoint(request: Request): + data = await request.json() # Note: Passing the request to the context is optional. - # In Flask, the current request is always accessible as flask.request + # In Starlette, the current request is accessible as request success, result = await graphql(schema, data, context_value=request) status_code = 200 @@ -24,4 +25,4 @@ async def graphql_endpoint(): ] status_code = max(status_codes, default=200) - return jsonify(result), status_code + return JSONResponse(result, status_code=status_code) diff --git a/api/src/api/routes/ui.py b/api/src/api/routes/ui.py index 1eae647..8a298e5 100644 --- a/api/src/api/routes/ui.py +++ b/api/src/api/routes/ui.py @@ -1,4 +1,6 @@ from ariadne.explorer import ExplorerPlayground +from starlette.requests import Request +from starlette.responses import HTMLResponse from api.route import Route from core.environment import Environment @@ -10,7 +12,7 @@ logger = Logger(__name__) @Route.get(f"{BasePath}/playground") @Route.authorize(skip_in_dev=True) -async def playground(): +async def playground(r: Request): if Environment.get_environment() != "development": return "", 403 @@ -19,7 +21,6 @@ async def playground(): if dev_user: request_global_headers = {f"Authorization": f"DEV-User {dev_user}"} - return ( - ExplorerPlayground(request_global_headers=request_global_headers).html(None), - 200, + return HTMLResponse( + ExplorerPlayground(request_global_headers=request_global_headers).html(None) ) diff --git a/api/src/api/routes/version.py b/api/src/api/routes/version.py index fab0f1d..bc7f230 100644 --- a/api/src/api/routes/version.py +++ b/api/src/api/routes/version.py @@ -1,7 +1,16 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse + from api.route import Route +from core.configuration.feature_flags import FeatureFlags +from core.configuration.feature_flags_enum import FeatureFlagsEnum from version import VERSION @Route.get(f"/api/version") -def version(): - return VERSION +async def version(r: Request): + feature = await FeatureFlags.has_feature(FeatureFlagsEnum.version_endpoint) + if not feature: + return JSONResponse("DISABLED", status_code=403) + + return JSONResponse(VERSION) diff --git a/api/src/api_graphql/abc/db_model_filter_abc.py b/api/src/api_graphql/abc/db_model_filter_abc.py index a3c1336..1a8931b 100644 --- a/api/src/api_graphql/abc/db_model_filter_abc.py +++ b/api/src/api_graphql/abc/db_model_filter_abc.py @@ -4,6 +4,7 @@ from api_graphql.abc.filter.bool_filter import BoolFilter from api_graphql.abc.filter.int_filter import IntFilter from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.abc.filter_abc import FilterABC +from api_graphql.filter.fuzzy_filter import FuzzyFilter class DbModelFilterABC[T](FilterABC[T]): @@ -18,3 +19,5 @@ class DbModelFilterABC[T](FilterABC[T]): self.add_field("editor", IntFilter) self.add_field("createdUtc", StringFilter, "created") self.add_field("updatedUtc", StringFilter, "updated") + + self.add_field("fuzzy", FuzzyFilter) diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index b19cee8..9c74ee4 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -4,12 +4,13 @@ from enum import Enum from types import NoneType from typing import Callable, Type, get_args, Any, Union -from ariadne import ObjectType +from ariadne import ObjectType, SubscriptionType from graphql import GraphQLResolveInfo from typing_extensions import deprecated from api.route import Route from api_graphql.abc.collection_filter_abc import CollectionFilterABC +from api_graphql.abc.field_abc import FieldABC from api_graphql.abc.input_abc import InputABC from api_graphql.abc.sort_abc import Sort from api_graphql.field.collection_field import CollectionField @@ -20,6 +21,7 @@ from api_graphql.field.mutation_field import MutationField from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.resolver_field import ResolverField from api_graphql.field.resolver_field_builder import ResolverFieldBuilder +from api_graphql.field.subscription_field import SubscriptionField from api_graphql.service.collection_result import CollectionResult from api_graphql.service.exceptions import ( UnauthorizedException, @@ -29,6 +31,7 @@ from api_graphql.service.exceptions import ( from api_graphql.service.query_context import QueryContext from api_graphql.typing import TRequireAnyPermissions, TRequireAnyResolvers from core.logger import APILogger +from core.string import first_to_lower from service.permission.permissions_enum import Permissions logger = APILogger(__name__) @@ -40,6 +43,7 @@ class QueryABC(ObjectType): @abstractmethod def __init__(self, name: str = __name__): ObjectType.__init__(self, name) + self._subscriptions: dict[str, SubscriptionType] = {} @staticmethod async def _authorize(): @@ -60,19 +64,19 @@ class QueryABC(ObjectType): @classmethod async def _require_any( - cls, - data: Any, - permissions: TRequireAnyPermissions, - resolvers: TRequireAnyResolvers, - *args, - **kwargs, + cls, + data: Any, + permissions: TRequireAnyPermissions, + resolvers: TRequireAnyResolvers, + *args, + **kwargs, ): + info = args[0] + if len(permissions) > 0: user = await Route.get_authenticated_user_or_api_key_or_default() - perms = await user.permissions - has_perms = [await user.has_permission(x) for x in permissions] if user is not None and all( - has_perms + [await user.has_permission(x) for x in permissions] ): return @@ -93,13 +97,13 @@ class QueryABC(ObjectType): raise AccessDenied() def field( - self, - builder: Union[ - DaoFieldBuilder, - CollectionFieldBuilder, - ResolverFieldBuilder, - MutationFieldBuilder, - ], + self, + builder: Union[ + DaoFieldBuilder, + CollectionFieldBuilder, + ResolverFieldBuilder, + MutationFieldBuilder, + ], ): """ Add a field to the query @@ -134,7 +138,12 @@ class QueryABC(ObjectType): skip = kwargs["skip"] collection = await field.dao.find_by(filters, sorts, take, skip) - res = CollectionResult(await field.dao.count(), len(collection), collection) + if field.direct_result: + return collection + + res = CollectionResult( + await field.dao.count(filters), len(collection), collection + ) return res async def collection_wrapper(*args, **kwargs): @@ -171,11 +180,12 @@ class QueryABC(ObjectType): ) async def resolver_wrapper(*args, **kwargs): - return ( + result = ( await field.resolver(*args, **kwargs) if iscoroutinefunction(field.resolver) else field.resolver(*args, **kwargs) ) + return result if isinstance(field, DaoField): resolver = dao_wrapper @@ -189,7 +199,7 @@ class QueryABC(ObjectType): elif isinstance(field, MutationField): async def input_wrapper( - mutation: QueryABC, info: GraphQLResolveInfo, **kwargs + mutation: QueryABC, info: GraphQLResolveInfo, **kwargs ): if field.input_type is None: return await resolver_wrapper(mutation, info, **kwargs) @@ -205,6 +215,13 @@ class QueryABC(ObjectType): resolver = input_wrapper + elif isinstance(field, SubscriptionField): + + async def sub_wrapper(sub: QueryABC, info: GraphQLResolveInfo, **kwargs): + return await resolver_wrapper(sub, info, **kwargs) + + resolver = sub_wrapper + else: raise ValueError(f"Unknown field type: {field.name}") @@ -213,16 +230,21 @@ class QueryABC(ObjectType): await self._authorize() if ( - field.require_any is None - and not field.public - and field.require_any_permission + field.require_any is None + and not field.public + and field.require_any_permission ): await self._require_any_permission(field.require_any_permission) result = await resolver(*args, **kwargs) if field.require_any is not None: - await self._require_any(result, *field.require_any, *args, **kwargs) + await self._require_any( + result, + *field.require_any, + *args, + **kwargs, + ) return result @@ -230,13 +252,13 @@ class QueryABC(ObjectType): @deprecated("Use field(FieldBuilder()) instead") def mutation( - self, - name: str, - f: Callable, - input_type: Type[InputABC] = None, - input_key: str = "input", - require_any_permission: list[Permissions] = None, - public: bool = False, + self, + name: str, + f: Callable, + input_type: Type[InputABC] = None, + input_key: str = "input", + require_any_permission: list[Permissions] = None, + public: bool = False, ): """ Adds a mutation to the query @@ -252,6 +274,9 @@ class QueryABC(ObjectType): self.field( MutationFieldBuilder(name) .with_resolver(f) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) .with_input(input_type, input_key) .with_require_any_permission(require_any_permission) .with_public(public) @@ -259,13 +284,13 @@ class QueryABC(ObjectType): @classmethod def _resolve_collection( - cls, - collection: list, - *_, - filters: list[CollectionFilterABC] = None, - sort: list[Sort] = None, - skip: int = None, - take: int = None, + cls, + collection: list, + *_, + filters: list[CollectionFilterABC] = None, + sort: list[Sort] = None, + skip: int = None, + take: int = None, ) -> CollectionResult: total_count = len(collection) @@ -273,6 +298,8 @@ class QueryABC(ObjectType): for f in filters: collection = list(filter(lambda x: f.filter(x), collection)) + total_count = len(collection) + if sort is not None: def f_sort(x: object, k: str): @@ -286,7 +313,7 @@ class QueryABC(ObjectType): return attr for s in reversed( - sort + sort ): # Apply sorting in reverse order to make first primary "orderBy" and other secondary "thenBy" attrs = [a for a in dir(s) if not a.startswith("_")] for k in attrs: diff --git a/api/src/api_graphql/abc/subscription_abc.py b/api/src/api_graphql/abc/subscription_abc.py new file mode 100644 index 0000000..e38f730 --- /dev/null +++ b/api/src/api_graphql/abc/subscription_abc.py @@ -0,0 +1,50 @@ +from abc import abstractmethod +from asyncio import iscoroutinefunction + +from ariadne import SubscriptionType + +from api_graphql.abc.query_abc import QueryABC +from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder +from core.logger import APILogger + +logger = APILogger(__name__) + + +class SubscriptionABC(SubscriptionType, QueryABC): + + @abstractmethod + def __init__(self): + SubscriptionType.__init__(self) + + def subscribe(self, builder: SubscriptionFieldBuilder): + field = builder.build() + + async def wrapper(*args, **kwargs): + if not field.public: + await self._authorize() + + if ( + field.require_any is None + and not field.public + and field.require_any_permission + ): + await self._require_any_permission(field.require_any_permission) + + result = ( + await field.resolver(*args, **kwargs) + if iscoroutinefunction(field.resolver) + else field.resolver(*args, **kwargs) + ) + + if field.require_any is not None: + await self._require_any( + result, + *field.require_any, + *args, + **kwargs, + ) + + return result + + self.set_field(field.name, wrapper) + self.set_source(field.name, field.generator) diff --git a/api/src/api_graphql/definition.py b/api/src/api_graphql/definition.py index 39e7467..772b87f 100644 --- a/api/src/api_graphql/definition.py +++ b/api/src/api_graphql/definition.py @@ -4,6 +4,7 @@ import os from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.query_abc import QueryABC +from api_graphql.abc.subscription_abc import SubscriptionABC from api_graphql.query import Query @@ -19,7 +20,7 @@ def import_graphql_schema_part(part: str): import_graphql_schema_part("queries") import_graphql_schema_part("mutations") -sub_query_classes = [DbModelQueryABC, MutationABC] +sub_query_classes = [DbModelQueryABC, MutationABC, SubscriptionABC] query_classes = [ *[y for x in sub_query_classes for y in x.__subclasses__()], *[x for x in QueryABC.__subclasses__() if x not in sub_query_classes], diff --git a/api/src/api_graphql/field/dao_field.py b/api/src/api_graphql/field/dao_field.py index 0d2f876..194238b 100644 --- a/api/src/api_graphql/field/dao_field.py +++ b/api/src/api_graphql/field/dao_field.py @@ -20,6 +20,7 @@ class DaoField(FieldABC): dao: DataAccessObjectABC = None, filter_type: Type[FilterABC] = None, sort_type: Type[T] = None, + direct_result: bool = False, ): FieldABC.__init__(self, name, require_any_permission, require_any, public) self._name = name @@ -28,6 +29,7 @@ class DaoField(FieldABC): self._dao = dao self._filter_type = filter_type self._sort_type = sort_type + self._direct_result = direct_result @property def dao(self) -> Optional[DataAccessObjectABC]: @@ -42,3 +44,7 @@ class DaoField(FieldABC): @property def sort_type(self) -> Optional[Type[T]]: return self._sort_type + + @property + def direct_result(self) -> bool: + return self._direct_result diff --git a/api/src/api_graphql/field/dao_field_builder.py b/api/src/api_graphql/field/dao_field_builder.py index 1b8ba54..5aa0984 100644 --- a/api/src/api_graphql/field/dao_field_builder.py +++ b/api/src/api_graphql/field/dao_field_builder.py @@ -15,6 +15,7 @@ class DaoFieldBuilder(FieldBuilderABC): self._dao = None self._filter_type = None self._sort_type = None + self._direct_result = False def with_dao(self, dao: DataAccessObjectABC) -> Self: assert dao is not None, "dao cannot be None" @@ -31,6 +32,10 @@ class DaoFieldBuilder(FieldBuilderABC): self._sort_type = sort_type return self + def with_direct_result(self) -> Self: + self._direct_result = True + return self + def build(self) -> DaoField: assert self._dao is not None, "dao cannot be None" return DaoField( @@ -41,4 +46,5 @@ class DaoFieldBuilder(FieldBuilderABC): self._dao, self._filter_type, self._sort_type, + self._direct_result, ) diff --git a/api/src/api_graphql/field/mutation_field_builder.py b/api/src/api_graphql/field/mutation_field_builder.py index 57f6652..a27a415 100644 --- a/api/src/api_graphql/field/mutation_field_builder.py +++ b/api/src/api_graphql/field/mutation_field_builder.py @@ -1,7 +1,9 @@ +from asyncio import iscoroutinefunction from typing import Self, Type from ariadne.types import Resolver +from api.broadcast import broadcast from api_graphql.abc.field_builder_abc import FieldBuilderABC from api_graphql.abc.input_abc import InputABC from api_graphql.field.mutation_field import MutationField @@ -18,9 +20,41 @@ class MutationFieldBuilder(FieldBuilderABC): def with_resolver(self, resolver: Resolver) -> Self: assert resolver is not None, "resolver cannot be None" + self._resolver = resolver return self + def with_broadcast(self, source: str): + assert self._resolver is not None, "resolver cannot be None for broadcast" + + resolver = self._resolver + + async def resolver_wrapper(*args, **kwargs): + result = ( + await resolver(*args, **kwargs) + if iscoroutinefunction(resolver) + else resolver(*args, **kwargs) + ) + await broadcast.publish(f"{source}", result) + return result + + def with_change_broadcast(self, source: str): + assert self._resolver is not None, "resolver cannot be None for broadcast" + + resolver = self._resolver + + async def resolver_wrapper(*args, **kwargs): + result = ( + await resolver(*args, **kwargs) + if iscoroutinefunction(resolver) + else resolver(*args, **kwargs) + ) + await broadcast.publish(f"{source}", {}) + return result + + self._resolver = resolver_wrapper + return self + def with_input(self, input_type: Type[InputABC], input_key: str = None) -> Self: self._input_type = input_type self._input_key = input_key diff --git a/api/src/api_graphql/field/resolver_field.py b/api/src/api_graphql/field/resolver_field.py index 0cba9a3..a444d2f 100644 --- a/api/src/api_graphql/field/resolver_field.py +++ b/api/src/api_graphql/field/resolver_field.py @@ -16,11 +16,17 @@ class ResolverField(FieldABC): require_any: TRequireAny = None, public: bool = False, resolver: Resolver = None, + direct_result: bool = False, ): FieldABC.__init__(self, name, require_any_permission, require_any, public) self._resolver = resolver + self._direct_result = direct_result @property def resolver(self) -> Optional[Resolver]: return self._resolver + + @property + def direct_result(self) -> bool: + return self._direct_result diff --git a/api/src/api_graphql/field/resolver_field_builder.py b/api/src/api_graphql/field/resolver_field_builder.py index bcafb5f..03f1bd6 100644 --- a/api/src/api_graphql/field/resolver_field_builder.py +++ b/api/src/api_graphql/field/resolver_field_builder.py @@ -12,12 +12,17 @@ class ResolverFieldBuilder(FieldBuilderABC): FieldBuilderABC.__init__(self, name) self._resolver = None + self._direct_result = False def with_resolver(self, resolver: Resolver) -> Self: assert resolver is not None, "resolver cannot be None" self._resolver = resolver return self + def with_direct_result(self) -> Self: + self._direct_result = True + return self + def build(self) -> ResolverField: assert self._resolver is not None, "resolver cannot be None" return ResolverField( @@ -26,4 +31,5 @@ class ResolverFieldBuilder(FieldBuilderABC): self._require_any, self._public, self._resolver, + self._direct_result, ) diff --git a/api/src/api_graphql/field/subscription_field.py b/api/src/api_graphql/field/subscription_field.py new file mode 100644 index 0000000..721a386 --- /dev/null +++ b/api/src/api_graphql/field/subscription_field.py @@ -0,0 +1,32 @@ +from typing import Optional + +from ariadne.types import Resolver + +from api_graphql.abc.field_abc import FieldABC +from api_graphql.typing import TRequireAny +from service.permission.permissions_enum import Permissions + + +class SubscriptionField(FieldABC): + + def __init__( + self, + name: str, + require_any_permission: list[Permissions] = None, + require_any: TRequireAny = None, + public: bool = False, + resolver: Resolver = None, + generator: Resolver = None, + ): + FieldABC.__init__(self, name, require_any_permission, require_any, public) + + self._resolver = resolver + self._generator = generator + + @property + def resolver(self) -> Optional[Resolver]: + return self._resolver + + @property + def generator(self) -> Optional[Resolver]: + return self._generator diff --git a/api/src/api_graphql/field/subscription_field_builder.py b/api/src/api_graphql/field/subscription_field_builder.py new file mode 100644 index 0000000..6e0432a --- /dev/null +++ b/api/src/api_graphql/field/subscription_field_builder.py @@ -0,0 +1,46 @@ +from typing import Self, AsyncGenerator + +from ariadne.types import Resolver + +from api.broadcast import broadcast +from api_graphql.abc.field_builder_abc import FieldBuilderABC +from api_graphql.field.subscription_field import SubscriptionField + + +class SubscriptionFieldBuilder(FieldBuilderABC): + + def __init__(self, name: str): + FieldBuilderABC.__init__(self, name) + + self._resolver = None + self._generator = None + + def with_resolver(self, resolver: Resolver) -> Self: + assert resolver is not None, "resolver cannot be None" + self._resolver = resolver + return self + + def with_generator(self, generator: Resolver) -> Self: + assert generator is not None, "generator cannot be None" + self._generator = generator + return self + + def build(self) -> SubscriptionField: + assert self._resolver is not None, "resolver cannot be None" + if self._generator is None: + + async def generator(*args, **kwargs) -> AsyncGenerator[str, None]: + async with broadcast.subscribe(channel=self._name) as subscriber: + async for message in subscriber: + yield message + + self._generator = generator + + return SubscriptionField( + self._name, + self._require_any_permission, + self._require_any, + self._public, + self._resolver, + self._generator, + ) diff --git a/api/src/api_graphql/filter/fuzzy_filter.py b/api/src/api_graphql/filter/fuzzy_filter.py new file mode 100644 index 0000000..3d9bc58 --- /dev/null +++ b/api/src/api_graphql/filter/fuzzy_filter.py @@ -0,0 +1,15 @@ +from typing import Optional + +from api_graphql.abc.filter_abc import FilterABC + + +class FuzzyFilter(FilterABC): + def __init__( + self, + obj: Optional[dict], + ): + FilterABC.__init__(self, obj) + + self.add_field("fields", list) + self.add_field("term", str) + self.add_field("threshold", int) diff --git a/api/src/api_graphql/filter/short_url_filter.py b/api/src/api_graphql/filter/short_url_filter.py index 4c59402..812fce6 100644 --- a/api/src/api_graphql/filter/short_url_filter.py +++ b/api/src/api_graphql/filter/short_url_filter.py @@ -9,6 +9,6 @@ class ShortUrlFilter(DbModelFilterABC): ): DbModelFilterABC.__init__(self, obj) - self.add_field("short_url", StringFilter) - self.add_field("target_url", StringFilter) + self.add_field("shortUrl", StringFilter, db_name="short_url") + self.add_field("targetUrl", StringFilter, db_name="target_url") self.add_field("description", StringFilter) diff --git a/api/src/api_graphql/graphql/api_key.gql b/api/src/api_graphql/graphql/api_key.gql index 627f36d..8a6edf2 100644 --- a/api/src/api_graphql/graphql/api_key.gql +++ b/api/src/api_graphql/graphql/api_key.gql @@ -21,11 +21,21 @@ input ApiKeySort { identifier: SortOrder deleted: SortOrder - editorId: SortOrder + editor: UserSort createdUtc: SortOrder updatedUtc: SortOrder } +enum ApiKeyFuzzyFields { + identifier +} + +input ApiKeyFuzzy { + fields: [ApiKeyFuzzyFields] + term: String + threshold: Int +} + input ApiKeyFilter { id: IntFilter identifier: StringFilter diff --git a/api/src/api_graphql/graphql/domain.gql b/api/src/api_graphql/graphql/domain.gql index ab59428..3783c83 100644 --- a/api/src/api_graphql/graphql/domain.gql +++ b/api/src/api_graphql/graphql/domain.gql @@ -26,10 +26,22 @@ input DomainSort { updatedUtc: SortOrder } +enum DomainFuzzyFields { + name +} + +input DomainFuzzy { + fields: [DomainFuzzyFields] + term: String + threshold: Int +} + input DomainFilter { id: IntFilter name: StringFilter + fuzzy: DomainFuzzy + deleted: BooleanFilter editor: IntFilter createdUtc: DateFilter diff --git a/api/src/api_graphql/graphql/feature_flag.gql b/api/src/api_graphql/graphql/feature_flag.gql new file mode 100644 index 0000000..69826b6 --- /dev/null +++ b/api/src/api_graphql/graphql/feature_flag.gql @@ -0,0 +1,19 @@ +type FeatureFlag implements DbModel { + id: ID + key: String + value: Boolean + + deleted: Boolean + editor: User + createdUtc: String + updatedUtc: String +} + +type FeatureFlagMutation { + change(input: FeatureFlagInput!): FeatureFlag +} + +input FeatureFlagInput { + key: String! + value: Boolean! +} \ No newline at end of file diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index aaaf64c..4d62320 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -27,10 +27,22 @@ input GroupSort { updatedUtc: SortOrder } +enum GroupFuzzyFields { + name +} + +input GroupFuzzy { + fields: [GroupFuzzyFields] + term: String + threshold: Int +} + input GroupFilter { id: IntFilter name: StringFilter + fuzzy: GroupFuzzy + deleted: BooleanFilter editor: IntFilter createdUtc: DateFilter diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 5d0e151..2aabbcd 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -14,4 +14,8 @@ type Query { domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult + + settings(key: String): [Setting] + userSettings(key: String): [Setting] + featureFlags(key: String): [FeatureFlag] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/role.gql b/api/src/api_graphql/graphql/role.gql index 67eb691..e69b1e6 100644 --- a/api/src/api_graphql/graphql/role.gql +++ b/api/src/api_graphql/graphql/role.gql @@ -23,18 +23,31 @@ input RoleSort { description: SortOrder deleted: SortOrder - editorId: SortOrder + editor: UserSort createdUtc: SortOrder updatedUtc: SortOrder } +enum RoleFuzzyFields { + name + description +} + +input RoleFuzzy { + fields: [RoleFuzzyFields] + term: String + threshold: Int +} + input RoleFilter { id: IntFilter name: StringFilter description: StringFilter + fuzzy: RoleFuzzy + deleted: BooleanFilter - editorId: IntFilter + editor_id: IntFilter createdUtc: DateFilter updatedUtc: DateFilter } diff --git a/api/src/api_graphql/graphql/setting.gql b/api/src/api_graphql/graphql/setting.gql new file mode 100644 index 0000000..88f9700 --- /dev/null +++ b/api/src/api_graphql/graphql/setting.gql @@ -0,0 +1,19 @@ +type Setting implements DbModel { + id: ID + key: String + value: String + + deleted: Boolean + editor: User + createdUtc: String + updatedUtc: String +} + +type SettingMutation { + change(input: SettingInput!): Setting +} + +input SettingInput { + key: String! + value: String! +} \ No newline at end of file diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index ec5f1b4..a221e97 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -32,12 +32,26 @@ input ShortUrlSort { updatedUtc: SortOrder } +enum ShortUrlFuzzyFields { + shortUrl + targetUrl + description +} + +input ShortUrlFuzzy { + fields: [ShortUrlFuzzyFields] + term: String + threshold: Int +} + input ShortUrlFilter { id: IntFilter name: StringFilter description: StringFilter loadingScreen: BooleanFilter + fuzzy: ShortUrlFuzzy + deleted: BooleanFilter editor: IntFilter createdUtc: DateFilter diff --git a/api/src/api_graphql/graphql/subscription.gql b/api/src/api_graphql/graphql/subscription.gql new file mode 100644 index 0000000..9aa8729 --- /dev/null +++ b/api/src/api_graphql/graphql/subscription.gql @@ -0,0 +1,16 @@ +scalar SubscriptionChange + +type Subscription { + ping: String + + apiKeyChange: SubscriptionChange + featureFlagChange: SubscriptionChange + roleChange: SubscriptionChange + settingChange: SubscriptionChange + userChange: SubscriptionChange + userSettingChange: SubscriptionChange + + domainChange: SubscriptionChange + groupChange: SubscriptionChange + shortUrlChange: SubscriptionChange +} \ No newline at end of file diff --git a/api/src/api_graphql/graphql/user.gql b/api/src/api_graphql/graphql/user.gql index 39edc97..c66b38c 100644 --- a/api/src/api_graphql/graphql/user.gql +++ b/api/src/api_graphql/graphql/user.gql @@ -35,19 +35,33 @@ input UserSort { email: SortOrder deleted: SortOrder - editorId: SortOrder + editor: UserSort createdUtc: SortOrder updatedUtc: SortOrder } +enum UserFuzzyFields { + keycloakId + username + email +} + +input UserFuzzy { + fields: [UserFuzzyFields] + term: String + threshold: Int +} + input UserFilter { id: IntFilter keycloakId: StringFilter username: StringFilter email: StringFilter + fuzzy: UserFuzzy + deleted: BooleanFilter - editor: IntFilter + editor: UserFilter createdUtc: DateFilter updatedUtc: DateFilter } diff --git a/api/src/api_graphql/graphql/user_setting.gql b/api/src/api_graphql/graphql/user_setting.gql new file mode 100644 index 0000000..2eba639 --- /dev/null +++ b/api/src/api_graphql/graphql/user_setting.gql @@ -0,0 +1,19 @@ +type UserSetting implements DbModel { + id: ID + key: String + value: String + + deleted: Boolean + editor: User + createdUtc: String + updatedUtc: String +} + +type UserSettingMutation { + change(input: UserSettingInput!): UserSetting +} + +input UserSettingInput { + key: String! + value: String! +} \ No newline at end of file diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 09236f2..cf16bdd 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -26,6 +26,10 @@ from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url_dao import shortUrlDao +from data.schemas.public.user_setting import UserSetting +from data.schemas.public.user_setting_dao import userSettingsDao +from data.schemas.system.feature_flag_dao import featureFlagDao +from data.schemas.system.setting_dao import settingsDao from service.permission.permissions_enum import Permissions @@ -127,6 +131,23 @@ class Query(QueryABC): .with_require_any([Permissions.short_urls], [group_by_assignment_resolver]) ) + self.field( + ResolverFieldBuilder("settings") + .with_resolver(self._resolve_settings) + .with_direct_result() + .with_public(True) + ) + self.field( + ResolverFieldBuilder("userSettings") + .with_resolver(self._resolve_user_settings) + .with_direct_result() + ) + self.field( + ResolverFieldBuilder("featureFlags") + .with_resolver(self._resolve_feature_flags) + .with_direct_result() + ) + @staticmethod async def _get_user(*_): return await Route.get_user() @@ -157,3 +178,27 @@ class Query(QueryABC): for x in kc_users if x["id"] not in existing_user_keycloak_ids ] + + @staticmethod + async def _resolve_settings(*args, **kwargs): + if "key" in kwargs: + return [await settingsDao.find_by_key(kwargs["key"])] + return await settingsDao.get_all() + + @staticmethod + async def _resolve_user_settings(*args, **kwargs): + user = await Route.get_user() + if user is None: + return None + + if "key" in kwargs: + return await userSettingsDao.find_by( + {UserSetting.user_id: user.id, UserSetting.key: kwargs["key"]} + ) + return await userSettingsDao.find_by({UserSetting.user_id: user.id}) + + @staticmethod + async def _resolve_feature_flags(*args, **kwargs): + if "key" in kwargs: + return [await featureFlagDao.find_by_key(kwargs["key"])] + return await featureFlagDao.get_all() diff --git a/api/src/api_graphql/service/schema.py b/api/src/api_graphql/service/schema.py index 52b9215..bd932f6 100644 --- a/api/src/api_graphql/service/schema.py +++ b/api/src/api_graphql/service/schema.py @@ -5,6 +5,7 @@ from ariadne import make_executable_schema, load_schema_from_path from api_graphql.definition import QUERIES from api_graphql.mutation import Mutation from api_graphql.query import Query +from api_graphql.subscription import Subscription type_defs = load_schema_from_path( os.path.join(os.path.dirname(os.path.realpath(__file__)), "../graphql/") @@ -13,5 +14,6 @@ schema = make_executable_schema( type_defs, Query(), Mutation(), + Subscription(), *QUERIES, ) diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py new file mode 100644 index 0000000..e17bbfd --- /dev/null +++ b/api/src/api_graphql/subscription.py @@ -0,0 +1,66 @@ +from api_graphql.abc.subscription_abc import SubscriptionABC +from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder +from service.permission.permissions_enum import Permissions + + +class Subscription(SubscriptionABC): + def __init__(self): + SubscriptionABC.__init__(self) + + self.subscribe( + SubscriptionFieldBuilder("ping") + .with_resolver(lambda message, *_: message.message) + .with_public(True) + ) + + self.subscribe( + SubscriptionFieldBuilder("apiKeyChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.api_keys]) + ) + + self.subscribe( + SubscriptionFieldBuilder("featureFlagChange") + .with_resolver(lambda message, *_: message.message) + .with_public(True) + ) + + self.subscribe( + SubscriptionFieldBuilder("roleChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.roles]) + ) + + self.subscribe( + SubscriptionFieldBuilder("settingChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.settings]) + ) + + self.subscribe( + SubscriptionFieldBuilder("userChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.users]) + ) + + self.subscribe( + SubscriptionFieldBuilder("userSettingChange") + .with_resolver(lambda message, *_: message.message) + .with_public(True) + ) + + self.subscribe( + SubscriptionFieldBuilder("domainChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.domains]) + ) + self.subscribe( + SubscriptionFieldBuilder("groupChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.groups]) + ) + self.subscribe( + SubscriptionFieldBuilder("shortUrlChange") + .with_resolver(lambda message, *_: message.message) + .with_require_any_permission([Permissions.short_urls]) + ) diff --git a/api/src/core/configuration/__init__.py b/api/src/core/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/core/configuration/feature_flags.py b/api/src/core/configuration/feature_flags.py new file mode 100644 index 0000000..46b83d0 --- /dev/null +++ b/api/src/core/configuration/feature_flags.py @@ -0,0 +1,20 @@ +from core.configuration.feature_flags_enum import FeatureFlagsEnum +from data.schemas.system.feature_flag_dao import featureFlagDao + + +class FeatureFlags: + _flags = { + FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025 + } + + @staticmethod + def get_default(key: FeatureFlagsEnum) -> bool: + return FeatureFlags._flags[key.value] + + @staticmethod + async def has_feature(key: FeatureFlagsEnum) -> bool: + value = await featureFlagDao.find_by_key(key.value) + if value is None: + return False + + return value.value diff --git a/api/src/core/configuration/feature_flags_enum.py b/api/src/core/configuration/feature_flags_enum.py new file mode 100644 index 0000000..c5f48c7 --- /dev/null +++ b/api/src/core/configuration/feature_flags_enum.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class FeatureFlagsEnum(Enum): + # modules + version_endpoint = "VersionEndpoint" diff --git a/api/src/core/const.py b/api/src/core/const.py new file mode 100644 index 0000000..355c43a --- /dev/null +++ b/api/src/core/const.py @@ -0,0 +1 @@ +DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f %z" diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py index 9325c5d..0a1f4dd 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -4,9 +4,12 @@ from enum import Enum from types import NoneType from typing import Generic, Optional, Union, TypeVar, Any, Type +from core.const import DATETIME_FORMAT from core.database.abc.db_model_abc import DbModelABC from core.database.database import Database +from core.get_value import get_value from core.logger import DBLogger +from core.string import camel_to_snake from core.typing import T, Attribute, AttributeFilters, AttributeSorts T_DBM = TypeVar("T_DBM", bound=DbModelABC) @@ -23,7 +26,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self._default_filter_condition = None self.__attributes: dict[str, type] = {} + self.__joins: dict[str, str] = {} + self.__db_names: dict[str, str] = {} + self.__foreign_tables: dict[str, str] = {} + self.__date_attributes: set[str] = set() self.__ignored_attributes: set[str] = set() @@ -69,6 +76,40 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if attr_type in [datetime, datetime.datetime]: self.__date_attributes.add(db_name) + def reference( + self, + attr: Attribute, + primary_attr: Attribute, + foreign_attr: Attribute, + table_name: str, + ): + """ + Add a reference to another table for the given attribute + :param str primary_attr: Name of the primary key in the foreign object + :param str foreign_attr: Name of the foreign key in the object + :param str table_name: Name of the table to reference + :return: + """ + if table_name == self._table_name: + return + if isinstance(attr, property): + attr = attr.fget.__name__ + + if isinstance(primary_attr, property): + primary_attr = primary_attr.fget.__name__ + + primary_attr = primary_attr.lower().replace("_", "") + + if isinstance(foreign_attr, property): + foreign_attr = foreign_attr.fget.__name__ + + foreign_attr = foreign_attr.lower().replace("_", "") + + self.__joins[foreign_attr] = ( + f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}" + ) + self.__foreign_tables[attr] = table_name + def to_object(self, result: dict) -> T_DBM: """ Convert a result from the database to an object @@ -89,8 +130,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return self._model_type(**value_map) - async def count(self) -> int: - result = await self._db.select_map(f"SELECT COUNT(*) FROM {self._table_name}") + async def count(self, filters: AttributeFilters = None) -> int: + query = f"SELECT COUNT(*) FROM {self._table_name}" + + if filters is not None and (not isinstance(filters, list) or len(filters) > 0): + query += f" WHERE {self._build_conditions(filters)}" + + result = await self._db.select_map(query) return result[0]["count"] async def get_all(self) -> list[T_DBM]: @@ -384,6 +430,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return "ARRAY[]::text[]" return f"ARRAY[{", ".join([DataAccessObjectABC._get_value_sql(x) for x in value])}]" + if isinstance(value, datetime.datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + + return f"'{value.strftime(DATETIME_FORMAT)}'" + return str(value) @staticmethod @@ -412,7 +464,10 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): take: int = None, skip: int = None, ) -> str: - query = f"SELECT * FROM {self._table_name}" + query = f"SELECT {self._table_name}.* FROM {self._table_name}" + + for join in self.__joins: + query += f" {self.__joins[join]}" if filters is not None and (not isinstance(filters, list) or len(filters) > 0): query += f" WHERE {self._build_conditions(filters)}" @@ -438,12 +493,37 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): for attr, values in f.items(): if isinstance(attr, property): attr = attr.fget.__name__ + + if attr in self.__foreign_tables: + foreign_table = self.__foreign_tables[attr] + conditions.extend( + self._build_foreign_conditions(foreign_table, values) + ) + continue + + if attr == "fuzzy": + conditions.append( + " OR ".join( + self._build_fuzzy_conditions( + [ + self.__db_names[x] if x in self.__db_names else self.__db_names[camel_to_snake(x)] + for x in get_value(values, "fields", list[str]) + ], + get_value(values, "term", str), + get_value(values, "threshold", int, 5), + ) + ) + ) + continue + db_name = self.__db_names[attr] if isinstance(values, dict): for operator, value in values.items(): conditions.append( - self._build_condition(db_name, operator, value) + self._build_condition( + f"{self._table_name}.{db_name}", operator, value + ) ) elif isinstance(values, list): sub_conditions = [] @@ -451,7 +531,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if isinstance(value, dict): for operator, val in value.items(): sub_conditions.append( - self._build_condition(db_name, operator, val) + self._build_condition( + f"{self._table_name}.{db_name}", operator, val + ) ) else: sub_conditions.append( @@ -463,12 +545,65 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return " AND ".join(conditions) + def _build_fuzzy_conditions( + self, fields: list[str], term: str, threshold: int = 10 + ) -> list[str]: + conditions = [] + for field in fields: + conditions.append( + f"levenshtein({field}, '{term}') <= {threshold}" + ) # Adjust the threshold as needed + + return conditions + + def _build_foreign_conditions(self, table: str, values: dict) -> list[str]: + """ + Build SQL conditions for foreign key references + :param table: Foreign table name + :param values: Filter values + :return: List of conditions + """ + conditions = [] + for attr, sub_values in values.items(): + if isinstance(attr, property): + attr = attr.fget.__name__ + + if attr in self.__foreign_tables: + foreign_table = self.__foreign_tables[attr] + conditions.extend( + self._build_foreign_conditions(foreign_table, sub_values) + ) + continue + + db_name = f"{table}.{attr.lower().replace('_', '')}" + + if isinstance(sub_values, dict): + for operator, value in sub_values.items(): + conditions.append(self._build_condition(db_name, operator, value)) + elif isinstance(sub_values, list): + sub_conditions = [] + for value in sub_values: + if isinstance(value, dict): + for operator, val in value.items(): + sub_conditions.append( + self._build_condition(db_name, operator, val) + ) + else: + sub_conditions.append( + self._get_value_validation_sql(db_name, value) + ) + conditions.append(f"({' OR '.join(sub_conditions)})") + else: + conditions.append(self._get_value_validation_sql(db_name, sub_values)) + + return conditions + def _get_value_validation_sql(self, field: str, value: Any): value = self._get_value_sql(value) if value == "NULL": - return f"{field} IS NULL" - return f"{field} = {value}" + return f"{self._table_name}.{field} IS NULL" + return f"{self._table_name}.{field} = {value}" def _build_condition(self, db_name: str, operator: str, value: Any) -> str: """ @@ -530,6 +665,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if isinstance(attr, property): attr = attr.fget.__name__ + if attr in self.__foreign_tables: + foreign_table = self.__foreign_tables[attr] + sort_clauses.extend( + self._build_foreign_order_by(foreign_table, direction) + ) + continue + match attr: case "createdUtc": attr = "created" @@ -547,6 +689,30 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return ", ".join(sort_clauses) + def _build_foreign_order_by(self, table: str, direction: str) -> list[str]: + """ + Build SQL order by clause for foreign key references + :param table: Foreign table name + :param direction: Sort direction + :return: List of order by clauses + """ + sort_clauses = [] + for attr, sub_direction in direction.items(): + if isinstance(attr, property): + attr = attr.fget.__name__ + + if attr in self.__foreign_tables: + foreign_table = self.__foreign_tables[attr] + sort_clauses.extend( + self._build_foreign_order_by(foreign_table, sub_direction) + ) + continue + + db_name = f"{table}.{attr.lower().replace('_', '')}" + sort_clauses.append(f"{db_name} {sub_direction.upper()}") + + return sort_clauses + @staticmethod async def _get_editor_id(obj: T_DBM): editor_id = obj.editor_id diff --git a/api/src/core/logger.py b/api/src/core/logger.py index 6d83ad7..1f4a308 100644 --- a/api/src/core/logger.py +++ b/api/src/core/logger.py @@ -1,8 +1,13 @@ +import asyncio import os import traceback from datetime import datetime +from api.middleware.request import get_request +from core.environment import Environment + + class Logger: _level = "info" _levels = ["trace", "debug", "info", "warning", "error", "fatal"] @@ -54,6 +59,30 @@ class Logger: else: raise ValueError(f"Invalid log level: {level}") + def _get_structured_message(self, level: str, timestamp: str, messages: str) -> str: + structured_message = { + "timestamp": timestamp, + "level": level.upper(), + "source": self.source, + "messages": messages, + } + + request = get_request() + + if request is not None: + structured_message["request"] = { + "url": str(request.url), + "method": request.method, + "data": asyncio.create_task(request.body()), + } + return str(structured_message) + + def _write_log_to_file(self, content: str): + self._ensure_file_size() + with open(self.log_file, "a") as log_file: + log_file.write(content + "\n") + log_file.close() + def _log(self, level: str, *messages): try: if self._levels.index(level) < self._levels.index(self._level): @@ -63,17 +92,18 @@ class Logger: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") formatted_message = f"<{timestamp}> [{level.upper():^7}] [{self._file_prefix:^5}] - [{self.source}]: {' '.join(messages)}" - self._ensure_file_size() - with open(self.log_file, "a") as log_file: - log_file.write(formatted_message + "\n") - log_file.close() + if Environment.get("STRUCTURED_LOGGING", bool, False): + self._write_log_to_file( + self._get_structured_message(level, timestamp, " ".join(messages)) + ) + else: + self._write_log_to_file(formatted_message) - color = self.COLORS.get(level, self.COLORS["reset"]) - reset_color = self.COLORS["reset"] - - print(f"{color}{formatted_message}{reset_color}") + print( + f"{self.COLORS.get(level, self.COLORS["reset"])}{formatted_message}{self.COLORS["reset"]}" + ) except Exception as e: - print(f"Error while logging: {e}") + print(f"Error while logging: {e} -> {traceback.format_exc()}") def trace(self, *messages): self._log("trace", *messages) diff --git a/api/src/core/string.py b/api/src/core/string.py new file mode 100644 index 0000000..6adbe99 --- /dev/null +++ b/api/src/core/string.py @@ -0,0 +1,8 @@ +import re + + +def first_to_lower(s: str) -> str: + return s[0].lower() + s[1:] if s else s + +def camel_to_snake(s: str) -> str: + return re.sub(r'(? SerialId: + return self._user_id + + @async_property + async def user(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._user_id) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, value: Union[str, int, float, bool]): + self._value = str(value) diff --git a/api/src/data/schemas/public/user_setting_dao.py b/api/src/data/schemas/public/user_setting_dao.py new file mode 100644 index 0000000..78f3411 --- /dev/null +++ b/api/src/data/schemas/public/user_setting_dao.py @@ -0,0 +1,24 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.administration.user import User +from data.schemas.public.user_setting import UserSetting + +logger = DBLogger(__name__) + + +class UserSettingDao(DbModelDaoABC[UserSetting]): + + def __init__(self): + DbModelDaoABC.__init__(self, __name__, UserSetting, "public.user_settings") + + self.attribute(UserSetting.user_id, int) + self.attribute(UserSetting.key, str) + self.attribute(UserSetting.value, str) + + async def find_by_key(self, user: User, key: str) -> UserSetting: + return await self.find_single_by( + [{UserSetting.user_id: user.id}, {UserSetting.key: key}] + ) + + +userSettingsDao = UserSettingDao() diff --git a/api/src/data/schemas/system/feature_flag.py b/api/src/data/schemas/system/feature_flag.py new file mode 100644 index 0000000..c8950f4 --- /dev/null +++ b/api/src/data/schemas/system/feature_flag.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class FeatureFlag(DbModelABC): + def __init__( + self, + id: SerialId, + key: str, + value: bool, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + + self._key = key + self._value = value + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool): + self._value = value diff --git a/api/src/data/schemas/system/feature_flag_dao.py b/api/src/data/schemas/system/feature_flag_dao.py new file mode 100644 index 0000000..c140a03 --- /dev/null +++ b/api/src/data/schemas/system/feature_flag_dao.py @@ -0,0 +1,20 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.system.feature_flag import FeatureFlag + +logger = DBLogger(__name__) + + +class FeatureFlagDao(DbModelDaoABC[FeatureFlag]): + + def __init__(self): + DbModelDaoABC.__init__(self, __name__, FeatureFlag, "system.feature_flags") + + self.attribute(FeatureFlag.key, str) + self.attribute(FeatureFlag.value, bool) + + async def find_by_key(self, key: str) -> FeatureFlag: + return await self.find_single_by({FeatureFlag.key: key}) + + +featureFlagDao = FeatureFlagDao() diff --git a/api/src/data/schemas/system/setting.py b/api/src/data/schemas/system/setting.py new file mode 100644 index 0000000..60a4f95 --- /dev/null +++ b/api/src/data/schemas/system/setting.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional, Union + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class Setting(DbModelABC): + def __init__( + self, + id: SerialId, + key: str, + value: str, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + + self._key = key + self._value = value + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, value: Union[str, int, float, bool]): + self._value = str(value) diff --git a/api/src/data/schemas/system/setting_dao.py b/api/src/data/schemas/system/setting_dao.py new file mode 100644 index 0000000..d7262eb --- /dev/null +++ b/api/src/data/schemas/system/setting_dao.py @@ -0,0 +1,20 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.system.setting import Setting + +logger = DBLogger(__name__) + + +class SettingDao(DbModelDaoABC[Setting]): + + def __init__(self): + DbModelDaoABC.__init__(self, __name__, Setting, "system.settings") + + self.attribute(Setting.key, str) + self.attribute(Setting.value, str) + + async def find_by_key(self, key: str) -> Setting: + return await self.find_single_by({Setting.key: key}) + + +settingsDao = SettingDao() diff --git a/api/src/data/scripts/2025-03-08-08-10-settings.sql b/api/src/data/scripts/2025-03-08-08-10-settings.sql new file mode 100644 index 0000000..89aaad2 --- /dev/null +++ b/api/src/data/scripts/2025-03-08-08-10-settings.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA IF NOT EXISTS system; + +CREATE TABLE IF NOT EXISTS system.settings +( + Id SERIAL PRIMARY KEY, + Key TEXT NOT NULL, + Value TEXT NOT NULL, + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + CreatedUtc timestamptz NOT NULL DEFAULT NOW(), + UpdatedUtc timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE system.settings_history +( + LIKE system.settings +); + +CREATE TRIGGER ip_list_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON system.settings + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); \ No newline at end of file diff --git a/api/src/data/scripts/2025-03-08-08-15-feature-flags.sql b/api/src/data/scripts/2025-03-08-08-15-feature-flags.sql new file mode 100644 index 0000000..5a43161 --- /dev/null +++ b/api/src/data/scripts/2025-03-08-08-15-feature-flags.sql @@ -0,0 +1,24 @@ +CREATE SCHEMA IF NOT EXISTS system; + +CREATE TABLE IF NOT EXISTS system.feature_flags +( + Id SERIAL PRIMARY KEY, + Key TEXT NOT NULL, + Value BOOLEAN NOT NULL, + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + CreatedUtc timestamptz NOT NULL DEFAULT NOW(), + UpdatedUtc timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE system.feature_flags_history +( + LIKE system.feature_flags +); + +CREATE TRIGGER ip_list_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON system.feature_flags + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); \ No newline at end of file diff --git a/api/src/data/scripts/2025-03-08-08-15-user-settings.sql b/api/src/data/scripts/2025-03-08-08-15-user-settings.sql new file mode 100644 index 0000000..7a95dec --- /dev/null +++ b/api/src/data/scripts/2025-03-08-08-15-user-settings.sql @@ -0,0 +1,25 @@ +CREATE SCHEMA IF NOT EXISTS public; + +CREATE TABLE IF NOT EXISTS public.user_settings +( + Id SERIAL PRIMARY KEY, + Key TEXT NOT NULL, + Value TEXT NOT NULL, + UserId INT NOT NULL REFERENCES public.user_settings (Id) ON DELETE CASCADE, + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + CreatedUtc timestamptz NOT NULL DEFAULT NOW(), + UpdatedUtc timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE public.user_settings_history +( + LIKE public.user_settings +); + +CREATE TRIGGER ip_list_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_settings + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); \ No newline at end of file diff --git a/api/src/data/seeder/feature_flags_seeder.py b/api/src/data/seeder/feature_flags_seeder.py new file mode 100644 index 0000000..185312c --- /dev/null +++ b/api/src/data/seeder/feature_flags_seeder.py @@ -0,0 +1,40 @@ +from core.configuration.feature_flags import FeatureFlags +from core.configuration.feature_flags_enum import FeatureFlagsEnum +from core.logger import DBLogger +from data.abc.data_seeder_abc import DataSeederABC +from data.schemas.system.feature_flag import FeatureFlag +from data.schemas.system.feature_flag_dao import featureFlagDao + +logger = DBLogger(__name__) + + +class FeatureFlagsSeeder(DataSeederABC): + def __init__(self): + DataSeederABC.__init__(self) + + async def seed(self): + logger.info("Seeding feature flags") + feature_flags = await featureFlagDao.get_all() + feature_flag_keys = [x.key for x in feature_flags] + + possible_feature_flags = { + x.value: FeatureFlags.get_default(x) for x in FeatureFlagsEnum + } + + to_create = [ + FeatureFlag(0, x, possible_feature_flags[x]) + for x in possible_feature_flags.keys() + if x not in feature_flag_keys + ] + if len(to_create) > 0: + await featureFlagDao.create_many(to_create) + to_create_dicts = {x.key: x.value for x in to_create} + logger.debug(f"Created feature flags: {to_create_dicts}") + + to_delete = [ + x for x in feature_flags if x.key not in possible_feature_flags.keys() + ] + if len(to_delete) > 0: + await featureFlagDao.delete_many(to_delete, hard_delete=True) + to_delete_dicts = {x.key: x.value for x in to_delete} + logger.debug(f"Deleted feature flags: {to_delete_dicts}") diff --git a/api/src/data/seeder/settings_seeder.py b/api/src/data/seeder/settings_seeder.py new file mode 100644 index 0000000..dcdff29 --- /dev/null +++ b/api/src/data/seeder/settings_seeder.py @@ -0,0 +1,25 @@ +from typing import Any + +from core.logger import DBLogger +from data.abc.data_seeder_abc import DataSeederABC +from data.schemas.system.setting import Setting +from data.schemas.system.setting_dao import settingsDao + +logger = DBLogger(__name__) + + +class SettingsSeeder(DataSeederABC): + def __init__(self): + DataSeederABC.__init__(self) + + async def seed(self): + await self._seed_if_not_exists("default_language", "de") + await self._seed_if_not_exists("show_terms", True) + + @staticmethod + async def _seed_if_not_exists(key: str, value: Any): + existing = await settingsDao.find_by_key(key) + if existing is not None: + return + + await settingsDao.create(Setting(0, key, str(value))) diff --git a/api/src/main.py b/api/src/main.py index bdb206a..575d99b 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -1,10 +1,9 @@ import asyncio import sys -import eventlet -from eventlet import wsgi +import uvicorn -from api.api import app +from api.api import API from core.environment import Environment from core.logger import Logger from startup import Startup @@ -18,15 +17,13 @@ def main(): asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) - loop = asyncio.new_event_loop() - loop.run_until_complete(Startup.configure()) - loop.close() - - port = Environment.get("PORT", int, 5000) - logger.info(f"Start API on port: {port}") - if Environment.get_environment() == "development": - logger.info(f"Playground: http://localhost:{port}/ui/playground") - wsgi.server(eventlet.listen(("0.0.0.0", port)), app, log_output=False) + Startup.configure() + uvicorn.run( + API.app, + host="0.0.0.0", + port=Environment.get("PORT", int, 5000), + log_config=None, + ) if __name__ == "__main__": diff --git a/api/src/service/permission/permissions_enum.py b/api/src/service/permission/permissions_enum.py index 2ccdaa2..267a9bc 100644 --- a/api/src/service/permission/permissions_enum.py +++ b/api/src/service/permission/permissions_enum.py @@ -7,6 +7,9 @@ class Permissions(Enum): """ Administration """ + # administrator + administrator = "administrator" + # api keys api_keys = "api_keys" api_keys_create = "api_keys.create" @@ -19,6 +22,10 @@ class Permissions(Enum): users_update = "users.update" users_delete = "users.delete" + # settings + settings = "settings" + settings_update = "settings.update" + """ Permissions """ diff --git a/api/src/startup.py b/api/src/startup.py index 582f661..9829dc6 100644 --- a/api/src/startup.py +++ b/api/src/startup.py @@ -1,17 +1,30 @@ -from flask_cors import CORS +from contextlib import asynccontextmanager -from api.api import app +from ariadne.asgi import GraphQL +from ariadne.asgi.handlers import GraphQLTransportWSHandler +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import WebSocketRoute + +from api.api import API from api.auth.keycloak_client import Keycloak +from api.broadcast import broadcast +from api.middleware.logging import LoggingMiddleware +from api.middleware.request import RequestMiddleware +from api.route import Route +from api_graphql.service.schema import schema from core.database.database import Database from core.database.database_settings import DatabaseSettings from core.database.db_context import DBContext from core.environment import Environment from core.logger import Logger from data.seeder.api_key_seeder import ApiKeySeeder +from data.seeder.feature_flags_seeder import FeatureFlagsSeeder from data.seeder.file_hash_seeder import FileHashSeeder from data.seeder.permission_seeder import PermissionSeeder from data.seeder.role_seeder import RoleSeeder -from data.seeder.short_url_seeder import ShortUrlSeeder +from data.seeder.settings_seeder import SettingsSeeder from data.service.migration_service import MigrationService from service.file_service import FileService @@ -19,15 +32,43 @@ logger = Logger(__name__) class Startup: + @classmethod + def _get_db_settings(cls): + host = Environment.get("DB_HOST", str) + port = Environment.get("DB_PORT", int) + user = Environment.get("DB_USER", str) + password = Environment.get("DB_PASSWORD", str) + database = Environment.get("DB_DATABASE", str) + + if None in [host, port, user, password, database]: + logger.fatal( + "DB settings are not set correctly", + EnvironmentError("DB settings are not set correctly"), + ) + + return DatabaseSettings( + host=host, port=port, user=user, password=password, database=database + ) + + @classmethod + async def _startup_db(cls): + logger.info("Init DB") + db = DBContext() + + await db.connect(cls._get_db_settings()) + Database.init(db) + migrations = MigrationService(db) + await migrations.migrate() @staticmethod async def _seed_data(): seeders = [ + SettingsSeeder, + FeatureFlagsSeeder, PermissionSeeder, RoleSeeder, ApiKeySeeder, FileHashSeeder, - ShortUrlSeeder, ] for seeder in [x() for x in seeders]: await seeder.seed() @@ -38,22 +79,67 @@ class Startup: Keycloak.init() @classmethod - async def configure(cls): - Logger.set_level(Environment.get("LOG_LEVEL", str, "info")) - Environment.set_environment(Environment.get("ENVIRONMENT", str, "production")) - logger.info(f"Environment: {Environment.get_environment()}") + async def _startup_broadcast(cls): + logger.info("Init Broadcast") + await broadcast.connect() - app.debug = Environment.get_environment() == "development" - - await Database.startup_db() + @classmethod + async def configure_api(cls): + await cls._startup_db() await FileService.clean_files() await cls._seed_data() cls._startup_keycloak() + await cls._startup_broadcast() - client_urls = Environment.get("CLIENT_URLS", str) - if client_urls is None: - raise EnvironmentError("CLIENT_URLS not set") + @staticmethod + @asynccontextmanager + async def api_lifespan(app: Starlette): + await Startup.configure_api() - origins = client_urls.split(",") - CORS(app, support_credentials=True, resources={r"/api/*": {"origins": origins}}) + port = Environment.get("PORT", int, 5000) + logger.info(f"Start API server on port: {port}") + if Environment.get_environment() == "development": + logger.info(f"Playground: http://localhost:{port}/ui/playground") + + app.debug = Environment.get_environment() == "development" + yield + logger.info("Shutdown API") + + @classmethod + def init_api(cls): + logger.info("Init API") + API.import_routes() + API.create( + Starlette( + lifespan=cls.api_lifespan, + routes=[ + *Route.registered_routes, + WebSocketRoute( + "/graphql", + endpoint=GraphQL( + schema, websocket_handler=GraphQLTransportWSHandler() + ), + ), + ], + middleware=[ + Middleware(RequestMiddleware), + Middleware(LoggingMiddleware), + Middleware( + CORSMiddleware, + allow_origins=API.get_allowed_origins(), + allow_methods=["*"], + allow_headers=["*"], + ), + ], + exception_handlers={Exception: API.handle_exception}, + ) + ) + + @classmethod + def configure(cls): + Logger.set_level(Environment.get("LOG_LEVEL", str, "info")) + Environment.set_environment(Environment.get("ENVIRONMENT", str, "production")) + logger.info(f"Environment: {Environment.get_environment()}") + + cls.init_api() diff --git a/web/ngx-translate-lint.json b/web/ngx-translate-lint.json new file mode 100755 index 0000000..a8d094c --- /dev/null +++ b/web/ngx-translate-lint.json @@ -0,0 +1,26 @@ +{ + "rules": { + "keysOnViews": "error", + "zombieKeys": "error", + "misprintKeys": "disable", + "deepSearch": "enable", + "emptyKeys": "warning", + "maxWarning": "0", + "misprintCoefficient": "0.9", + "ignoredKeys": [ + "permissions.*", + "permission_descriptions.*" + ], + "ignoredMisprintKeys": [], + "customRegExpToFindKeys": [ + "(?<=countHeaderTranslation=\")[A-Za-z0-9_.-]+(?=\")", + "(?<=translationKey:\\s*['\"])[A-Za-z0-9_.-]+(?=['\"])", + "(?<=(success|info|warn|error)\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))", + "(?<=instant\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))", + "(?<=\\.instant\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))|(?<=\\?\\s*['\"])[A-Za-z0-9_.-]+(?=['\"]\\s*:\\s*['\"].*?\\|\\s*translate)|(?<=:\\s*['\"])[A-Za-z0-9_.-]+(?=['\"]\\s*\\|\\s*translate)\n" + ] + }, + "fixZombiesKeys": false, + "project": "./src/app/**/*.{html,ts}", + "languages": "./src/assets/i18n/*.json" +} diff --git a/web/package-lock.json b/web/package-lock.json index 89bace4..ea7f3ab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "apollo-angular": "^7.2.1", "date-fns": "^4.1.0", "dompurify": "^3.2.1", + "graphql-ws": "^5.16.2", "keycloak-angular": "^16.1.0", "keycloak-js": "^26.0.5", "marked": "^12.0.2", @@ -57,6 +58,7 @@ "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "ngx-translate-lint": "^1.22.0", "postcss": "^8.4.49", "prettier": "^3.3.3", "prettier-eslint": "^16.3.0", @@ -7377,6 +7379,13 @@ "node": ">= 0.6" } }, + "node_modules/conventional-cli": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/conventional-cli/-/conventional-cli-1.2.0.tgz", + "integrity": "sha512-4EGXbt16iIOjTz7ocOInsHfjxL6NxdUNqnHv4XHxXfRc8ClZJcQB5SoxQhT7U2XmZ9y2O/PFFkT8hLwG3n+DJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -9711,6 +9720,13 @@ "node": ">= 0.6" } }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "dev": true, + "license": "ISC" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -9996,6 +10012,18 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/graphql-ws": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.2.tgz", + "integrity": "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -12835,6 +12863,141 @@ "zone.js": "~0.14.0" } }, + "node_modules/ngx-translate-lint": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/ngx-translate-lint/-/ngx-translate-lint-1.22.0.tgz", + "integrity": "sha512-7ECu8xs5OTWvJ6/9JC6CVhxooqRopGm6LO4BW9VhPQNFQJKuE13bipBxtW3jGz9ecyTVJuQ3hIVDG/8uSAkyig==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "commander": "^2.20.0", + "conventional-cli": "^1.2.0", + "dir-glob": "^3.0.1", + "fs": "0.0.1-security", + "glob": "^7.1.4", + "lodash": "^4.17.20", + "path": "^0.12.7", + "rxjs": "^6.5.4", + "string-similarity": "^4.0.1", + "typescript": "^4.1.2" + }, + "bin": { + "ngx-translate-lint": "dist/bin.js" + } + }, + "node_modules/ngx-translate-lint/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ngx-translate-lint/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ngx-translate-lint/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ngx-translate-lint/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ngx-translate-lint/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ngx-translate-lint/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ngx-translate-lint/node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/ngx-translate-lint/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ngx-translate-lint/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/ngx-translate-lint/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -13634,6 +13797,17 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -14467,6 +14641,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -16087,6 +16271,14 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "ISC" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -17019,6 +17211,16 @@ "node": ">=6" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17026,6 +17228,13 @@ "dev": true, "license": "MIT" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/web/package.json b/web/package.json index 1966a33..2247ad4 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "test": "ng test", "test:ci": "ng test --browsers=ChromeHeadlessCustom --watch=false --code-coverage", "lint": "ng lint", + "lint:fix": "ng lint --fix", + "lint:translations": "ngx-translate-lint -c ngx-translate-lint.json", "prettiefy": "prettier --write \"src/**/*.ts\"" }, "private": true, @@ -26,6 +28,7 @@ "apollo-angular": "^7.2.1", "date-fns": "^4.1.0", "dompurify": "^3.2.1", + "graphql-ws": "^5.16.2", "keycloak-angular": "^16.1.0", "keycloak-js": "^26.0.5", "marked": "^12.0.2", @@ -62,6 +65,7 @@ "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "ngx-translate-lint": "^1.22.0", "postcss": "^8.4.49", "prettier": "^3.3.3", "prettier-eslint": "^16.3.0", diff --git a/web/src/app/app-routing.module.ts b/web/src/app/app-routing.module.ts index 5496efc..f4fdce7 100644 --- a/web/src/app/app-routing.module.ts +++ b/web/src/app/app-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component'; import { AuthGuard } from 'src/app/core/guard/auth.guard'; import { HomeComponent } from 'src/app/components/home/home.component'; +import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component'; const routes: Routes = [ { @@ -15,8 +16,12 @@ const routes: Routes = [ import('./modules/admin/admin.module').then(m => m.AdminModule), canActivate: [AuthGuard], }, - { path: '404', component: NotFoundComponent }, - { path: '**', redirectTo: '/404', pathMatch: 'full' }, + { path: 'error/404', component: NotFoundComponent }, + { path: 'error/unavailable', component: ServerUnavailableComponent }, + { + path: '**', + redirectTo: 'error/404', + }, ]; @NgModule({ diff --git a/web/src/app/app.module.ts b/web/src/app/app.module.ts index 4bba424..b84161c 100644 --- a/web/src/app/app.module.ts +++ b/web/src/app/app.module.ts @@ -4,6 +4,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { KeycloakService } from 'keycloak-angular'; +import { HomeComponent } from './components/home/home.component'; import { initializeKeycloak } from './core/init-keycloak'; import { HttpClient } from '@angular/common/http'; import { environment } from '../environments/environment'; @@ -20,8 +21,8 @@ import { DialogService } from 'primeng/dynamicdialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { SidebarComponent } from './components/sidebar/sidebar.component'; import { ErrorHandlingService } from 'src/app/service/error-handling.service'; -import { HomeComponent } from './components/home/home.component'; -import { SettingsService } from 'src/app/service/settings.service'; +import { ConfigService } from 'src/app/service/config.service'; +import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component'; if (environment.production) { Logger.enableProductionMode(); @@ -35,13 +36,13 @@ export function HttpLoaderFactory(http: HttpClient) { export function appInitializerFactory( keycloak: KeycloakService, - settings: SettingsService + config: ConfigService ): () => Promise { return (): Promise => new Promise((resolve, reject) => { - settings + config .loadSettings() - .then(() => initializeKeycloak(keycloak, settings)) + .then(() => initializeKeycloak(keycloak, config)) .then(() => resolve()) .catch(error => reject(error)); }); @@ -50,12 +51,13 @@ export function appInitializerFactory( @NgModule({ declarations: [ AppComponent, + HomeComponent, FooterComponent, HeaderComponent, NotFoundComponent, + ServerUnavailableComponent, SpinnerComponent, SidebarComponent, - HomeComponent, ], imports: [ BrowserModule, @@ -86,7 +88,7 @@ export function appInitializerFactory( provide: APP_INITIALIZER, useFactory: appInitializerFactory, multi: true, - deps: [KeycloakService, SettingsService], + deps: [KeycloakService, ConfigService], }, { provide: ErrorHandler, diff --git a/web/src/app/components/error/not-found/not-found.component.html b/web/src/app/components/error/not-found/not-found.component.html index 543db78..d102f22 100644 --- a/web/src/app/components/error/not-found/not-found.component.html +++ b/web/src/app/components/error/not-found/not-found.component.html @@ -1,8 +1,10 @@
-
-

- {{ 'error.404' | translate }} -

- +
+
+

+ {{ 'error.404' | translate }} +

+ +
diff --git a/web/src/app/components/error/not-found/not-found.component.spec.ts b/web/src/app/components/error/not-found/not-found.component.spec.ts index 4ebd2e8..73c2e28 100644 --- a/web/src/app/components/error/not-found/not-found.component.spec.ts +++ b/web/src/app/components/error/not-found/not-found.component.spec.ts @@ -1,9 +1,9 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NotFoundComponent } from "src/app/components/error/not-found/not-found.component"; -import { TranslateModule } from "@ngx-translate/core"; +import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component'; +import { TranslateModule } from '@ngx-translate/core'; -describe("NotFoundComponent", () => { +describe('NotFoundComponent', () => { let component: NotFoundComponent; let fixture: ComponentFixture; @@ -20,7 +20,7 @@ describe("NotFoundComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/components/error/not-found/not-found.component.ts b/web/src/app/components/error/not-found/not-found.component.ts index aaf0dfd..d80e028 100644 --- a/web/src/app/components/error/not-found/not-found.component.ts +++ b/web/src/app/components/error/not-found/not-found.component.ts @@ -1,8 +1,8 @@ -import { Component } from "@angular/core"; +import { Component } from '@angular/core'; @Component({ - selector: "app-not-found", - templateUrl: "./not-found.component.html", - styleUrls: ["./not-found.component.scss"], + selector: 'app-not-found', + templateUrl: './not-found.component.html', + styleUrls: ['./not-found.component.scss'], }) export class NotFoundComponent {} diff --git a/web/src/app/components/error/server-unavailable/server-unavailable.component.html b/web/src/app/components/error/server-unavailable/server-unavailable.component.html new file mode 100644 index 0000000..7a22427 --- /dev/null +++ b/web/src/app/components/error/server-unavailable/server-unavailable.component.html @@ -0,0 +1,12 @@ +
+
+
+

+ {{ 'error.server_unavailable' | translate }} +

+ + {{ 'error.retry' | translate }} + +
+
+
diff --git a/web/src/app/components/error/server-unavailable/server-unavailable.component.scss b/web/src/app/components/error/server-unavailable/server-unavailable.component.scss new file mode 100644 index 0000000..c910fd1 --- /dev/null +++ b/web/src/app/components/error/server-unavailable/server-unavailable.component.scss @@ -0,0 +1,4 @@ +h1 { + color: #a03033; + font-size: 3rem !important; +} diff --git a/web/src/app/components/error/server-unavailable/server-unavailable.component.spec.ts b/web/src/app/components/error/server-unavailable/server-unavailable.component.spec.ts new file mode 100644 index 0000000..73c2e28 --- /dev/null +++ b/web/src/app/components/error/server-unavailable/server-unavailable.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('NotFoundComponent', () => { + let component: NotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NotFoundComponent], + imports: [TranslateModule.forRoot()], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/components/error/server-unavailable/server-unavailable.component.ts b/web/src/app/components/error/server-unavailable/server-unavailable.component.ts new file mode 100644 index 0000000..f0815c0 --- /dev/null +++ b/web/src/app/components/error/server-unavailable/server-unavailable.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-server-unavailable', + templateUrl: './server-unavailable.component.html', + styleUrls: ['./server-unavailable.component.scss'], +}) +export class ServerUnavailableComponent { + constructor(private router: Router) {} + + async retryConnection() { + await this.router.navigate(['/']); + } +} diff --git a/web/src/app/components/footer/footer.component.ts b/web/src/app/components/footer/footer.component.ts index b44bb0c..fcccc9c 100644 --- a/web/src/app/components/footer/footer.component.ts +++ b/web/src/app/components/footer/footer.component.ts @@ -1,23 +1,23 @@ -import { Component } from "@angular/core"; -import { SettingsService } from "src/app/service/settings.service"; +import { Component } from '@angular/core'; +import { ConfigService } from 'src/app/service/config.service'; @Component({ - selector: "app-footer", - templateUrl: "./footer.component.html", - styleUrls: ["./footer.component.scss"], + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'], }) export class FooterComponent { - constructor(private settings: SettingsService) {} + constructor(private config: ConfigService) {} get termsUrl(): string { - return this.settings.settings.termsUrl; + return this.config.settings.termsUrl; } get privacyUrl(): string { - return this.settings.settings.privacyURL; + return this.config.settings.privacyURL; } get imprintUrl(): string { - return this.settings.settings.imprintURL; + return this.config.settings.imprintURL; } } diff --git a/web/src/app/components/header/header.component.ts b/web/src/app/components/header/header.component.ts index 9811b95..6ded32c 100644 --- a/web/src/app/components/header/header.component.ts +++ b/web/src/app/components/header/header.component.ts @@ -9,6 +9,7 @@ import { AuthService } from 'src/app/service/auth.service'; import { MenuElement } from 'src/app/model/view/menu-element'; import { SidebarService } from 'src/app/service/sidebar.service'; import { SettingsService } from 'src/app/service/settings.service'; +import { ConfigService } from 'src/app/service/config.service'; @Component({ selector: 'app-header', @@ -28,11 +29,11 @@ export class HeaderComponent implements OnInit, OnDestroy { constructor( private translateService: TranslateService, - private config: PrimeNGConfig, + private ngConfig: PrimeNGConfig, private guiService: GuiService, private auth: AuthService, private sidebarService: SidebarService, - private settings: SettingsService + private config: ConfigService ) { this.guiService.isMobile$ .pipe(takeUntil(this.unsubscribe$)) @@ -48,7 +49,7 @@ export class HeaderComponent implements OnInit, OnDestroy { await this.initMenuLists(); }); - this.themeList = this.settings.settings.themes.map(theme => { + this.themeList = this.config.settings.themes.map(theme => { return { label: theme.label, command: () => { @@ -122,7 +123,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.translateService.use(lang); this.translateService .get('primeng') - .subscribe(res => this.config.setTranslation(res)); + .subscribe(res => this.ngConfig.setTranslation(res)); } async loadLang() { diff --git a/web/src/app/core/base/form-page-base.ts b/web/src/app/core/base/form-page-base.ts index ac73c3c..3fb3523 100644 --- a/web/src/app/core/base/form-page-base.ts +++ b/web/src/app/core/base/form-page-base.ts @@ -1,9 +1,9 @@ -import { Directive, inject } from "@angular/core"; -import { PageDataService } from "src/app/core/base/page.data.service"; -import { SpinnerService } from "src/app/service/spinner.service"; -import { FilterService } from "src/app/service/filter.service"; -import { FormGroup } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; +import { Directive, inject } from '@angular/core'; +import { PageDataService } from 'src/app/core/base/page.data.service'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { FilterService } from 'src/app/service/filter.service'; +import { FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; @Directive() export abstract class FormPageBase< @@ -27,7 +27,7 @@ export abstract class FormPageBase< protected dataService = inject(PageDataService) as S; protected constructor() { - const id = this.route.snapshot.params["id"]; + const id = this.route.snapshot.params['id']; this.validateRoute(id); this.buildForm(); @@ -35,18 +35,18 @@ export abstract class FormPageBase< validateRoute(id: string | undefined) { const url = this.router.url; - if (url.endsWith("create") && id !== undefined) { - throw new Error("Route ends with create but id is defined"); + if (url.endsWith('create') && id !== undefined) { + throw new Error('Route ends with create but id is defined'); } - if (url.endsWith("edit") && (id === undefined || isNaN(+id))) { - throw new Error("Route ends with edit but id is not a number"); + if (url.endsWith('edit') && (id === undefined || isNaN(+id))) { + throw new Error('Route ends with edit but id is not a number'); } this.nodeId = id ? +id : undefined; } close() { - const backRoute = this.nodeId ? "../.." : ".."; + const backRoute = this.nodeId ? '../..' : '..'; this.router.navigate([backRoute], { relativeTo: this.route }).then(() => { this.filterService.onLoad.emit(); diff --git a/web/src/app/core/base/page-base.ts b/web/src/app/core/base/page-base.ts index 58f70e5..f9233e3 100644 --- a/web/src/app/core/base/page-base.ts +++ b/web/src/app/core/base/page-base.ts @@ -1,21 +1,21 @@ -import { Directive, inject, OnDestroy } from "@angular/core"; -import { PageDataService } from "src/app/core/base/page.data.service"; -import { Subject } from "rxjs"; -import { Logger } from "src/app/service/logger.service"; -import { QueryResult } from "src/app/model/entities/query-result"; -import { SpinnerService } from "src/app/service/spinner.service"; -import { FilterService } from "src/app/service/filter.service"; -import { Filter } from "src/app/model/graphql/filter/filter.model"; -import { Sort } from "src/app/model/graphql/filter/sort.model"; -import { takeUntil } from "rxjs/operators"; -import { PaginatorState } from "primeng/paginator"; -import { PageColumns } from "src/app/core/base/page.columns"; +import { Directive, inject, OnDestroy } from '@angular/core'; +import { PageDataService } from 'src/app/core/base/page.data.service'; +import { Subject } from 'rxjs'; +import { Logger } from 'src/app/service/logger.service'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { FilterService } from 'src/app/service/filter.service'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort } from 'src/app/model/graphql/filter/sort.model'; +import { takeUntil } from 'rxjs/operators'; +import { PaginatorState } from 'primeng/paginator'; +import { PageColumns } from 'src/app/core/base/page.columns'; import { TableColumn, TableRequireAnyPermissions, -} from "src/app/modules/shared/components/table/table.model"; +} from 'src/app/modules/shared/components/table/table.model'; -const logger = new Logger("PageBase"); +const logger = new Logger('PageBase'); @Directive() export abstract class PageBase< @@ -96,13 +96,13 @@ export abstract class PageBase< } columns: TableColumn[] = - "get" in this.columnsService ? this.columnsService.get() : []; + 'get' in this.columnsService ? this.columnsService.get() : []; protected unsubscribe$ = new Subject(); protected constructor( useQueryParams = false, - permissions?: TableRequireAnyPermissions, + permissions?: TableRequireAnyPermissions ) { this.subscribeToFilterService(); this.filterService.reset({ @@ -110,10 +110,17 @@ export abstract class PageBase< withHideDeleted: true, }); this.requiredPermissions = permissions ?? {}; + + this.dataService + .onChange() + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(true); + }); } ngOnDestroy(): void { - logger.trace("Destroy component"); + logger.trace('Destroy component'); this.unsubscribe$.next(); this.unsubscribe$.complete(); } @@ -125,26 +132,26 @@ export abstract class PageBase< this.filterService.filter$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((filter) => { + .subscribe(filter => { this._filter = filter; }); this.filterService.sort$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((sort) => { + .subscribe(sort => { this._sort = sort; }); this.filterService.skip$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((skip) => { + .subscribe(skip => { this._skip = skip; }); this.filterService.take$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((take) => { - if (take && Object.prototype.hasOwnProperty.call(take, "showAll")) { + .subscribe(take => { + if (take && Object.prototype.hasOwnProperty.call(take, 'showAll')) { this._take = 0; return; } @@ -163,5 +170,5 @@ export abstract class PageBase< this.filterService.onLoad.emit(); } - abstract load(): void; + abstract load(silent?: boolean): void; } diff --git a/web/src/app/core/base/page.columns.ts b/web/src/app/core/base/page.columns.ts index d193624..405f988 100644 --- a/web/src/app/core/base/page.columns.ts +++ b/web/src/app/core/base/page.columns.ts @@ -1,67 +1,69 @@ -import { Injectable } from "@angular/core"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; -import { DbModel } from "src/app/model/entities/db-model"; +import { Injectable } from '@angular/core'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { DbModel } from 'src/app/model/entities/db-model'; @Injectable({ - providedIn: "root", + providedIn: 'root', }) export abstract class PageColumns { abstract get(): TableColumn[]; } export const ID_COLUMN = { - name: "id", - label: "common.id", - type: "number", + name: 'id', + translationKey: 'common.id', + type: 'number', filterable: true, value: (row: { id?: number }) => row.id, - class: "max-w-24", + class: 'max-w-24', }; export const NAME_COLUMN = { - name: "name", - label: "common.name", - type: "text", + name: 'name', + translationKey: 'common.name', + type: 'text', filterable: true, value: (row: { name?: string }) => row.name, }; export const DESCRIPTION_COLUMN = { - name: "description", - label: "common.description", - type: "text", + name: 'description', + translationKey: 'common.description', + type: 'text', filterable: true, value: (row: { description?: string }) => row.description, }; export const DELETED_COLUMN = { - name: "deleted", - label: "common.deleted", - type: "bool", + name: 'deleted', + translationKey: 'common.deleted', + type: 'bool', filterable: true, value: (row: DbModel) => row.deleted, }; export const EDITOR_COLUMN = { - name: "editor", - label: "common.editor", + name: 'editor', + translationKey: 'common.editor', value: (row: DbModel) => row.editor?.username, }; export const CREATED_UTC_COLUMN = { - name: "createdUtc", - label: "common.created", - type: "date", + name: 'createdUtc', + translationKey: 'common.created', + type: 'date', + filterable: true, value: (row: DbModel) => row.createdUtc, - class: "max-w-32", + class: 'max-w-32', }; export const UPDATED_UTC_COLUMN = { - name: "updatedUtc", - label: "common.updated", - type: "date", + name: 'updatedUtc', + translationKey: 'common.updated', + type: 'date', + filterable: true, value: (row: DbModel) => row.updatedUtc, - class: "max-w-32", + class: 'max-w-32', }; export const DB_MODEL_COLUMNS = [ diff --git a/web/src/app/core/base/page.data.service.ts b/web/src/app/core/base/page.data.service.ts index 945d1a5..b878044 100644 --- a/web/src/app/core/base/page.data.service.ts +++ b/web/src/app/core/base/page.data.service.ts @@ -1,22 +1,24 @@ -import { Injectable } from "@angular/core"; -import { Observable } from "rxjs"; -import { MutationResult } from "apollo-angular"; -import { Filter } from "src/app/model/graphql/filter/filter.model"; -import { Sort } from "src/app/model/graphql/filter/sort.model"; -import { QueryResult } from "src/app/model/entities/query-result"; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { MutationResult } from 'apollo-angular'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort } from 'src/app/model/graphql/filter/sort.model'; +import { QueryResult } from 'src/app/model/entities/query-result'; @Injectable({ - providedIn: "root", + providedIn: 'root', }) export abstract class PageDataService { abstract load( filter?: Filter[], sort?: Sort[], skip?: number, - take?: number, + take?: number ): Observable>; abstract loadById(id: number): Observable; + + abstract onChange(): Observable; } export interface Create { @@ -29,12 +31,12 @@ export interface Update { export interface Delete { delete( - object: T, + object: T ): Observable | Observable; } export interface Restore { restore( - object: T, + object: T ): Observable | Observable; } diff --git a/web/src/app/core/init-keycloak.ts b/web/src/app/core/init-keycloak.ts index 11aa944..07232b9 100644 --- a/web/src/app/core/init-keycloak.ts +++ b/web/src/app/core/init-keycloak.ts @@ -1,15 +1,15 @@ import { KeycloakService } from 'keycloak-angular'; -import { SettingsService } from 'src/app/service/settings.service'; +import { ConfigService } from 'src/app/service/config.service'; export function initializeKeycloak( keycloak: KeycloakService, - settings: SettingsService + config: ConfigService ): Promise { return keycloak.init({ config: { - url: settings.settings.keycloak.url, - realm: settings.settings.keycloak.realm, - clientId: settings.settings.keycloak.clientId, + url: config.settings.keycloak.url, + realm: config.settings.keycloak.realm, + clientId: config.settings.keycloak.clientId, }, initOptions: { onLoad: 'check-sso', diff --git a/web/src/app/model/auth/permissionsEnum.ts b/web/src/app/model/auth/permissionsEnum.ts index 5885651..0d62ae2 100644 --- a/web/src/app/model/auth/permissionsEnum.ts +++ b/web/src/app/model/auth/permissionsEnum.ts @@ -1,5 +1,7 @@ export enum PermissionsEnum { // Administration + administrator = 'administrator', + apiKeys = 'api_keys', apiKeysCreate = 'api_keys.create', apiKeysUpdate = 'api_keys.update', diff --git a/web/src/app/model/config/app-settings.ts b/web/src/app/model/config/app-settings.ts index 6046efc..690d6be 100644 --- a/web/src/app/model/config/app-settings.ts +++ b/web/src/app/model/config/app-settings.ts @@ -23,5 +23,6 @@ export interface KeycloakSettings { export interface ApiSettings { url: string; + wsUrl: string; redirector: string; } diff --git a/web/src/app/model/entities/feature-flag.ts b/web/src/app/model/entities/feature-flag.ts new file mode 100644 index 0000000..010843a --- /dev/null +++ b/web/src/app/model/entities/feature-flag.ts @@ -0,0 +1,4 @@ +export interface FeatureFlag { + key: string; + value: boolean; +} diff --git a/web/src/app/model/entities/setting.ts b/web/src/app/model/entities/setting.ts new file mode 100644 index 0000000..2a8ae07 --- /dev/null +++ b/web/src/app/model/entities/setting.ts @@ -0,0 +1,4 @@ +export interface Setting { + key: string; + value: string; +} diff --git a/web/src/app/modules/admin/administration/administration.module.ts b/web/src/app/modules/admin/administration/administration.module.ts index 8201495..8ea1df2 100644 --- a/web/src/app/modules/admin/administration/administration.module.ts +++ b/web/src/app/modules/admin/administration/administration.module.ts @@ -1,41 +1,61 @@ -import { NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterModule, Routes } from "@angular/router"; -import { PermissionGuard } from "src/app/core/guard/permission.guard"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { SharedModule } from "src/app/modules/shared/shared.module"; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { PermissionGuard } from 'src/app/core/guard/permission.guard'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; const routes: Routes = [ { - path: "users", - title: "Users | Maxlan", + path: 'users', + title: 'Users | Maxlan', loadChildren: () => - import("src/app/modules/admin/administration/users/users.module").then( - (m) => m.UsersModule, + import('src/app/modules/admin/administration/users/users.module').then( + m => m.UsersModule ), canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.users] }, }, { - path: "roles", - title: "Roles | Maxlan", + path: 'roles', + title: 'Roles | Maxlan', loadChildren: () => - import("src/app/modules/admin/administration/roles/roles.module").then( - (m) => m.RolesModule, + import('src/app/modules/admin/administration/roles/roles.module').then( + m => m.RolesModule ), canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.roles] }, }, { - path: "api-keys", - title: "API Key | Maxlan", + path: 'api-keys', + title: 'API Key | Maxlan', loadChildren: () => import( - "src/app/modules/admin/administration/api-keys/api-keys.module" - ).then((m) => m.ApiKeysModule), + 'src/app/modules/admin/administration/api-keys/api-keys.module' + ).then(m => m.ApiKeysModule), canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.apiKeys] }, }, + { + path: 'feature-flags', + title: 'Feature-flags | Maxlan', + loadChildren: () => + import( + 'src/app/modules/admin/administration/feature-flags/feature-flags.module' + ).then(m => m.FeatureFlagsModule), + canActivate: [PermissionGuard], + data: { permissions: [PermissionsEnum.administrator] }, + }, + { + path: 'settings', + title: 'Settings | Maxlan', + loadChildren: () => + import( + 'src/app/modules/admin/administration/settings/settings.module' + ).then(m => m.SettingsModule), + canActivate: [PermissionGuard], + data: { permissions: [PermissionsEnum.administrator] }, + }, ]; @NgModule({ diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.columns.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.columns.ts index f08227e..5974867 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.columns.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.columns.ts @@ -1,11 +1,11 @@ -import { Injectable, Provider } from "@angular/core"; +import { Injectable, Provider } from '@angular/core'; import { DB_MODEL_COLUMNS, ID_COLUMN, PageColumns, -} from "src/app/core/base/page.columns"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; -import { ApiKey } from "src/app/model/entities/api-key"; +} from 'src/app/core/base/page.columns'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { ApiKey } from 'src/app/model/entities/api-key'; @Injectable() export class ApiKeysColumns extends PageColumns { @@ -13,16 +13,16 @@ export class ApiKeysColumns extends PageColumns { return [ ID_COLUMN, { - name: "identifier", - label: "common.identifier", - type: "text", + name: 'identifier', + translationKey: 'common.identifier', + type: 'text', filterable: true, value: (row: ApiKey) => row.identifier, }, { - name: "key", - label: "common.key", - type: "password", + name: 'key', + translationKey: 'common.key', + type: 'password', value: (row: ApiKey) => row.key, }, ...DB_MODEL_COLUMNS, diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts index 30d1d0a..3c0c1cd 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts @@ -1,25 +1,25 @@ -import { Injectable, Provider } from "@angular/core"; -import { Observable } from "rxjs"; +import { Injectable, Provider } from '@angular/core'; +import { Observable } from 'rxjs'; import { Create, Delete, PageDataService, Restore, Update, -} from "src/app/core/base/page.data.service"; -import { Permission } from "src/app/model/entities/role"; -import { Filter } from "src/app/model/graphql/filter/filter.model"; -import { Sort } from "src/app/model/graphql/filter/sort.model"; -import { Apollo, gql } from "apollo-angular"; -import { QueryResult } from "src/app/model/entities/query-result"; -import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query"; -import { catchError, map } from "rxjs/operators"; -import { SpinnerService } from "src/app/service/spinner.service"; +} from 'src/app/core/base/page.data.service'; +import { Permission } from 'src/app/model/entities/role'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort } from 'src/app/model/graphql/filter/sort.model'; +import { Apollo, gql } from 'apollo-angular'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query'; +import { catchError, map } from 'rxjs/operators'; +import { SpinnerService } from 'src/app/service/spinner.service'; import { ApiKey, ApiKeyCreateInput, ApiKeyUpdateInput, -} from "src/app/model/entities/api-key"; +} from 'src/app/model/entities/api-key'; @Injectable() export class ApiKeysDataService @@ -32,7 +32,7 @@ export class ApiKeysDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo, + private apollo: Apollo ) { super(); } @@ -41,7 +41,7 @@ export class ApiKeysDataService filter?: Filter[] | undefined, sort?: Sort[] | undefined, skip?: number | undefined, - take?: number | undefined, + take?: number | undefined ): Observable> { return this.apollo .query<{ apiKeys: QueryResult }>({ @@ -78,12 +78,12 @@ export class ApiKeysDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.apiKeys)); + .pipe(map(result => result.data.apiKeys)); } loadById(id: number): Observable { @@ -109,12 +109,24 @@ export class ApiKeysDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.apiKeys.nodes[0])); + .pipe(map(result => result.data.apiKeys.nodes[0])); + } + + onChange(): Observable { + return this.apollo + .subscribe<{ apiKeyChange: void }>({ + query: gql` + subscription onApiKeyChange { + apiKeyChange + } + `, + }) + .pipe(map(result => result.data?.apiKeyChange)); } create(object: ApiKeyCreateInput): Observable { @@ -140,17 +152,17 @@ export class ApiKeysDataService variables: { input: { identifier: object.identifier, - permissions: object.permissions?.map((x) => x.id), + permissions: object.permissions?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.apiKey.create)); + .pipe(map(result => result.data?.apiKey.create)); } update(object: ApiKeyUpdateInput): Observable { @@ -177,17 +189,17 @@ export class ApiKeysDataService input: { id: object.id, identifier: object.identifier, - permissions: object.permissions?.map((x) => x.id), + permissions: object.permissions?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.apiKey.update)); + .pipe(map(result => result.data?.apiKey.update)); } delete(object: ApiKey): Observable { @@ -205,12 +217,12 @@ export class ApiKeysDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.apiKey.delete ?? false)); + .pipe(map(result => result.data?.apiKey.delete ?? false)); } restore(object: ApiKey): Observable { @@ -228,12 +240,12 @@ export class ApiKeysDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.apiKey.restore ?? false)); + .pipe(map(result => result.data?.apiKey.restore ?? false)); } getAllPermissions(): Observable { @@ -251,12 +263,12 @@ export class ApiKeysDataService `, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.permissions.nodes)); + .pipe(map(result => result.data.permissions.nodes)); } static provide(): Provider[] { diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.module.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.module.ts index 268b1c7..93a2678 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.module.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.module.ts @@ -1,22 +1,22 @@ -import { NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { RouterModule, Routes } from "@angular/router"; -import { PermissionGuard } from "src/app/core/guard/permission.guard"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { ApiKeyFormPageComponent } from "src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component"; -import { ApiKeysPage } from "src/app/modules/admin/administration/api-keys/api-keys.page"; -import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; -import { ApiKeysColumns } from "src/app/modules/admin/administration/api-keys/api-keys.columns"; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { PermissionGuard } from 'src/app/core/guard/permission.guard'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { ApiKeyFormPageComponent } from 'src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component'; +import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page'; +import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; +import { ApiKeysColumns } from 'src/app/modules/admin/administration/api-keys/api-keys.columns'; const routes: Routes = [ { - path: "", - title: "Admin - ApiKeys | Maxlan", + path: '', + title: 'Admin - ApiKeys | Maxlan', component: ApiKeysPage, children: [ { - path: "create", + path: 'create', component: ApiKeyFormPageComponent, canActivate: [PermissionGuard], data: { @@ -24,7 +24,7 @@ const routes: Routes = [ }, }, { - path: "edit/:id", + path: 'edit/:id', component: ApiKeyFormPageComponent, canActivate: [PermissionGuard], data: { diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.page.spec.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.page.spec.ts index f1ba746..98eb39d 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.page.spec.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.page.spec.ts @@ -1,18 +1,18 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ApiKeysPage } from "src/app/modules/admin/administration/api-keys/api-keys.page"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { KeycloakService } from "keycloak-angular"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { PageDataService } from "src/app/core/base/page.data.service"; -import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { PageDataService } from 'src/app/core/base/page.data.service'; +import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; -describe("ApiKeysComponent", () => { +describe('ApiKeysComponent', () => { let component: ApiKeysPage; let fixture: ComponentFixture; @@ -45,7 +45,7 @@ describe("ApiKeysComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.page.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.page.ts index d946dfc..c2205cf 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.page.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.page.ts @@ -1,16 +1,16 @@ -import { Component } from "@angular/core"; -import { PageBase } from "src/app/core/base/page-base"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationDialogService } from "src/app/service/confirmation-dialog.service"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { ApiKey } from "src/app/model/entities/api-key"; -import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; -import { ApiKeysColumns } from "src/app/modules/admin/administration/api-keys/api-keys.columns"; +import { Component } from '@angular/core'; +import { PageBase } from 'src/app/core/base/page-base'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { ApiKey } from 'src/app/model/entities/api-key'; +import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; +import { ApiKeysColumns } from 'src/app/modules/admin/administration/api-keys/api-keys.columns'; @Component({ - selector: "app-api-keys", - templateUrl: "./api-keys.page.html", - styleUrl: "./api-keys.page.scss", + selector: 'app-api-keys', + templateUrl: './api-keys.page.html', + styleUrl: './api-keys.page.scss', }) export class ApiKeysPage extends PageBase< ApiKey, @@ -19,7 +19,7 @@ export class ApiKeysPage extends PageBase< > { constructor( private toast: ToastService, - private confirmation: ConfirmationDialogService, + private confirmation: ConfirmationDialogService ) { super(true, { read: [PermissionsEnum.apiKeys], @@ -30,11 +30,11 @@ export class ApiKeysPage extends PageBase< }); } - load(): void { - this.loading = true; + load(silent?: boolean): void { + if (silent) this.loading = true; this.dataService .load(this.filter, this.sort, this.skip, this.take) - .subscribe((result) => { + .subscribe(result => { this.result = result; this.loading = false; }); @@ -42,12 +42,12 @@ export class ApiKeysPage extends PageBase< delete(apiKey: ApiKey): void { this.confirmation.confirmDialog({ - header: "dialog.delete.header", - message: "dialog.delete.message", + header: 'dialog.delete.header', + message: 'dialog.delete.message', accept: () => { this.loading = true; this.dataService.delete(apiKey).subscribe(() => { - this.toast.success("action.deleted"); + this.toast.success('action.deleted'); this.load(); }); }, @@ -57,12 +57,12 @@ export class ApiKeysPage extends PageBase< restore(apiKey: ApiKey): void { this.confirmation.confirmDialog({ - header: "dialog.restore.header", - message: "dialog.restore.message", + header: 'dialog.restore.header', + message: 'dialog.restore.message', accept: () => { this.loading = true; this.dataService.restore(apiKey).subscribe(() => { - this.toast.success("action.restored"); + this.toast.success('action.restored'); this.load(); }); }, diff --git a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.spec.ts b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.spec.ts index d7bfe12..a4a6c84 100644 --- a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.spec.ts +++ b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.spec.ts @@ -1,17 +1,17 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -describe("ApiKeyFormpageComponent", () => { +describe('ApiKeyFormpageComponent', () => { let component: RoleFormPageComponent; let fixture: ComponentFixture; @@ -44,7 +44,7 @@ describe("ApiKeyFormpageComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts index 3bd327a..5b2d941 100644 --- a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts +++ b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts @@ -1,21 +1,21 @@ -import { Component } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { InputSwitchChangeEvent } from "primeng/inputswitch"; -import { ToastService } from "src/app/service/toast.service"; -import { firstValueFrom } from "rxjs"; -import { FormPageBase } from "src/app/core/base/form-page-base"; -import { Permission } from "src/app/model/entities/role"; +import { Component } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { InputSwitchChangeEvent } from 'primeng/inputswitch'; +import { ToastService } from 'src/app/service/toast.service'; +import { firstValueFrom } from 'rxjs'; +import { FormPageBase } from 'src/app/core/base/form-page-base'; +import { Permission } from 'src/app/model/entities/role'; import { ApiKey, ApiKeyCreateInput, ApiKeyUpdateInput, -} from "src/app/model/entities/api-key"; -import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; +} from 'src/app/model/entities/api-key'; +import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; @Component({ - selector: "app-api-key-form-page", - templateUrl: "./api-key-form-page.component.html", - styleUrl: "./api-key-form-page.component.scss", + selector: 'app-api-key-form-page', + templateUrl: './api-key-form-page.component.html', + styleUrl: './api-key-form-page.component.scss', }) export class ApiKeyFormPageComponent extends FormPageBase< ApiKey, @@ -38,7 +38,7 @@ export class ApiKeyFormPageComponent extends FormPageBase< this.dataService .load([{ id: { equal: this.nodeId } }]) - .subscribe((apiKey) => { + .subscribe(apiKey => { this.node = apiKey.nodes[0]; this.setForm(this.node); }); @@ -47,12 +47,12 @@ export class ApiKeyFormPageComponent extends FormPageBase< async initializePermissions() { const permissions = await firstValueFrom( - this.dataService.getAllPermissions(), + this.dataService.getAllPermissions() ); this.allPermissions = permissions; this.permissionGroups = permissions.reduce( (acc, p) => { - const group = p.name.includes(".") ? p.name.split(".")[0] : p.name; + const group = p.name.includes('.') ? p.name.split('.')[0] : p.name; if (!acc[group]) { acc[group] = []; @@ -60,10 +60,10 @@ export class ApiKeyFormPageComponent extends FormPageBase< acc[group].push(p); return acc; }, - {} as { [key: string]: Permission[] }, + {} as { [key: string]: Permission[] } ); - permissions.forEach((p) => { + permissions.forEach(p => { this.form.addControl(p.name, new FormControl(false)); }); } @@ -77,48 +77,48 @@ export class ApiKeyFormPageComponent extends FormPageBase< id: new FormControl(undefined), identifier: new FormControl( undefined, - Validators.required, + Validators.required ), }); - this.form.controls["id"].disable(); + this.form.controls['id'].disable(); } setForm(node?: ApiKey) { - this.form.controls["id"].setValue(node?.id); - this.form.controls["identifier"].setValue(node?.identifier); + this.form.controls['id'].setValue(node?.id); + this.form.controls['identifier'].setValue(node?.identifier); if (!node) return; const permissions = node.permissions ?? []; - permissions.forEach((p) => { + permissions.forEach(p => { this.form.controls[p.name].setValue(true); }); } getCreateInput(): ApiKeyCreateInput { return { - identifier: this.form.controls["identifier"].pristine + identifier: this.form.controls['identifier'].pristine ? undefined - : (this.form.controls["identifier"].value ?? undefined), + : (this.form.controls['identifier'].value ?? undefined), permissions: this.allPermissions.filter( - (p) => this.form.controls[p.name].value, + p => this.form.controls[p.name].value ), }; } getUpdateInput(): ApiKeyUpdateInput { if (!this.node?.id) { - throw new Error("Node id is missing"); + throw new Error('Node id is missing'); } return { - id: this.form.controls["id"].value, - identifier: this.form.controls["identifier"].pristine + id: this.form.controls['id'].value, + identifier: this.form.controls['identifier'].pristine ? undefined - : (this.form.controls["identifier"].value ?? undefined), + : (this.form.controls['identifier'].value ?? undefined), permissions: this.allPermissions.filter( - (p) => this.form.controls[p.name].value, + p => this.form.controls[p.name].value ), }; } @@ -126,7 +126,7 @@ export class ApiKeyFormPageComponent extends FormPageBase< create(apiKey: ApiKeyCreateInput): void { this.dataService.create(apiKey).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.created'); this.close(); }); } @@ -134,20 +134,20 @@ export class ApiKeyFormPageComponent extends FormPageBase< update(apiKey: ApiKeyUpdateInput): void { this.dataService.update(apiKey).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.updated'); this.close(); }); } toggleGroup(event: InputSwitchChangeEvent, group: string) { - this.permissionGroups[group].forEach((p) => { + this.permissionGroups[group].forEach(p => { this.form.controls[p.name].setValue(event.checked); }); } isGroupChecked(group: string) { return this.permissionGroups[group].every( - (p) => this.form.controls[p.name].value, + p => this.form.controls[p.name].value ); } diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.data.service.ts b/web/src/app/modules/admin/administration/feature-flags/feature-flags.data.service.ts new file mode 100644 index 0000000..8aa912c --- /dev/null +++ b/web/src/app/modules/admin/administration/feature-flags/feature-flags.data.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Provider } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Apollo, gql } from 'apollo-angular'; +import { catchError, map } from 'rxjs/operators'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { FeatureFlag } from 'src/app/model/entities/feature-flag'; + +@Injectable() +export class FeatureFlagsDataService { + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) {} + + load(): Observable { + return this.apollo + .query<{ featureFlags: FeatureFlag[] }>({ + query: gql` + query GetFeatureFlags { + featureFlags { + key + value + } + } + `, + variables: {}, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.featureFlags)); + } + + loadByKey(key: string): Observable { + return this.apollo + .query<{ featureFlags: FeatureFlag[] }>({ + query: gql` + query getFeatureFlag($key: String!) { + featureFlag(key: $key) { + key + value + } + } + `, + variables: { + key, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.featureFlags[0])); + } + + onUpdate(): Observable { + return this.apollo + .subscribe<{ featureFlagChange: void }>({ + query: gql` + subscription onFeatureFlagChange { + featureFlagChange + } + `, + }) + .pipe(map(result => result.data?.featureFlagChange)); + } + + change(object: FeatureFlag): Observable { + return this.apollo + .mutate<{ featureFlag: { change: FeatureFlag } }>({ + mutation: gql` + mutation changeFeatureFlag($input: FeatureFlagInput!) { + featureFlag { + change(input: $input) { + key + value + } + } + } + `, + variables: { + input: { + key: object.key, + value: object.value, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.featureFlag.change)); + } + + static provide(): Provider[] { + return [FeatureFlagsDataService]; + } +} diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.module.ts b/web/src/app/modules/admin/administration/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..a9b7d6e --- /dev/null +++ b/web/src/app/modules/admin/administration/feature-flags/feature-flags.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { FeatureFlagsPage } from 'src/app/modules/admin/administration/feature-flags/feature-flags.page'; +import { FeatureFlagsDataService } from 'src/app/modules/admin/administration/feature-flags/feature-flags.data.service'; + +const routes: Routes = [ + { + path: '', + title: 'Admin - Feature-flags | Maxlan', + component: FeatureFlagsPage, + }, +]; + +@NgModule({ + declarations: [FeatureFlagsPage], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [FeatureFlagsDataService.provide()], +}) +export class FeatureFlagsModule {} diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.html b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.html new file mode 100644 index 0000000..ca5c0c0 --- /dev/null +++ b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.html @@ -0,0 +1,10 @@ +
+
+

{{ flag.key }}

+
+ +
+
+
diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.scss b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.spec.ts b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.spec.ts new file mode 100644 index 0000000..275fe19 --- /dev/null +++ b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FeatureFlagsPage } from 'src/app/modules/admin/administration/feature-flags/feature-flags.page'; +import { FeatureFlagsDataService } from 'src/app/modules/admin/administration/feature-flags/feature-flags.data.service'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('FeatureFlagsComponent', () => { + let component: FeatureFlagsPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FeatureFlagsPage], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + FeatureFlagsDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FeatureFlagsPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.ts b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.ts new file mode 100644 index 0000000..0e5b24f --- /dev/null +++ b/web/src/app/modules/admin/administration/feature-flags/feature-flags.page.ts @@ -0,0 +1,71 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FeatureFlagsDataService } from 'src/app/modules/admin/administration/feature-flags/feature-flags.data.service'; +import { FeatureFlag } from 'src/app/model/entities/feature-flag'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { catchError, takeUntil } from 'rxjs/operators'; +import { InputSwitchChangeEvent } from 'primeng/inputswitch'; +import { Subject } from 'rxjs'; + +@Component({ + selector: 'app-feature-flags', + templateUrl: './feature-flags.page.html', + styleUrl: './feature-flags.page.scss', +}) +export class FeatureFlagsPage implements OnInit, OnDestroy { + flags: FeatureFlag[] = []; + + protected unsubscribe$ = new Subject(); + + constructor( + private spinner: SpinnerService, + private data: FeatureFlagsDataService + ) {} + + ngOnInit() { + this.data + .onUpdate() + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(); + }); + + this.spinner.show(); + this.load(); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + load() { + this.data + .load() + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .subscribe(flags => { + this.flags = flags; + this.spinner.hide(); + }); + } + + change(flag: FeatureFlag, event: InputSwitchChangeEvent) { + flag.value = event.checked; + this.spinner.show(); + this.data + .change(flag) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .subscribe(() => { + this.spinner.hide(); + }); + } +} diff --git a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.html b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.html index a1ff83f..3aca618 100644 --- a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.html +++ b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.html @@ -1,73 +1,73 @@ - -

- {{ 'common.role' | translate }} - {{ - (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') - | translate - }} -

-
+ *ngIf="node" + [formGroup]="form" + [isUpdate]="isUpdate" + (onSave)="save()" + (onClose)="close()"> + +

+ {{ 'common.role' | translate }} + {{ + (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') + | translate + }} +

+
- -
-

{{ 'common.id' | translate }}

- -
-
-

{{ 'common.name' | translate }}

- -
-
-

{{ 'common.description' | translate }}

- -
-
+ +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.name' | translate }}

+ +
+
+

{{ 'common.description' | translate }}

+ +
+
-
-
-
-
- -
- -
-
+
+
+
+
+ +
+ +
+
-
-
- - - -
-
-

- {{ permission.description }} -

-
-
-
+
+
+ + +
+
+

+ {{ permission.description }} +

+
+
- +
+
+ diff --git a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.spec.ts b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.spec.ts index 12380a9..e83d513 100644 --- a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.spec.ts +++ b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.spec.ts @@ -1,17 +1,17 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -describe("RoleFormpageComponent", () => { +describe('RoleFormpageComponent', () => { let component: RoleFormPageComponent; let fixture: ComponentFixture; @@ -44,7 +44,7 @@ describe("RoleFormpageComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.ts b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.ts index 2cbd74f..3a6c0d0 100644 --- a/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.ts +++ b/web/src/app/modules/admin/administration/roles/form-page/role-form-page.component.ts @@ -142,7 +142,7 @@ export class RoleFormPageComponent extends FormPageBase< update(role: RoleUpdateInput): void { this.dataService.update(role).subscribe(() => { this.spinner.hide(); - this.toast.success('action.created'); + this.toast.success('action.updated'); this.close(); }); } diff --git a/web/src/app/modules/admin/administration/roles/roles.columns.ts b/web/src/app/modules/admin/administration/roles/roles.columns.ts index e59dd7b..f9b9e51 100644 --- a/web/src/app/modules/admin/administration/roles/roles.columns.ts +++ b/web/src/app/modules/admin/administration/roles/roles.columns.ts @@ -1,13 +1,13 @@ -import { Injectable, Provider } from "@angular/core"; -import { Role } from "src/app/model/entities/role"; +import { Injectable, Provider } from '@angular/core'; +import { Role } from 'src/app/model/entities/role'; import { DB_MODEL_COLUMNS, DESCRIPTION_COLUMN, ID_COLUMN, NAME_COLUMN, PageColumns, -} from "src/app/core/base/page.columns"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; +} from 'src/app/core/base/page.columns'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; @Injectable() export class RolesColumns extends PageColumns { diff --git a/web/src/app/modules/admin/administration/roles/roles.data.service.ts b/web/src/app/modules/admin/administration/roles/roles.data.service.ts index a2632f1..d553858 100644 --- a/web/src/app/modules/admin/administration/roles/roles.data.service.ts +++ b/web/src/app/modules/admin/administration/roles/roles.data.service.ts @@ -120,6 +120,18 @@ export class RolesDataService .pipe(map(result => result.data.roles.nodes[0])); } + onChange(): Observable { + return this.apollo + .subscribe<{ roleChange: void }>({ + query: gql` + subscription onRoleChange { + roleChange + } + `, + }) + .pipe(map(result => result.data?.roleChange)); + } + create(object: RoleCreateInput): Observable { return this.apollo .mutate<{ role: { create: Role } }>({ @@ -252,7 +264,6 @@ export class RolesDataService nodes { id name - description } } } diff --git a/web/src/app/modules/admin/administration/roles/roles.module.ts b/web/src/app/modules/admin/administration/roles/roles.module.ts index 87cf3cb..7eebe04 100644 --- a/web/src/app/modules/admin/administration/roles/roles.module.ts +++ b/web/src/app/modules/admin/administration/roles/roles.module.ts @@ -1,22 +1,22 @@ -import { NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { RouterModule, Routes } from "@angular/router"; -import { PermissionGuard } from "src/app/core/guard/permission.guard"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; -import { RolesPage } from "src/app/modules/admin/administration/roles/roles.page"; -import { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; -import { RolesColumns } from "src/app/modules/admin/administration/roles/roles.columns"; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { PermissionGuard } from 'src/app/core/guard/permission.guard'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.component'; +import { RolesPage } from 'src/app/modules/admin/administration/roles/roles.page'; +import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; +import { RolesColumns } from 'src/app/modules/admin/administration/roles/roles.columns'; const routes: Routes = [ { - path: "", - title: "Admin - Roles | Maxlan", + path: '', + title: 'Admin - Roles | Maxlan', component: RolesPage, children: [ { - path: "create", + path: 'create', component: RoleFormPageComponent, canActivate: [PermissionGuard], data: { @@ -24,7 +24,7 @@ const routes: Routes = [ }, }, { - path: "edit/:id", + path: 'edit/:id', component: RoleFormPageComponent, canActivate: [PermissionGuard], data: { diff --git a/web/src/app/modules/admin/administration/roles/roles.page.spec.ts b/web/src/app/modules/admin/administration/roles/roles.page.spec.ts index b818f66..86eaa98 100644 --- a/web/src/app/modules/admin/administration/roles/roles.page.spec.ts +++ b/web/src/app/modules/admin/administration/roles/roles.page.spec.ts @@ -1,18 +1,18 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { RolesPage } from "src/app/modules/admin/administration/roles/roles.page"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { KeycloakService } from "keycloak-angular"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { PageDataService } from "src/app/core/base/page.data.service"; -import { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RolesPage } from 'src/app/modules/admin/administration/roles/roles.page'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { PageDataService } from 'src/app/core/base/page.data.service'; +import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; -describe("RolesComponent", () => { +describe('RolesComponent', () => { let component: RolesPage; let fixture: ComponentFixture; @@ -45,7 +45,7 @@ describe("RolesComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/roles/roles.page.ts b/web/src/app/modules/admin/administration/roles/roles.page.ts index afe28b8..e60a981 100644 --- a/web/src/app/modules/admin/administration/roles/roles.page.ts +++ b/web/src/app/modules/admin/administration/roles/roles.page.ts @@ -1,21 +1,21 @@ -import { Component } from "@angular/core"; -import { PageBase } from "src/app/core/base/page-base"; -import { Role } from "src/app/model/entities/role"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationDialogService } from "src/app/service/confirmation-dialog.service"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { RolesColumns } from "src/app/modules/admin/administration/roles/roles.columns"; -import { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; +import { Component } from '@angular/core'; +import { PageBase } from 'src/app/core/base/page-base'; +import { Role } from 'src/app/model/entities/role'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { RolesColumns } from 'src/app/modules/admin/administration/roles/roles.columns'; +import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; @Component({ - selector: "app-roles", - templateUrl: "./roles.page.html", - styleUrl: "./roles.page.scss", + selector: 'app-roles', + templateUrl: './roles.page.html', + styleUrl: './roles.page.scss', }) export class RolesPage extends PageBase { constructor( private toast: ToastService, - private confirmation: ConfirmationDialogService, + private confirmation: ConfirmationDialogService ) { super(true, { read: [PermissionsEnum.roles], @@ -26,11 +26,11 @@ export class RolesPage extends PageBase { }); } - load(): void { - this.loading = true; + load(silent?: boolean): void { + if (!silent) this.loading = true; this.dataService .load(this.filter, this.sort, this.skip, this.take) - .subscribe((result) => { + .subscribe(result => { this.result = result; this.loading = false; }); @@ -38,12 +38,12 @@ export class RolesPage extends PageBase { delete(role: Role): void { this.confirmation.confirmDialog({ - header: "dialog.delete.header", - message: "dialog.delete.message", + header: 'dialog.delete.header', + message: 'dialog.delete.message', accept: () => { this.loading = true; this.dataService.delete(role).subscribe(() => { - this.toast.success("action.deleted"); + this.toast.success('action.deleted'); this.load(); }); }, @@ -53,12 +53,12 @@ export class RolesPage extends PageBase { restore(role: Role): void { this.confirmation.confirmDialog({ - header: "dialog.restore.header", - message: "dialog.restore.message", + header: 'dialog.restore.header', + message: 'dialog.restore.message', accept: () => { this.loading = true; this.dataService.restore(role).subscribe(() => { - this.toast.success("action.restored"); + this.toast.success('action.restored'); this.load(); }); }, diff --git a/web/src/app/modules/admin/administration/settings/settings.data.service.ts b/web/src/app/modules/admin/administration/settings/settings.data.service.ts new file mode 100644 index 0000000..b4d734d --- /dev/null +++ b/web/src/app/modules/admin/administration/settings/settings.data.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Provider } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Apollo, gql } from 'apollo-angular'; +import { catchError, map } from 'rxjs/operators'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { Setting } from 'src/app/model/entities/setting'; + +@Injectable() +export class SettingsDataService { + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) {} + + load(): Observable { + return this.apollo + .query<{ settings: Setting[] }>({ + query: gql` + query GetSettings { + settings { + key + value + } + } + `, + variables: {}, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.settings)); + } + + loadByKey(key: string): Observable { + return this.apollo + .query<{ settings: Setting[] }>({ + query: gql` + query getSetting($key: String!) { + settings(key: $key) { + key + value + } + } + `, + variables: { + key, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.settings[0])); + } + + onUpdate(): Observable { + return this.apollo + .subscribe<{ settingChange: void }>({ + query: gql` + subscription onSettingChange { + settingChange + } + `, + }) + .pipe(map(result => result.data?.settingChange)); + } + + change(object: Setting): Observable { + return this.apollo + .mutate<{ setting: { change: Setting } }>({ + mutation: gql` + mutation changeSetting($input: SettingInput!) { + setting { + change(input: $input) { + key + value + } + } + } + `, + variables: { + input: { + key: object.key, + value: object.value, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.setting.change)); + } + + static provide(): Provider[] { + return [SettingsDataService]; + } +} diff --git a/web/src/app/modules/admin/administration/settings/settings.module.ts b/web/src/app/modules/admin/administration/settings/settings.module.ts new file mode 100644 index 0000000..fcab2f4 --- /dev/null +++ b/web/src/app/modules/admin/administration/settings/settings.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { SettingsPage } from 'src/app/modules/admin/administration/settings/settings.page'; +import { SettingsDataService } from 'src/app/modules/admin/administration/settings/settings.data.service'; + +const routes: Routes = [ + { + path: '', + title: 'Admin - settings | Maxlan', + component: SettingsPage, + }, +]; + +@NgModule({ + declarations: [SettingsPage], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [SettingsDataService.provide()], +}) +export class SettingsModule {} diff --git a/web/src/app/modules/admin/administration/settings/settings.page.html b/web/src/app/modules/admin/administration/settings/settings.page.html new file mode 100644 index 0000000..a9f56ee --- /dev/null +++ b/web/src/app/modules/admin/administration/settings/settings.page.html @@ -0,0 +1,20 @@ +
+
+
+

{{ setting.key }}

+
+ +
+
+
+ +
+ + +
+
diff --git a/web/src/app/modules/admin/administration/settings/settings.page.scss b/web/src/app/modules/admin/administration/settings/settings.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/administration/settings/settings.page.spec.ts b/web/src/app/modules/admin/administration/settings/settings.page.spec.ts new file mode 100644 index 0000000..f51745d --- /dev/null +++ b/web/src/app/modules/admin/administration/settings/settings.page.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SettingsPage } from 'src/app/modules/admin/administration/settings/settings.page'; +import { SettingsDataService } from 'src/app/modules/admin/administration/settings/settings.data.service'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +describe('SettingsComponent', () => { + let component: SettingsPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SettingsPage], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + SettingsDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/administration/settings/settings.page.ts b/web/src/app/modules/admin/administration/settings/settings.page.ts new file mode 100644 index 0000000..826e10c --- /dev/null +++ b/web/src/app/modules/admin/administration/settings/settings.page.ts @@ -0,0 +1,94 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { SettingsDataService } from 'src/app/modules/admin/administration/settings/settings.data.service'; +import { Setting } from 'src/app/model/entities/setting'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { catchError, takeUntil } from 'rxjs/operators'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { Subject } from 'rxjs'; + +@Component({ + selector: 'app-settings', + templateUrl: './settings.page.html', + styleUrl: './settings.page.scss', +}) +export class SettingsPage implements OnInit, OnDestroy { + settings: Setting[] = []; + settingsForm: FormGroup = this.fb.group({}); + + get hasChanges(): boolean { + return !this.settingsForm.pristine; + } + + protected unsubscribe$ = new Subject(); + + constructor( + private spinner: SpinnerService, + private data: SettingsDataService, + private fb: FormBuilder + ) {} + + ngOnInit() { + this.data + .onUpdate() + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(); + }); + + this.spinner.show(); + this.load(); + } + + ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + load() { + this.settingsForm = this.fb.group({}); + this.data + .load() + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .subscribe(settings => { + this.settings = settings; + this.settings.forEach(setting => { + this.settingsForm.addControl( + setting.key, + new FormControl(setting.value) + ); + }); + this.spinner.hide(); + }); + } + + save() { + if (this.settingsForm.pristine) { + return; + } + + const updatedSettings: Setting[] = this.settings + .filter(x => this.settingsForm.get(x.key)?.dirty) + .map(setting => ({ + key: setting.key, + value: this.settingsForm.get(setting.key)?.value, + })); + + for (const setting of updatedSettings) { + this.spinner.show(); + this.data + .change(setting) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .subscribe(() => this.spinner.hide()); + } + } +} diff --git a/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.spec.ts b/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.spec.ts index cd7ab63..c1d221d 100644 --- a/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.spec.ts +++ b/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.spec.ts @@ -1,17 +1,17 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { UsersDataService } from "src/app/modules/admin/administration/users/users.data.service"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -describe("UserFormpageComponent", () => { +describe('UserFormpageComponent', () => { let component: RoleFormPageComponent; let fixture: ComponentFixture; @@ -44,7 +44,7 @@ describe("UserFormpageComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.ts b/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.ts index 00bd9a8..857ca5b 100644 --- a/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.ts +++ b/web/src/app/modules/admin/administration/users/form-page/user-form-page.component.ts @@ -10,7 +10,6 @@ import { } from 'src/app/model/auth/user'; import { Role } from 'src/app/model/entities/role'; import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; -import { CommonDataService } from 'src/app/modules/shared/service/common-data.service'; @Component({ selector: 'app-user-form-page', @@ -26,12 +25,9 @@ export class UserFormPageComponent extends FormPageBase< notExistingUsers: NotExistingUser[] = []; roles: Role[] = []; - constructor( - private toast: ToastService, - private cds: CommonDataService - ) { + constructor(private toast: ToastService) { super(); - this.cds.getAllRoles().subscribe(roles => { + this.dataService.getAllRoles().subscribe(roles => { this.roles = roles; }); @@ -111,7 +107,7 @@ export class UserFormPageComponent extends FormPageBase< update(user: UserUpdateInput): void { this.dataService.update(user).subscribe(() => { this.spinner.hide(); - this.toast.success('action.created'); + this.toast.success('action.updated'); this.close(); }); } diff --git a/web/src/app/modules/admin/administration/users/users.columns.ts b/web/src/app/modules/admin/administration/users/users.columns.ts index bdb4cc3..e1cbbd5 100644 --- a/web/src/app/modules/admin/administration/users/users.columns.ts +++ b/web/src/app/modules/admin/administration/users/users.columns.ts @@ -1,11 +1,11 @@ -import { Injectable, Provider } from "@angular/core"; +import { Injectable, Provider } from '@angular/core'; import { DB_MODEL_COLUMNS, ID_COLUMN, PageColumns, -} from "src/app/core/base/page.columns"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; -import { User } from "src/app/model/auth/user"; +} from 'src/app/core/base/page.columns'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { User } from 'src/app/model/auth/user'; @Injectable() export class UsersColumns extends PageColumns { @@ -13,15 +13,15 @@ export class UsersColumns extends PageColumns { return [ ID_COLUMN, { - name: "username", - label: "user.username", - type: "text", + name: 'username', + translationKey: 'user.username', + type: 'text', value: (row: User) => row.username, }, { - name: "email", - label: "user.email", - type: "text", + name: 'email', + translationKey: 'user.email', + type: 'text', value: (row: User) => row.email, }, ...DB_MODEL_COLUMNS, diff --git a/web/src/app/modules/admin/administration/users/users.data.service.ts b/web/src/app/modules/admin/administration/users/users.data.service.ts index 1a2eb58..f27ebb3 100644 --- a/web/src/app/modules/admin/administration/users/users.data.service.ts +++ b/web/src/app/modules/admin/administration/users/users.data.service.ts @@ -123,6 +123,18 @@ export class UsersDataService .pipe(map(result => result.data.users.nodes[0])); } + onChange(): Observable { + return this.apollo + .subscribe<{ userChange: void }>({ + query: gql` + subscription onUserChange { + userChange + } + `, + }) + .pipe(map(result => result.data?.userChange)); + } + create(object: UserCreateInput): Observable { return this.apollo .mutate<{ user: { create: User } }>({ @@ -239,6 +251,29 @@ export class UsersDataService .pipe(map(result => result.data?.user.restore ?? false)); } + getAllRoles(): Observable { + return this.apollo + .query<{ roles: QueryResult }>({ + query: gql` + query getRoles { + roles { + nodes { + id + name + } + } + } + `, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.roles.nodes)); + } + getNotExistingUsersFromKeycloak(): Observable { return this.apollo .query<{ notExistingUsersFromKeycloak: QueryResult }>({ diff --git a/web/src/app/modules/admin/administration/users/users.module.ts b/web/src/app/modules/admin/administration/users/users.module.ts index b54596f..7546fd4 100644 --- a/web/src/app/modules/admin/administration/users/users.module.ts +++ b/web/src/app/modules/admin/administration/users/users.module.ts @@ -1,22 +1,22 @@ -import { NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { RouterModule, Routes } from "@angular/router"; -import { PermissionGuard } from "src/app/core/guard/permission.guard"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { UserFormPageComponent } from "src/app/modules/admin/administration/users/form-page/user-form-page.component"; -import { UsersPage } from "src/app/modules/admin/administration/users/users.page"; -import { UsersDataService } from "src/app/modules/admin/administration/users/users.data.service"; -import { UsersColumns } from "src/app/modules/admin/administration/users/users.columns"; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { RouterModule, Routes } from '@angular/router'; +import { PermissionGuard } from 'src/app/core/guard/permission.guard'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { UserFormPageComponent } from 'src/app/modules/admin/administration/users/form-page/user-form-page.component'; +import { UsersPage } from 'src/app/modules/admin/administration/users/users.page'; +import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; +import { UsersColumns } from 'src/app/modules/admin/administration/users/users.columns'; const routes: Routes = [ { - path: "", - title: "Admin - Users | Maxlan", + path: '', + title: 'Admin - Users | Maxlan', component: UsersPage, children: [ { - path: "create", + path: 'create', component: UserFormPageComponent, canActivate: [PermissionGuard], data: { @@ -24,7 +24,7 @@ const routes: Routes = [ }, }, { - path: "edit/:id", + path: 'edit/:id', component: UserFormPageComponent, canActivate: [PermissionGuard], data: { diff --git a/web/src/app/modules/admin/administration/users/users.page.spec.ts b/web/src/app/modules/admin/administration/users/users.page.spec.ts index 2a5e730..0e7be0d 100644 --- a/web/src/app/modules/admin/administration/users/users.page.spec.ts +++ b/web/src/app/modules/admin/administration/users/users.page.spec.ts @@ -1,18 +1,18 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { UsersPage } from "src/app/modules/admin/administration/users/users.page"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { KeycloakService } from "keycloak-angular"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; -import { PageDataService } from "src/app/core/base/page.data.service"; -import { UsersDataService } from "src/app/modules/admin/administration/users/users.data.service"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UsersPage } from 'src/app/modules/admin/administration/users/users.page'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { PageDataService } from 'src/app/core/base/page.data.service'; +import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; -describe("UsersComponent", () => { +describe('UsersComponent', () => { let component: UsersPage; let fixture: ComponentFixture; @@ -45,7 +45,7 @@ describe("UsersComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/admin/administration/users/users.page.ts b/web/src/app/modules/admin/administration/users/users.page.ts index f2aa1fc..bbdfbf7 100644 --- a/web/src/app/modules/admin/administration/users/users.page.ts +++ b/web/src/app/modules/admin/administration/users/users.page.ts @@ -1,21 +1,21 @@ -import { Component } from "@angular/core"; -import { PageBase } from "src/app/core/base/page-base"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationDialogService } from "src/app/service/confirmation-dialog.service"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { User } from "src/app/model/auth/user"; -import { UsersColumns } from "src/app/modules/admin/administration/users/users.columns"; -import { UsersDataService } from "src/app/modules/admin/administration/users/users.data.service"; +import { Component } from '@angular/core'; +import { PageBase } from 'src/app/core/base/page-base'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { User } from 'src/app/model/auth/user'; +import { UsersColumns } from 'src/app/modules/admin/administration/users/users.columns'; +import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; @Component({ - selector: "app-users", - templateUrl: "./users.page.html", - styleUrl: "./users.page.scss", + selector: 'app-users', + templateUrl: './users.page.html', + styleUrl: './users.page.scss', }) export class UsersPage extends PageBase { constructor( private toast: ToastService, - private confirmation: ConfirmationDialogService, + private confirmation: ConfirmationDialogService ) { super(true, { read: [PermissionsEnum.users], @@ -26,11 +26,11 @@ export class UsersPage extends PageBase { }); } - load(): void { - this.loading = true; + load(silent?: boolean): void { + if (!silent) this.loading = true; this.dataService .load(this.filter, this.sort, this.skip, this.take) - .subscribe((result) => { + .subscribe(result => { this.result = result; this.loading = false; }); @@ -38,12 +38,12 @@ export class UsersPage extends PageBase { delete(user: User): void { this.confirmation.confirmDialog({ - header: "dialog.delete.header", - message: "dialog.delete.message", + header: 'dialog.delete.header', + message: 'dialog.delete.message', accept: () => { this.loading = true; this.dataService.delete(user).subscribe(() => { - this.toast.success("action.deleted"); + this.toast.success('action.deleted'); this.load(); }); }, @@ -53,12 +53,12 @@ export class UsersPage extends PageBase { restore(user: User): void { this.confirmation.confirmDialog({ - header: "dialog.restore.header", - message: "dialog.restore.message", + header: 'dialog.restore.header', + message: 'dialog.restore.message', accept: () => { this.loading = true; this.dataService.restore(user).subscribe(() => { - this.toast.success("action.restored"); + this.toast.success('action.restored'); this.load(); }); }, diff --git a/web/src/app/modules/admin/domains/domains.columns.ts b/web/src/app/modules/admin/domains/domains.columns.ts index 3138c19..ef239c1 100644 --- a/web/src/app/modules/admin/domains/domains.columns.ts +++ b/web/src/app/modules/admin/domains/domains.columns.ts @@ -14,7 +14,7 @@ export class DomainsColumns extends PageColumns { ID_COLUMN, { name: 'name', - label: 'common.name', + translationKey: 'common.name', type: 'text', filterable: true, value: (row: Domain) => row.name, diff --git a/web/src/app/modules/admin/domains/domains.data.service.ts b/web/src/app/modules/admin/domains/domains.data.service.ts index 133790f..469958a 100644 --- a/web/src/app/modules/admin/domains/domains.data.service.ts +++ b/web/src/app/modules/admin/domains/domains.data.service.ts @@ -109,6 +109,18 @@ export class DomainsDataService .pipe(map(result => result.data.domains.nodes[0])); } + onChange(): Observable { + return this.apollo + .subscribe<{ domainChange: void }>({ + query: gql` + subscription onRoleChange { + domainChange + } + `, + }) + .pipe(map(result => result.data?.domainChange)); + } + create(object: DomainCreateInput): Observable { return this.apollo .mutate<{ domain: { create: Domain } }>({ diff --git a/web/src/app/modules/admin/groups/groups.columns.ts b/web/src/app/modules/admin/groups/groups.columns.ts index 20cc3c0..b72bc2c 100644 --- a/web/src/app/modules/admin/groups/groups.columns.ts +++ b/web/src/app/modules/admin/groups/groups.columns.ts @@ -1,11 +1,11 @@ -import { Injectable, Provider } from "@angular/core"; +import { Injectable, Provider } from '@angular/core'; import { DB_MODEL_COLUMNS, ID_COLUMN, PageColumns, -} from "src/app/core/base/page.columns"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; -import { Group } from "src/app/model/entities/group"; +} from 'src/app/core/base/page.columns'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { Group } from 'src/app/model/entities/group'; @Injectable() export class GroupsColumns extends PageColumns { @@ -13,9 +13,9 @@ export class GroupsColumns extends PageColumns { return [ ID_COLUMN, { - name: "name", - label: "common.name", - type: "text", + name: 'name', + translationKey: 'common.name', + type: 'text', filterable: true, value: (row: Group) => row.name, }, diff --git a/web/src/app/modules/admin/groups/groups.data.service.ts b/web/src/app/modules/admin/groups/groups.data.service.ts index 40f5d21..589bf9b 100644 --- a/web/src/app/modules/admin/groups/groups.data.service.ts +++ b/web/src/app/modules/admin/groups/groups.data.service.ts @@ -117,6 +117,18 @@ export class GroupsDataService .pipe(map(result => result.data.groups.nodes[0])); } + onChange(): Observable { + return this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onRoleChange { + groupChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)); + } + create(object: GroupCreateInput): Observable { return this.apollo .mutate<{ group: { create: Group } }>({ diff --git a/web/src/app/modules/admin/public/public.module.ts b/web/src/app/modules/admin/public/public.module.ts deleted file mode 100644 index 3b80e02..0000000 --- a/web/src/app/modules/admin/public/public.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterModule, Routes } from "@angular/router"; -import { SharedModule } from "src/app/modules/shared/shared.module"; - -const routes: Routes = []; - -@NgModule({ - declarations: [], - imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], -}) -export class PublicModule {} diff --git a/web/src/app/modules/admin/short-urls/short-urls.columns.ts b/web/src/app/modules/admin/short-urls/short-urls.columns.ts index 56f5431..70dbdde 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.columns.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.columns.ts @@ -1,11 +1,11 @@ -import { Injectable, Provider } from "@angular/core"; +import { Injectable, Provider } from '@angular/core'; import { DB_MODEL_COLUMNS, ID_COLUMN, PageColumns, -} from "src/app/core/base/page.columns"; -import { TableColumn } from "src/app/modules/shared/components/table/table.model"; -import { ShortUrl } from "src/app/model/entities/short-url"; +} from 'src/app/core/base/page.columns'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { ShortUrl } from 'src/app/model/entities/short-url'; @Injectable() export class ShortUrlsColumns extends PageColumns { @@ -13,23 +13,23 @@ export class ShortUrlsColumns extends PageColumns { return [ ID_COLUMN, { - name: "short_url", - label: "short_url.short_url", - type: "text", + name: 'short_url', + translationKey: 'short_url.short_url', + type: 'text', filterable: true, value: (row: ShortUrl) => row.shortUrl, }, { - name: "target_url", - label: "short_url.target_url", - type: "text", + name: 'target_url', + translationKey: 'short_url.target_url', + type: 'text', filterable: true, value: (row: ShortUrl) => row.targetUrl, }, { - name: "description", - label: "common.description", - type: "text", + name: 'description', + translationKey: 'common.description', + type: 'text', filterable: true, value: (row: ShortUrl) => row.description, }, diff --git a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts index f43f3da..25f2e35 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts @@ -135,6 +135,18 @@ export class ShortUrlsDataService .pipe(map(result => result.data.shortUrls.nodes[0])); } + onChange(): Observable { + return this.apollo + .subscribe<{ shortUrlChange: void }>({ + query: gql` + subscription onRoleChange { + shortUrlChange + } + `, + }) + .pipe(map(result => result.data?.shortUrlChange)); + } + create(object: ShortUrlCreateInput): Observable { return this.apollo .mutate<{ shortUrl: { create: ShortUrl } }>({ diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.ts b/web/src/app/modules/admin/short-urls/short-urls.page.ts index d8acddd..02bcbb8 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.page.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.page.ts @@ -11,6 +11,7 @@ import { Filter } from 'src/app/model/graphql/filter/filter.model'; import { SettingsService } from 'src/app/service/settings.service'; import QrCodeWithLogo from 'qrcode-with-logos'; import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload'; +import { ConfigService } from 'src/app/service/config.service'; @Component({ selector: 'app-short-urls', @@ -64,7 +65,7 @@ export class ShortUrlsPage private toast: ToastService, private confirmation: ConfirmationDialogService, private auth: AuthService, - private settings: SettingsService + private config: ConfigService ) { super(true, { read: [PermissionsEnum.shortUrls], @@ -174,7 +175,7 @@ export class ShortUrlsPage if (!this.qrCodeDialog.shortUrl) return; const shortUrl = this.qrCodeDialog.shortUrl.shortUrl; - const targetUrl = `${this.settings.settings.api.redirector}/${shortUrl}`; + const targetUrl = `${this.config.settings.api.redirector}/${shortUrl}`; this.qrCodeDialog.qrCode = new QrCodeWithLogo({ content: targetUrl, @@ -206,12 +207,12 @@ export class ShortUrlsPage } open(url: string) { - window.open(`${this.settings.settings.api.redirector}/${url}`, '_blank'); + window.open(`${this.config.settings.api.redirector}/${url}`, '_blank'); } copy(val: string) { navigator.clipboard - .writeText(`${this.settings.settings.api.redirector}/${val}`) + .writeText(`${this.config.settings.api.redirector}/${val}`) .then(() => { this.toast.info('common.copied', 'common.copied_to_clipboard'); }); diff --git a/web/src/app/modules/shared/components/table/column-selector/column-selector.component.html b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.html new file mode 100644 index 0000000..f86d73a --- /dev/null +++ b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.html @@ -0,0 +1,20 @@ + + + + +
+
+ + + {{ column.translationKey | translate }} +
+
+
diff --git a/web/src/app/modules/shared/components/table/column-selector/column-selector.component.scss b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/shared/components/table/column-selector/column-selector.component.spec.ts b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.spec.ts new file mode 100644 index 0000000..e391b48 --- /dev/null +++ b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ColumnSelectorComponent } from 'src/app/modules/shared/components/table/column-selector/column-selector.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +describe('ColumnSelectorComponent', () => { + let component: ColumnSelectorComponent; + let fixture: ComponentFixture>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ColumnSelectorComponent], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ColumnSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/shared/components/table/column-selector/column-selector.component.ts b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.ts new file mode 100644 index 0000000..1cfc6d6 --- /dev/null +++ b/web/src/app/modules/shared/components/table/column-selector/column-selector.component.ts @@ -0,0 +1,36 @@ +import { + Component, + EventEmitter, + Input, + OnDestroy, + Output, +} from '@angular/core'; +import { TableColumn } from 'src/app/modules/shared/components/table/table.model'; +import { GuiService } from 'src/app/service/gui.service'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'app-column-selector', + templateUrl: './column-selector.component.html', + styleUrls: ['./column-selector.component.scss'], +}) +export class ColumnSelectorComponent implements OnDestroy { + @Input() columns: TableColumn[] = []; + @Output() selectChange = new EventEmitter(); + + protected theme = ''; + private unsubscribe$ = new EventEmitter(); + + constructor(private guiService: GuiService) { + this.guiService.theme$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(theme => { + this.theme = theme; + }); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } +} diff --git a/web/src/app/modules/shared/components/table/table.component.html b/web/src/app/modules/shared/components/table/table.component.html index 02fa31f..2ac8766 100644 --- a/web/src/app/modules/shared/components/table/table.component.html +++ b/web/src/app/modules/shared/components/table/table.component.html @@ -1,121 +1,147 @@ - -
-
-
-
- {{ skip > 0 ? skip : 1 }} {{ 'table.to' | translate }} - {{ skip + rows.length }} {{ 'table.of' | translate }} - {{ totalCount }} - - {{ countHeaderTranslation | translate }} -
-
-
-
- - + +
+
+
+
+ {{ skip + 1 }} {{ 'table.to' | translate }} + {{ skip + rows.length }} {{ 'table.of' | translate }} + {{ totalCount }} + + {{ countHeaderTranslation | translate }} +
+
+
+
+ + + - - -
+ (click)="toggleShowDeleted()"> + + + +
+
+ + + + + +
+ {{ column.translationKey | translate }} +
-
+ + + - - - -
- {{ column.label | translate }} - -
- - - - - - -
- - - - - -
- - - -
- - - - - - {{ - r.value | customDate: 'dd.MM.yyyy HH:mm:ss' - }} - + + +
+ + + + + +
+ + + +
+ + + + + + + {{ r.value | customDate: 'dd.MM.yyyy HH:mm:ss' }} + + @@ -125,56 +151,56 @@ - + {{ r.value | protect }} - + - {{ r.value }} - - - - - + - - - - - - - - - - - {{ 'table.no_entries_found' | translate }} - - - - + + + + + + + + + + + {{ 'table.no_entries_found' | translate }} + + + + diff --git a/web/src/app/modules/shared/components/table/table.component.spec.ts b/web/src/app/modules/shared/components/table/table.component.spec.ts index cd31ef8..daf61e1 100644 --- a/web/src/app/modules/shared/components/table/table.component.spec.ts +++ b/web/src/app/modules/shared/components/table/table.component.spec.ts @@ -1,17 +1,17 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TableComponent } from "./table.component"; -import { SharedModule } from "src/app/modules/shared/shared.module"; -import { TranslateModule } from "@ngx-translate/core"; -import { AuthService } from "src/app/service/auth.service"; -import { KeycloakService } from "keycloak-angular"; -import { ErrorHandlingService } from "src/app/service/error-handling.service"; -import { ToastService } from "src/app/service/toast.service"; -import { ConfirmationService, MessageService } from "primeng/api"; -import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; +import { TableComponent } from './table.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; -describe("TableComponent", () => { +describe('TableComponent', () => { let component: TableComponent; let fixture: ComponentFixture>; @@ -40,7 +40,7 @@ describe("TableComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/modules/shared/components/table/table.component.ts b/web/src/app/modules/shared/components/table/table.component.ts index 11623d0..b5b89f6 100644 --- a/web/src/app/modules/shared/components/table/table.component.ts +++ b/web/src/app/modules/shared/components/table/table.component.ts @@ -1,44 +1,38 @@ import { Component, ContentChild, - Directive, EventEmitter, Input, OnInit, Output, TemplateRef, -} from "@angular/core"; +} from '@angular/core'; import { - FilterMode, ResolvedTableColumn, TableColumn, TableRequireAnyPermissions, -} from "src/app/modules/shared/components/table/table.model"; -import { TableLazyLoadEvent } from "primeng/table"; -import { RowsPerPageOption } from "src/app/service/filter.service"; -import { Filter } from "src/app/model/graphql/filter/filter.model"; -import { Sort, SortOrder } from "src/app/model/graphql/filter/sort.model"; -import { FormControl, FormGroup } from "@angular/forms"; -import { debounceTime, Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; -import { Logger } from "src/app/service/logger.service"; -import { AuthService } from "src/app/service/auth.service"; -import { ToastService } from "src/app/service/toast.service"; +} from 'src/app/modules/shared/components/table/table.model'; +import { TableLazyLoadEvent } from 'primeng/table'; +import { RowsPerPageOption } from 'src/app/service/filter.service'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort, SortOrder } from 'src/app/model/graphql/filter/sort.model'; +import { FormControl, FormGroup } from '@angular/forms'; +import { debounceTime, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { Logger } from 'src/app/service/logger.service'; +import { AuthService } from 'src/app/service/auth.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { + CustomActionsDirective, + CustomRowActionsDirective, +} from 'src/app/modules/shared/components/table/table'; -const logger = new Logger("TableComponent"); - -@Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: "[customAction]", -}) -export class CustomActionDirective { - constructor(public templateRef: TemplateRef) {} -} +const logger = new Logger('TableComponent'); @Component({ - selector: "app-table", - templateUrl: "./table.component.html", - styleUrl: "./table.component.scss", + selector: 'app-table', + templateUrl: './table.component.html', + styleUrl: './table.component.scss', }) export class TableComponent implements OnInit { private _rows: T[] = []; @@ -52,6 +46,11 @@ export class TableComponent implements OnInit { } @Input({ required: true }) columns: TableColumn[] = []; + + get visibleColumns() { + return this.columns.filter(x => x.visible); + } + @Input({ required: true }) rowsPerPageOptions: RowsPerPageOption[] = []; @Input({ required: true }) countHeaderTranslation!: string; @Input({ required: true }) requireAnyPermissions!: TableRequireAnyPermissions; @@ -75,20 +74,24 @@ export class TableComponent implements OnInit { @Output() load = new EventEmitter(); @Input() loading = true; - @Input() dataKey = "id"; - @Input() responsiveLayout: "stack" | "scroll" = "stack"; + @Input() dataKey = 'id'; + @Input() responsiveLayout: 'stack' | 'scroll' = 'stack'; + @Input() selectableColumns = true; @Input() create = false; @Input() update = false; - @Input() createBaseUrl = ""; - @Input() updateBaseUrl = ""; + @Input() createBaseUrl = ''; + @Input() updateBaseUrl = ''; @Output() delete: EventEmitter = new EventEmitter(); @Output() restore: EventEmitter = new EventEmitter(); - @ContentChild(CustomActionDirective, { read: TemplateRef }) + @ContentChild(CustomActionsDirective, { read: TemplateRef }) customActions!: TemplateRef; + @ContentChild(CustomRowActionsDirective, { read: TemplateRef }) + customRowActions!: TemplateRef; + protected resolvedColumns: ResolvedTableColumn[][] = []; protected filterForm!: FormGroup; protected defaultFilterForm!: FormGroup; @@ -97,12 +100,12 @@ export class TableComponent implements OnInit { get showDeleted() { return !this.filter.some( - (f) => JSON.stringify(f) === JSON.stringify(this.hide_deleted_filter), + f => JSON.stringify(f) === JSON.stringify(this.hide_deleted_filter) ); } get showFilters() { - return this.columns.some((x) => x.filterable); + return this.columns.some(x => x.filterable); } protected unsubscriber$ = new Subject(); @@ -117,25 +120,25 @@ export class TableComponent implements OnInit { constructor( private auth: AuthService, - private toast: ToastService, + private toast: ToastService ) {} async ngOnInit() { this.hasPermissions = { read: await this.auth.hasAnyPermissionLazy( - this.requireAnyPermissions.read ?? [], + this.requireAnyPermissions.read ?? [] ), create: await this.auth.hasAnyPermissionLazy( - this.requireAnyPermissions.create ?? [], + this.requireAnyPermissions.create ?? [] ), update: await this.auth.hasAnyPermissionLazy( - this.requireAnyPermissions.update ?? [], + this.requireAnyPermissions.update ?? [] ), delete: await this.auth.hasAnyPermissionLazy( - this.requireAnyPermissions.delete ?? [], + this.requireAnyPermissions.delete ?? [] ), restore: await this.auth.hasAnyPermissionLazy( - this.requireAnyPermissions.restore ?? [], + this.requireAnyPermissions.restore ?? [] ), }; @@ -152,9 +155,19 @@ export class TableComponent implements OnInit { } const resolvedColumns: ResolvedTableColumn[][] = []; - this.rows.forEach((row) => { + + const columns = this.columns + .map(x => { + if (x.visible === undefined || x.visible === null) { + x.visible = true; + } + return x; + }) + .filter(x => !x.hidden && x.visible === true); + + this.rows.forEach(row => { const resolvedRow: ResolvedTableColumn[] = []; - this.columns.forEach((column) => { + columns.forEach(column => { resolvedRow.push({ value: column.value(row), data: row, @@ -168,7 +181,7 @@ export class TableComponent implements OnInit { } loadData(event: TableLazyLoadEvent) { - logger.trace("lazy load event", event); + logger.trace('lazy load event', event); const { rows, first, sortField, sortOrder } = event; const reload = @@ -193,9 +206,9 @@ export class TableComponent implements OnInit { ) { if (sortField instanceof Array) { this.sortChange.emit( - sortField.map((x) => ({ + sortField.map(x => ({ [x]: sortOrder === 1 ? SortOrder.ASC : SortOrder.DESC, - })), + })) ); } else { this.sortChange.emit([ @@ -232,17 +245,17 @@ export class TableComponent implements OnInit { buildDefaultFilterForm() { this.defaultFilterForm = new FormGroup({}); this.columns - .filter((x) => x.filterable) - .forEach((x) => { + .filter(x => x.filterable) + .forEach(x => { let control!: FormControl; - if (x.type === "text") { + if (x.type === 'text') { control = new FormControl(undefined); - } else if (x.type === "number") { + } else if (x.type === 'number') { control = new FormControl(undefined); - } else if (x.type === "bool") { + } else if (x.type === 'bool') { control = new FormControl(undefined); - } else if (x.type === "date") { + } else if (x.type === 'date') { control = new FormControl(undefined); } else { control = new FormControl(undefined); @@ -252,14 +265,14 @@ export class TableComponent implements OnInit { } setDefaultFilterForm() { - this.defaultFilter.forEach((x) => { - Object.keys(x).forEach((key) => { + this.defaultFilter.forEach(x => { + Object.keys(x).forEach(key => { const value = x[key]; if (!(key in this.defaultFilterForm.controls)) { return; } - if (typeof value === "object" && value !== null) { - Object.keys(value).forEach((subKey) => { + if (typeof value === 'object' && value !== null) { + Object.keys(value).forEach(subKey => { this.defaultFilterForm.get([key])?.setValue(value[subKey]); }); } else { @@ -274,41 +287,38 @@ export class TableComponent implements OnInit { this.filterForm.valueChanges .pipe(takeUntil(this.unsubscriber$), debounceTime(200)) - .subscribe((changes) => { - logger.trace("Filter input", changes); + .subscribe(changes => { + logger.trace('Filter input', changes); if (this.filterForm.disabled) { return; } const filter = Object.keys(changes) .filter( - (key) => + key => changes[key] !== undefined && changes[key] !== null && - changes[key] !== "", + changes[key] !== '' ) - .map((key) => { - const column = this.columns.find((x) => x.name === key); + .map(key => { + const column = this.columns.find(x => x.name === key); if (!column || !column.filterable) { return {}; } let value = changes[key]; - let defaultFilterMode: FilterMode = "contains"; + let defaultFilterMode = 'contains'; switch (column.type) { - case "number": - defaultFilterMode = "equal"; + case 'number': + defaultFilterMode = 'equal'; value = +value; break; - case "bool": - defaultFilterMode = "equal"; + case 'bool': + defaultFilterMode = 'equal'; } const filterMode = column.filterMode || defaultFilterMode; - if (column.filterFactory) { - return column.filterFactory(key, filterMode, value); - } return { [key]: { [filterMode]: value } }; }); @@ -324,7 +334,7 @@ export class TableComponent implements OnInit { copy(val: T | T[keyof T] | string) { navigator.clipboard.writeText(val as string).then(() => { - this.toast.info("common.copied", "common.copied_to_clipboard"); + this.toast.info('common.copied', 'common.copied_to_clipboard'); }); } } diff --git a/web/src/app/modules/shared/components/table/table.model.ts b/web/src/app/modules/shared/components/table/table.model.ts index d9d3459..a0b3f1b 100644 --- a/web/src/app/modules/shared/components/table/table.model.ts +++ b/web/src/app/modules/shared/components/table/table.model.ts @@ -1,24 +1,24 @@ -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { Filter } from "src/app/model/graphql/filter/filter.model"; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; export type TableColumnValue = T | T[keyof T] | string; -export type FilterMode = "contains" | "startsWith" | "endsWith" | "equal"; export interface TableColumn { - name: string; - label: string; + name: string; // internal name + translationKey: string; // translation key value: (row: T) => TableColumnValue; type?: string; - hidden?: boolean; + visible?: boolean; // toggle column visibility + hidden?: boolean; // dont even render column sortable?: boolean; filterable?: boolean; - filterMode?: FilterMode; - filterFactory?: (key: string, mode: FilterMode, value: T) => Filter; + filterMode?: 'contains' | 'startsWith' | 'endsWith' | 'equals'; sort?: (a: T, b: T) => number; filter?: (row: T, filter: string) => boolean; + minWidth?: string; width?: string; + maxWidth?: string; class?: string; } diff --git a/web/src/app/modules/shared/components/table/table.ts b/web/src/app/modules/shared/components/table/table.ts new file mode 100644 index 0000000..dadbab5 --- /dev/null +++ b/web/src/app/modules/shared/components/table/table.ts @@ -0,0 +1,17 @@ +import { Directive, TemplateRef } from '@angular/core'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[customActions]', +}) +export class CustomActionsDirective { + constructor(public templateRef: TemplateRef) {} +} + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[customRowActions]', +}) +export class CustomRowActionsDirective { + constructor(public templateRef: TemplateRef) {} +} diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts index ed6dfdd..02be5de 100644 --- a/web/src/app/modules/shared/shared.module.ts +++ b/web/src/app/modules/shared/shared.module.ts @@ -43,10 +43,7 @@ import { TriStateCheckboxModule } from 'primeng/tristatecheckbox'; import { InputTextareaModule } from 'primeng/inputtextarea'; import { MenuBarComponent } from 'src/app/modules/shared/components/menu-bar/menu-bar.component'; import { SideMenuComponent } from './components/side-menu/side-menu.component'; -import { - CustomActionDirective, - TableComponent, -} from './components/table/table.component'; +import { TableComponent } from './components/table/table.component'; import { BoolPipe } from 'src/app/modules/shared/pipes/bool.pipe'; import { CustomDatePipe } from 'src/app/modules/shared/pipes/customDate.pipe'; import { FormPageComponent } from 'src/app/modules/shared/components/slidein/form-page.component'; @@ -57,10 +54,21 @@ import { import { ProtectPipe } from './pipes/protect.pipe'; import { provideApollo } from 'apollo-angular'; import { HttpLink } from 'apollo-angular/http'; -import { InMemoryCache } from '@apollo/client/core'; +import { InMemoryCache, split } from '@apollo/client/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { tokenInterceptor } from 'src/app/core/token.interceptor'; -import { SettingsService } from 'src/app/service/settings.service'; +import { + CustomActionsDirective, + CustomRowActionsDirective, +} from 'src/app/modules/shared/components/table/table'; +import { ColumnSelectorComponent } from 'src/app/modules/shared/components/table/column-selector/column-selector.component'; +import { Kind, OperationTypeNode } from 'graphql'; +import { createClient } from 'graphql-ws'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { ConfigService } from 'src/app/service/config.service'; +import { Logger } from 'src/app/service/logger.service'; +import { Router } from '@angular/router'; import { SliderModule } from 'primeng/slider'; const sharedModules = [ @@ -119,21 +127,83 @@ const sharedComponents = [ FormPageComponent, FormPageHeaderDirective, FormPageContentDirective, - CustomActionDirective, + ColumnSelectorComponent, + CustomActionsDirective, + CustomRowActionsDirective, + TableComponent, + ProtectPipe, ]; +function debounce(func: (...args: unknown[]) => void, wait: number) { + let timeout: ReturnType; + return (...args: unknown[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + @NgModule({ - declarations: [...sharedComponents, TableComponent, ProtectPipe], + declarations: [...sharedComponents], imports: [CommonModule, ...sharedModules], - exports: [...sharedModules, ...sharedComponents, TableComponent], + exports: [...sharedModules, ...sharedComponents], providers: [ provideHttpClient(withInterceptors([tokenInterceptor])), + provideApollo(() => { + const logger = new Logger('graphql'); + + const settings = inject(ConfigService); const httpLink = inject(HttpLink); - const settings = inject(SettingsService); + const router = inject(Router); + + let isConnected = false; + + const http = httpLink.create({ + uri: `${settings.settings.api.url}/graphql`, + }); + + const ws = new GraphQLWsLink( + createClient({ + url: `${settings.settings.api.wsUrl}/graphql`, + retryAttempts: Infinity, + shouldRetry: () => true, + keepAlive: 10000, + connectionParams: () => ({ + authToken: localStorage.getItem('token'), + }), + on: { + connected: () => { + logger.info('WebSocket connected'); + isConnected = true; + }, + closed: debounce(async () => { + if (!isConnected) { + logger.error('WebSocket disconnected'); + await router.navigate(['/error/unavailable']); + } + isConnected = false; + }, 5000), // 3 seconds debounce + }, + }) + ); + + // Using the ability to split links, you can send data to each link + // depending on what kind of operation is being sent + const link = split( + // Split based on operation type + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === Kind.OPERATION_DEFINITION && + definition.operation === OperationTypeNode.SUBSCRIPTION + ); + }, + ws, + http + ); return { - link: httpLink.create({ uri: `${settings.settings.api.url}/graphql` }), + link: link, cache: new InMemoryCache(), defaultOptions: { diff --git a/web/src/app/service/config.service.ts b/web/src/app/service/config.service.ts new file mode 100644 index 0000000..2a853ed --- /dev/null +++ b/web/src/app/service/config.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AppSettings } from 'src/app/model/config/app-settings'; +import { HttpClient } from '@angular/common/http'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ConfigService { + settings: AppSettings = { + termsUrl: '', + privacyURL: '', + imprintURL: '', + themes: [ + { + label: 'Maxlan', + name: 'maxlan', + }, + ], + loadingScreen: { + background: '', + logo: '', + }, + api: { + url: '', + wsUrl: '', + redirector: '', + }, + keycloak: { + url: '', + realm: '', + clientId: '', + }, + }; + + constructor(private http: HttpClient) {} + + loadSettings(): Promise { + return new Promise((resolve, reject) => { + this.http + .get(`/assets/config/${environment.config}`) + .pipe( + catchError(error => { + reject(error); + return throwError(() => error); + }) + ) + .subscribe(settings => { + this.settings = settings; + if (this.settings.themes.length === 0) { + this.settings.themes.push({ + label: 'Maxlan', + name: 'maxlan', + }); + } + resolve(); + }); + }); + } +} diff --git a/web/src/app/service/gui.service.ts b/web/src/app/service/gui.service.ts index bc67b06..10fae79 100644 --- a/web/src/app/service/gui.service.ts +++ b/web/src/app/service/gui.service.ts @@ -3,7 +3,7 @@ import { BehaviorSubject, filter } from 'rxjs'; import { Logger } from 'src/app/service/logger.service'; import { NavigationEnd, Router } from '@angular/router'; import { SidebarService } from 'src/app/service/sidebar.service'; -import { SettingsService } from 'src/app/service/settings.service'; +import { ConfigService } from 'src/app/service/config.service'; const logger = new Logger('GuiService'); @@ -15,14 +15,12 @@ export class GuiService { isTablet$ = new BehaviorSubject(this.isTabletByWindowWith()); hideGui$ = new BehaviorSubject(false); - theme$ = new BehaviorSubject( - this.settingsService.settings.themes[0].name - ); + theme$ = new BehaviorSubject(this.config.settings.themes[0].name); constructor( private router: Router, private sidebarService: SidebarService, - private settingsService: SettingsService + private config: ConfigService ) { this.router.events .pipe(filter(event => event instanceof NavigationEnd)) diff --git a/web/src/app/service/settings.service.ts b/web/src/app/service/settings.service.ts index f921396..dc25e41 100644 --- a/web/src/app/service/settings.service.ts +++ b/web/src/app/service/settings.service.ts @@ -1,36 +1,94 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { AppSettings } from 'src/app/model/config/app-settings'; -import { environment } from 'src/environments/environment'; +import { catchError, map } from 'rxjs/operators'; +import { Apollo, gql } from 'apollo-angular'; +import { Logger } from 'src/app/service/logger.service'; +import { Setting } from 'src/app/model/entities/setting'; + +const log = new Logger('UserSettings'); @Injectable({ providedIn: 'root', }) export class SettingsService { - settings!: AppSettings; + constructor(private apollo: Apollo) {} - constructor(private http: HttpClient) {} - - loadSettings(): Promise { + get(key: string): Promise { + log.debug('get', key); return new Promise((resolve, reject) => { - this.http - .get(`/assets/config/${environment.config}`) + this.apollo + .query<{ settings: Setting[] }>({ + query: gql` + query getSetting($key: String!) { + settings(key: $key) { + key + value + } + } + `, + variables: { + key, + }, + }) .pipe( - catchError(error => { - reject(error); - return throwError(() => error); + catchError(err => { + throw err; }) ) - .subscribe(settings => { - this.settings = settings; - if (this.settings.themes.length === 0) { - this.settings.themes.push({ - label: 'Open-redirect', - name: 'open-redirect', - }); - } + .pipe( + map(result => + result.data.settings.length > 0 + ? result.data.settings[0] + : undefined + ) + ) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(setting => { + resolve(setting?.value); + }); + }); + } + + set(key: string, value: string): Promise { + log.debug('set', key, value); + return new Promise((resolve, reject) => { + this.apollo + .mutate<{ setting: { change: Setting } }>({ + mutation: gql` + mutation changeSetting($input: SettingInput!) { + setting { + change(input: $input) { + key + value + } + } + } + `, + variables: { + input: { + key: key, + value: value, + }, + }, + }) + .pipe( + catchError(err => { + throw err; + }) + ) + .pipe(map(result => result.data?.setting.change)) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(() => { resolve(); }); }); diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts index c92b12c..f17e185 100644 --- a/web/src/app/service/sidebar.service.ts +++ b/web/src/app/service/sidebar.service.ts @@ -3,6 +3,7 @@ import { BehaviorSubject } from 'rxjs'; import { MenuElement } from 'src/app/model/view/menu-element'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { Router } from '@angular/router'; @Injectable({ providedIn: 'root', @@ -11,7 +12,18 @@ export class SidebarService { visible$ = new BehaviorSubject(true); elements$ = new BehaviorSubject([]); - constructor(private auth: AuthService) { + constructor( + private router: Router, + private auth: AuthService + ) { + this.router.events.subscribe(() => { + if (this.router.url.startsWith('/admin')) { + this.show(); + } else { + this.hide(); + } + }); + this.auth.user$.subscribe(user => { if (user) { this.setElements().then(); @@ -93,6 +105,22 @@ export class SidebarService { PermissionsEnum.apiKeys, ]), }, + { + label: 'sidebar.feature_flags', + icon: 'pi pi-flag', + routerLink: ['/admin/administration/feature-flags'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.administrator, + ]), + }, + { + label: 'sidebar.settings', + icon: 'pi pi-cog', + routerLink: ['/admin/administration/settings'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.administrator, + ]), + }, ], }; } diff --git a/web/src/assets/config/config.json b/web/src/assets/config/config.json index fef294d..a9090ca 100644 --- a/web/src/assets/config/config.json +++ b/web/src/assets/config/config.json @@ -14,6 +14,7 @@ ], "api": { "url": "", + "wsUrl": "", "redirector": "" }, "keycloak": {