diff --git a/.gitea/workflows/build dev.yaml b/.gitea/workflows/build dev.yaml index 1ab0ca0..a0a930e 100644 --- a/.gitea/workflows/build dev.yaml +++ b/.gitea/workflows/build dev.yaml @@ -1,13 +1,48 @@ -name: Build dev on push -run-name: Build dev on push +name: Build on push +run-name: Build on push on: push: branches: - dev jobs: + prepare: + 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: Get Date and Build Number + run: | + git fetch --tags + git tag + DATE=$(date +'%Y.%m.%d') + TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l) + BUILD_NUMBER=$(($TAG_COUNT + 1)) + BUILD_VERSION="${DATE}.${BUILD_NUMBER}-dev" + echo "$BUILD_VERSION" > version.txt + echo "VERSION $BUILD_VERSION" + + - name: Create Git Tag for Build + run: | + git config user.name "ci" + git config user.email "dev@sh-edraft.de" + echo "tag $(cat version.txt)" + git tag $(cat version.txt) + git push origin --tags + + - name: Upload build version artifact + uses: actions/upload-artifact@v3 + with: + name: version + path: version.txt + build-api: runs-on: [runner] + needs: prepare container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: - name: Clone Repository @@ -15,10 +50,16 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Build docker run: | cd api - docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api-dev:$(cat ../version.txt) . + echo "VERSION = \"$(cat version.txt)\"" > version.py + docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 @@ -29,10 +70,11 @@ jobs: - name: Push image run: | - docker push git.sh-edraft.de/sh-edraft.de/open-redirect-api-dev:$(cat version.txt) - + docker push git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat version.txt) + build-redirector: runs-on: [runner] + needs: prepare container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: - name: Clone Repository @@ -40,10 +82,15 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Build docker run: | cd api - docker build -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector-dev:$(cat ../version.txt) . + docker build --no-cache -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 @@ -54,10 +101,11 @@ jobs: - name: Push image run: | - docker push git.sh-edraft.de/sh-edraft.de/open-redirect-redirector-dev:$(cat version.txt) + docker push git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat version.txt) build-web: runs-on: [runner] + needs: prepare container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: - name: Clone Repository @@ -65,6 +113,11 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Prepare web build run: | cd web @@ -78,7 +131,7 @@ jobs: - name: Build docker run: | cd web - docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-web-dev:$(cat ../version.txt) . + docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 @@ -89,4 +142,4 @@ jobs: - name: Push image run: | - docker push git.sh-edraft.de/sh-edraft.de/open-redirect-web-dev:$(cat version.txt) + docker push git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat version.txt) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 1d48b26..1e51a43 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -6,7 +6,7 @@ on: - master jobs: - build-api: + prepare: runs-on: [runner] container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: @@ -15,10 +15,51 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Get Date and Build Number + run: | + git fetch + git tag + DATE=$(date +'%Y.%m.%d') + TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l) + BUILD_NUMBER=$(($TAG_COUNT + 1)) + BUILD_VERSION="${DATE}.${BUILD_NUMBER}" + echo "$BUILD_VERSION" > version.txt + echo "VERSION $BUILD_VERSION" + + - name: Create Git Tag for Build + run: | + git config user.name "ci" + git config user.email "dev@sh-edraft.de" + echo "tag $(cat version.txt)" + git tag $(cat version.txt) + git push origin --tags + + - name: Upload build version artifact + uses: actions/upload-artifact@v3 + with: + name: version + path: version.txt + + build-api: + runs-on: [runner] + needs: prepare + 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: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Build docker run: | cd api - docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) . + echo "VERSION = \"$(cat version.txt)\"" > version.py + docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 @@ -30,9 +71,10 @@ jobs: - name: Push image run: | docker push git.sh-edraft.de/sh-edraft.de/open-redirect-api:$(cat version.txt) - + build-redirector: runs-on: [runner] + needs: prepare container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: - name: Clone Repository @@ -40,10 +82,15 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Build docker run: | cd api - docker build -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat ../version.txt) . + docker build --no-cache -f dockerfile_redirector -t git.sh-edraft.de/sh-edraft.de/open-redirect-redirector:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 @@ -58,6 +105,7 @@ jobs: build-web: runs-on: [runner] + needs: prepare container: git.sh-edraft.de/sh-edraft.de/act-runner:latest steps: - name: Clone Repository @@ -65,6 +113,11 @@ jobs: with: token: ${{ secrets.CI_ACCESS_TOKEN }} + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + - name: Prepare web build run: | cd web @@ -78,7 +131,7 @@ jobs: - name: Build docker run: | cd web - docker build -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) . + docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) . - name: Login to registry git.sh-edraft.de uses: https://github.com/docker/login-action@v1 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..88df780 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,10 +1,13 @@ 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 +Jinja2==3.1.5 +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..a524453 --- /dev/null +++ b/api/src/api/middleware/request.py @@ -0,0 +1,28 @@ +from contextvars import ContextVar +from typing import Optional, Union + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.websockets import WebSocket + +_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 set_request(request: Union[Request, WebSocket, None]): + _request_context.set(request) + + +def get_request() -> Optional[Request]: + return _request_context.get() diff --git a/api/src/api/middleware/websocket.py b/api/src/api/middleware/websocket.py new file mode 100644 index 0000000..0d8683f --- /dev/null +++ b/api/src/api/middleware/websocket.py @@ -0,0 +1,41 @@ +from uuid import uuid4 + +from ariadne.asgi.handlers import GraphQLTransportWSHandler +from starlette.datastructures import MutableHeaders +from starlette.websockets import WebSocket + +from api.middleware.request import set_request +from core.logger import APILogger + +logger = APILogger("api.ws") + + +class AuthenticatedGraphQLTransportWSHandler(GraphQLTransportWSHandler): + + def __init__(self, *args, **kwargs): + super().__init__( + *args, + on_connect=self.on_connect, + on_disconnect=self.on_disconnect, + **kwargs, + ) + + @staticmethod + async def on_connect(ws: WebSocket, message: dict): + ws.state.request_id = uuid4() + logger.info(f"WebSocket connection {ws.state.request_id}") + + if "Authorization" not in message: + return True + + mutable_headers = MutableHeaders() + mutable_headers["Authorization"] = message.get("Authorization", "") + ws._headers = mutable_headers + + set_request(ws) + return True + + @staticmethod + async def on_disconnect(ws: WebSocket): + logger.debug(f"WebSocket connection {ws.state.request_id} closed") + return True diff --git a/api/src/api/route.py b/api/src/api/route.py index 007eeff..40edd8b 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,11 +35,13 @@ 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" @@ -49,11 +51,15 @@ class Route(RouteUserExtension): @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 @@ -62,14 +68,22 @@ class Route(RouteUserExtension): async def get_authenticated_user_or_api_key_or_default( 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 @@ -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 acfca18..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,10 +46,11 @@ 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 @@ -56,8 +58,12 @@ class RouteUserExtension: @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 @@ -86,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 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/mutation_abc.py b/api/src/api_graphql/abc/mutation_abc.py index 51ed410..5768935 100644 --- a/api/src/api_graphql/abc/mutation_abc.py +++ b/api/src/api_graphql/abc/mutation_abc.py @@ -16,7 +16,7 @@ class MutationABC(QueryABC): self, name: str, mutation_name: str, - require_any_permission: list[Permissions] = None, + require_any_permission=None, public: bool = False, ): """ @@ -27,6 +27,8 @@ class MutationABC(QueryABC): :param bool public: Define if the field can resolve without authentication :return: """ + if require_any_permission is None: + require_any_permission = [] from api_graphql.definition import QUERIES self.field( diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index ecb7108..bafce27 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(): @@ -67,6 +71,8 @@ class QueryABC(ObjectType): *args, **kwargs, ): + info = args[0] + if len(permissions) > 0: user = await Route.get_authenticated_user_or_api_key_or_default() if user is not None and all( @@ -132,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): @@ -169,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 @@ -203,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}") @@ -220,7 +239,12 @@ class QueryABC(ObjectType): 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 @@ -250,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) @@ -271,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): 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..073142f --- /dev/null +++ b/api/src/api_graphql/abc/subscription_abc.py @@ -0,0 +1,51 @@ +from abc import abstractmethod +from asyncio import iscoroutinefunction + +from ariadne import SubscriptionType + +from api.middleware.request import get_request +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/domain_filter.py b/api/src/api_graphql/filter/domain_filter.py new file mode 100644 index 0000000..ba1240b --- /dev/null +++ b/api/src/api_graphql/filter/domain_filter.py @@ -0,0 +1,13 @@ +from api_graphql.abc.db_model_filter_abc import DbModelFilterABC +from api_graphql.abc.filter.string_filter import StringFilter + + +class DomainFilter(DbModelFilterABC): + def __init__( + self, + obj: dict, + ): + DbModelFilterABC.__init__(self, obj) + + self.add_field("name", StringFilter) + self.add_field("description", StringFilter) 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 new file mode 100644 index 0000000..3783c83 --- /dev/null +++ b/api/src/api_graphql/graphql/domain.gql @@ -0,0 +1,65 @@ +type DomainResult { + totalCount: Int + count: Int + nodes: [Domain] +} + +type Domain implements DbModel { + id: ID + name: String + + shortUrls: [ShortUrl] + + deleted: Boolean + editor: User + createdUtc: String + updatedUtc: String +} + +input DomainSort { + id: SortOrder + name: SortOrder + + deleted: SortOrder + editorId: SortOrder + createdUtc: SortOrder + 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 + updatedUtc: DateFilter +} + +type DomainMutation { + create(input: DomainCreateInput!): Domain + update(input: DomainUpdateInput!): Domain + delete(id: ID!): Boolean + restore(id: ID!): Boolean +} + +input DomainCreateInput { + name: String! +} + +input DomainUpdateInput { + id: ID! + name: String +} \ No newline at end of file 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 4a3880c..4d62320 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -9,6 +9,7 @@ type Group implements DbModel { name: String shortUrls: [ShortUrl] + roles: [Role] deleted: Boolean editor: User @@ -26,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 @@ -45,9 +58,11 @@ type GroupMutation { input GroupCreateInput { name: String! + roles: [ID] } input GroupUpdateInput { id: ID! name: String + roles: [ID] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index 3b93c89..ad67439 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -5,5 +5,10 @@ type Mutation { role: RoleMutation group: GroupMutation + domain: DomainMutation shortUrl: ShortUrlMutation + + setting: SettingMutation + userSetting: UserSettingMutation + featureFlag: FeatureFlagMutation } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 5e086db..2aabbcd 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -11,6 +11,11 @@ type Query { userHasAnyPermission(permissions: [String]!): Boolean notExistingUsersFromKeycloak: KeycloakUserResult + 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 ce8f9d8..1ab6144 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -11,6 +11,7 @@ type ShortUrl implements DbModel { description: String visits: Int group: Group + domain: Domain loadingScreen: Boolean deleted: Boolean @@ -31,12 +32,27 @@ input ShortUrlSort { updatedUtc: SortOrder } +enum ShortUrlFuzzyFields { + shortUrl + targetUrl + description +} + +input ShortUrlFuzzy { + fields: [ShortUrlFuzzyFields] + term: String + threshold: Int +} + input ShortUrlFilter { id: IntFilter - name: StringFilter + shortUrl: StringFilter + targetUrl: StringFilter description: StringFilter loadingScreen: BooleanFilter + fuzzy: ShortUrlFuzzy + deleted: BooleanFilter editor: IntFilter createdUtc: DateFilter @@ -48,6 +64,7 @@ type ShortUrlMutation { update(input: ShortUrlUpdateInput!): ShortUrl delete(id: ID!): Boolean restore(id: ID!): Boolean + trackVisit(id: ID!, agent: String): Boolean } input ShortUrlCreateInput { @@ -55,6 +72,7 @@ input ShortUrlCreateInput { targetUrl: String! description: String groupId: ID + domainId: ID loadingScreen: Boolean } @@ -64,5 +82,6 @@ input ShortUrlUpdateInput { targetUrl: String description: String groupId: ID + domainId: ID loadingScreen: Boolean } 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/input/domain_create_input.py b/api/src/api_graphql/input/domain_create_input.py new file mode 100644 index 0000000..e9aef8e --- /dev/null +++ b/api/src/api_graphql/input/domain_create_input.py @@ -0,0 +1,13 @@ +from api_graphql.abc.input_abc import InputABC + + +class DomainCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._name = self.option("name", str, required=True) + + @property + def name(self) -> str: + return self._name diff --git a/api/src/api_graphql/input/domain_update_input.py b/api/src/api_graphql/input/domain_update_input.py new file mode 100644 index 0000000..71f5684 --- /dev/null +++ b/api/src/api_graphql/input/domain_update_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class DomainUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._name = self.option("name", str) + + @property + def id(self) -> int: + return self._id + + @property + def name(self) -> str: + return self._name diff --git a/api/src/api_graphql/input/feature_flag_input.py b/api/src/api_graphql/input/feature_flag_input.py new file mode 100644 index 0000000..8985caa --- /dev/null +++ b/api/src/api_graphql/input/feature_flag_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class FeatureFlagInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", bool, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> bool: + return self._value diff --git a/api/src/api_graphql/input/group_create_input.py b/api/src/api_graphql/input/group_create_input.py index 482bc64..994224e 100644 --- a/api/src/api_graphql/input/group_create_input.py +++ b/api/src/api_graphql/input/group_create_input.py @@ -7,7 +7,12 @@ class GroupCreateInput(InputABC): InputABC.__init__(self, src) self._name = self.option("name", str, required=True) + self._roles = self.option("roles", list[int]) @property def name(self) -> str: return self._name + + @property + def roles(self) -> list[int]: + return self._roles diff --git a/api/src/api_graphql/input/group_update_input.py b/api/src/api_graphql/input/group_update_input.py index a40d665..5d740cf 100644 --- a/api/src/api_graphql/input/group_update_input.py +++ b/api/src/api_graphql/input/group_update_input.py @@ -8,6 +8,7 @@ class GroupUpdateInput(InputABC): self._id = self.option("id", int, required=True) self._name = self.option("name", str) + self._roles = self.option("roles", list[int]) @property def id(self) -> int: @@ -16,3 +17,7 @@ class GroupUpdateInput(InputABC): @property def name(self) -> str: return self._name + + @property + def roles(self) -> list[int]: + return self._roles diff --git a/api/src/api_graphql/input/setting_input.py b/api/src/api_graphql/input/setting_input.py new file mode 100644 index 0000000..7354b57 --- /dev/null +++ b/api/src/api_graphql/input/setting_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class SettingInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", str, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value diff --git a/api/src/api_graphql/input/short_url_create_input.py b/api/src/api_graphql/input/short_url_create_input.py index bb01aca..94c4b9e 100644 --- a/api/src/api_graphql/input/short_url_create_input.py +++ b/api/src/api_graphql/input/short_url_create_input.py @@ -12,6 +12,7 @@ class ShortUrlCreateInput(InputABC): self._target_url = self.option("targetUrl", str, required=True) self._description = self.option("description", str) self._group_id = self.option("groupId", int) + self._domain_id = self.option("domainId", int) self._loading_screen = self.option("loadingScreen", bool) @property @@ -30,6 +31,10 @@ class ShortUrlCreateInput(InputABC): def group_id(self) -> Optional[int]: return self._group_id + @property + def domain_id(self) -> Optional[int]: + return self._domain_id + @property def loading_screen(self) -> Optional[str]: return self._loading_screen diff --git a/api/src/api_graphql/input/short_url_update_input.py b/api/src/api_graphql/input/short_url_update_input.py index 2e4bb13..059098c 100644 --- a/api/src/api_graphql/input/short_url_update_input.py +++ b/api/src/api_graphql/input/short_url_update_input.py @@ -13,6 +13,7 @@ class ShortUrlUpdateInput(InputABC): self._target_url = self.option("targetUrl", str) self._description = self.option("description", str) self._group_id = self.option("groupId", int) + self._domain_id = self.option("domainId", int) self._loading_screen = self.option("loadingScreen", bool) @property @@ -35,6 +36,10 @@ class ShortUrlUpdateInput(InputABC): def group_id(self) -> Optional[int]: return self._group_id + @property + def domain_id(self) -> Optional[int]: + return self._domain_id + @property def loading_screen(self) -> Optional[str]: return self._loading_screen diff --git a/api/src/api_graphql/input/user_setting_input.py b/api/src/api_graphql/input/user_setting_input.py new file mode 100644 index 0000000..9d5fe09 --- /dev/null +++ b/api/src/api_graphql/input/user_setting_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSettingInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", str, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index c745ca5..d46df60 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -33,6 +33,15 @@ class Mutation(MutationABC): ], ) + self.add_mutation_type( + "domain", + "Domain", + require_any_permission=[ + Permissions.domains_create, + Permissions.domains_update, + Permissions.domains_delete, + ], + ) self.add_mutation_type( "group", "Group", @@ -51,3 +60,22 @@ class Mutation(MutationABC): Permissions.short_urls_delete, ], ) + + self.add_mutation_type( + "setting", + "Setting", + require_any_permission=[ + Permissions.settings_update, + ], + ) + self.add_mutation_type( + "userSetting", + "UserSetting", + ) + self.add_mutation_type( + "featureFlag", + "FeatureFlag", + require_any_permission=[ + Permissions.administrator, + ], + ) diff --git a/api/src/api_graphql/mutations/domain_mutation.py b/api/src/api_graphql/mutations/domain_mutation.py new file mode 100644 index 0000000..339704b --- /dev/null +++ b/api/src/api_graphql/mutations/domain_mutation.py @@ -0,0 +1,75 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.domain_create_input import DomainCreateInput +from api_graphql.input.domain_update_input import DomainUpdateInput +from api_graphql.input.group_create_input import GroupCreateInput +from api_graphql.input.group_update_input import GroupUpdateInput +from core.logger import APILogger +from data.schemas.public.domain_dao import domainDao +from data.schemas.public.group import Group +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class DomainMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "Domain") + + self.mutation( + "create", + self.resolve_create, + DomainCreateInput, + require_any_permission=[Permissions.domains_create], + ) + self.mutation( + "update", + self.resolve_update, + DomainUpdateInput, + require_any_permission=[Permissions.domains_update], + ) + self.mutation( + "delete", + self.resolve_delete, + require_any_permission=[Permissions.domains_delete], + ) + self.mutation( + "restore", + self.resolve_restore, + require_any_permission=[Permissions.domains_delete], + ) + + @staticmethod + async def resolve_create(obj: GroupCreateInput, *_): + logger.debug(f"create domain: {obj.__dict__}") + + domain = Group( + 0, + obj.name, + ) + nid = await domainDao.create(domain) + return await domainDao.get_by_id(nid) + + @staticmethod + async def resolve_update(obj: GroupUpdateInput, *_): + logger.debug(f"update domain: {input}") + + if obj.name is not None: + domain = await domainDao.get_by_id(obj.id) + domain.name = obj.name + await domainDao.update(domain) + + return await domainDao.get_by_id(obj.id) + + @staticmethod + async def resolve_delete(*_, id: str): + logger.debug(f"delete domain: {id}") + domain = await domainDao.get_by_id(id) + await domainDao.delete(domain) + return True + + @staticmethod + async def resolve_restore(*_, id: str): + logger.debug(f"restore domain: {id}") + domain = await domainDao.get_by_id(id) + await domainDao.restore(domain) + return True diff --git a/api/src/api_graphql/mutations/feature_flag_mutation.py b/api/src/api_graphql/mutations/feature_flag_mutation.py new file mode 100644 index 0000000..c940619 --- /dev/null +++ b/api/src/api_graphql/mutations/feature_flag_mutation.py @@ -0,0 +1,32 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.feature_flag_input import FeatureFlagInput +from core.logger import APILogger +from data.schemas.system.feature_flag import FeatureFlag +from data.schemas.system.feature_flag_dao import featureFlagDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class FeatureFlagMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "FeatureFlag") + self.mutation( + "change", + self.resolve_change, + FeatureFlagInput, + require_any_permission=[Permissions.administrator], + ) + + @staticmethod + async def resolve_change(obj: FeatureFlagInput, *_): + logger.debug(f"create new feature flag: {input}") + + setting = await featureFlagDao.find_single_by({FeatureFlag.key: obj.key}) + if setting is None: + raise ValueError(f"FeatureFlag {obj.key} not found") + + setting.value = obj.value + await featureFlagDao.update(setting) + + return await featureFlagDao.get_by_id(setting.id) diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index c526366..4372cb1 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -1,9 +1,13 @@ +from typing import Optional + from api_graphql.abc.mutation_abc import MutationABC from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_update_input import GroupUpdateInput from core.logger import APILogger from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao +from data.schemas.public.group_role_assignment import GroupRoleAssignment +from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao from service.permission.permissions_enum import Permissions logger = APILogger(__name__) @@ -37,25 +41,61 @@ class GroupMutation(MutationABC): ) @staticmethod - async def resolve_create(obj: GroupCreateInput, *_): + async def _handle_group_role_assignments(gid: int, roles: Optional[list[int]]): + if roles is None: + return + + existing_roles = await groupDao.get_roles(gid) + existing_role_ids = {role.id for role in existing_roles} + + new_role_ids = set(roles) + + roles_to_add = new_role_ids - existing_role_ids + roles_to_remove = existing_role_ids - new_role_ids + + if roles_to_add: + group_role_assignments = [ + GroupRoleAssignment(0, gid, role_id) for role_id in roles_to_add + ] + await groupRoleAssignmentDao.create_many(group_role_assignments) + + if roles_to_remove: + assignments_to_remove = await groupRoleAssignmentDao.find_by( + [ + {GroupRoleAssignment.group_id: gid}, + {GroupRoleAssignment.role_id: {"in": roles_to_remove}}, + ] + ) + await groupRoleAssignmentDao.delete_many(assignments_to_remove) + + @classmethod + async def resolve_create(cls, obj: GroupCreateInput, *_): logger.debug(f"create group: {obj.__dict__}") group = Group( 0, obj.name, ) - nid = await groupDao.create(group) - return await groupDao.get_by_id(nid) + gid = await groupDao.create(group) - @staticmethod - async def resolve_update(obj: GroupUpdateInput, *_): + await cls._handle_group_role_assignments(gid, obj.roles) + + return await groupDao.get_by_id(gid) + + @classmethod + async def resolve_update(cls, obj: GroupUpdateInput, *_): logger.debug(f"update group: {input}") + if await groupDao.find_by_id(obj.id) is None: + raise ValueError(f"Group with id {obj.id} not found") + if obj.name is not None: group = await groupDao.get_by_id(obj.id) group.name = obj.name await groupDao.update(group) + await cls._handle_group_role_assignments(obj.id, obj.roles) + return await groupDao.get_by_id(obj.id) @staticmethod diff --git a/api/src/api_graphql/mutations/setting_mutation.py b/api/src/api_graphql/mutations/setting_mutation.py new file mode 100644 index 0000000..4c499f3 --- /dev/null +++ b/api/src/api_graphql/mutations/setting_mutation.py @@ -0,0 +1,32 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.setting_input import SettingInput +from core.logger import APILogger +from data.schemas.system.setting import Setting +from data.schemas.system.setting_dao import settingsDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class SettingMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "Setting") + self.mutation( + "change", + self.resolve_change, + SettingInput, + require_any_permission=[Permissions.settings_update], + ) + + @staticmethod + async def resolve_change(obj: SettingInput, *_): + logger.debug(f"create new setting: {input}") + + setting = await settingsDao.find_single_by({Setting.key: obj.key}) + if setting is None: + raise ValueError(f"Setting with key {obj.key} not found") + + setting.value = obj.value + await settingsDao.update(setting) + + return await settingsDao.get_by_id(setting.id) diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py index cc67d8d..e91c0ba 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -1,12 +1,13 @@ -from werkzeug.exceptions import NotFound - from api_graphql.abc.mutation_abc import MutationABC from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput from core.logger import APILogger +from data.schemas.public.domain_dao import domainDao 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.short_url_visit import ShortUrlVisit +from data.schemas.public.short_url_visit_dao import shortUrlVisitDao from service.permission.permissions_enum import Permissions logger = APILogger(__name__) @@ -38,6 +39,11 @@ class ShortUrlMutation(MutationABC): self.resolve_restore, require_any_permission=[Permissions.short_urls_delete], ) + self.mutation( + "trackVisit", + self.resolve_track_visit, + require_any_permission=[Permissions.short_urls_update], + ) @staticmethod async def resolve_create(obj: ShortUrlCreateInput, *_): @@ -49,6 +55,7 @@ class ShortUrlMutation(MutationABC): obj.target_url, obj.description, obj.group_id, + obj.domain_id, obj.loading_screen, ) nid = await shortUrlDao.create(short_url) @@ -72,8 +79,18 @@ class ShortUrlMutation(MutationABC): if obj.group_id is not None: group_by_id = await groupDao.find_by_id(obj.group_id) if group_by_id is None: - raise NotFound(f"Group with id {obj.group_id} does not exist") + raise ValueError(f"Group with id {obj.group_id} does not exist") short_url.group_id = obj.group_id + else: + short_url.group_id = None + + if obj.domain_id is not None: + domain_by_id = await domainDao.find_by_id(obj.domain_id) + if domain_by_id is None: + raise ValueError(f"Domain with id {obj.domain_id} does not exist") + short_url.domain_id = obj.domain_id + else: + short_url.domain_id = None if obj.loading_screen is not None: short_url.loading_screen = obj.loading_screen @@ -94,3 +111,9 @@ class ShortUrlMutation(MutationABC): short_url = await shortUrlDao.get_by_id(id) await shortUrlDao.restore(short_url) return True + + @staticmethod + async def resolve_track_visit(*_, id: int, agent: str): + logger.debug(f"track visit: {id} -- {agent}") + await shortUrlVisitDao.create(ShortUrlVisit(0, id, agent)) + return True diff --git a/api/src/api_graphql/mutations/user_setting_mutation.py b/api/src/api_graphql/mutations/user_setting_mutation.py new file mode 100644 index 0000000..1fb012d --- /dev/null +++ b/api/src/api_graphql/mutations/user_setting_mutation.py @@ -0,0 +1,40 @@ +from api.route import Route +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.user_setting_input import UserSettingInput +from core.logger import APILogger +from data.schemas.public.user_setting import UserSetting +from data.schemas.public.user_setting_dao import userSettingsDao +from data.schemas.system.setting_dao import settingsDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class UserSettingMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "UserSetting") + self.mutation( + "change", + self.resolve_change, + UserSettingInput, + require_any_permission=[Permissions.settings_update], + ) + + @staticmethod + async def resolve_change(obj: UserSettingInput, *_): + logger.debug(f"create new setting: {input}") + user = await Route.get_user_or_default() + if user is None: + logger.debug("user not authorized") + return None + + setting = await userSettingsDao.find_single_by( + [{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}] + ) + if setting is None: + await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value)) + else: + setting.value = obj.value + await userSettingsDao.update(setting) + + return await userSettingsDao.find_by_key(user, obj.key) diff --git a/api/src/api_graphql/queries/domain_query.py b/api/src/api_graphql/queries/domain_query.py new file mode 100644 index 0000000..e3bfc43 --- /dev/null +++ b/api/src/api_graphql/queries/domain_query.py @@ -0,0 +1,17 @@ +from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from data.schemas.public.domain import Domain +from data.schemas.public.group import Group +from data.schemas.public.short_url import ShortUrl +from data.schemas.public.short_url_dao import shortUrlDao + + +class DomainQuery(DbModelQueryABC): + def __init__(self): + DbModelQueryABC.__init__(self, "Domain") + + self.set_field("name", lambda x, *_: x.name) + self.set_field("shortUrls", self._get_urls) + + @staticmethod + async def _get_urls(domain: Domain, *_): + return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id}) diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index 6917a2e..1e28ac6 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -1,7 +1,11 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from api_graphql.field.resolver_field_builder import ResolverFieldBuilder +from api_graphql.require_any_resolvers import group_by_assignment_resolver 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 service.permission.permissions_enum import Permissions class GroupQuery(DbModelQueryABC): @@ -9,8 +13,22 @@ class GroupQuery(DbModelQueryABC): DbModelQueryABC.__init__(self, "Group") self.set_field("name", lambda x, *_: x.name) - self.set_field("shortUrls", self._get_urls) + self.field( + ResolverFieldBuilder("shortUrls") + .with_resolver(self._get_urls) + .with_require_any( + [ + Permissions.groups, + ], + [group_by_assignment_resolver], + ) + ) + self.set_field("roles", self._get_roles) @staticmethod async def _get_urls(group: Group, *_): return await shortUrlDao.find_by({ShortUrl.group_id: group.id}) + + @staticmethod + async def _get_roles(group: Group, *_): + return await groupDao.get_roles(group.id) diff --git a/api/src/api_graphql/queries/short_url_query.py b/api/src/api_graphql/queries/short_url_query.py index 29c750a..10561b2 100644 --- a/api/src/api_graphql/queries/short_url_query.py +++ b/api/src/api_graphql/queries/short_url_query.py @@ -9,5 +9,6 @@ class ShortUrlQuery(DbModelQueryABC): self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("description", lambda x, *_: x.description) self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("loadingScreen", lambda x, *_: x.loading_screen) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index d80bacb..b3c01a1 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -5,11 +5,13 @@ from api_graphql.abc.sort_abc import Sort from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.filter.api_key_filter import ApiKeyFilter +from api_graphql.filter.domain_filter import DomainFilter from api_graphql.filter.group_filter import GroupFilter from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.user_filter import UserFilter +from api_graphql.require_any_resolvers import group_by_assignment_resolver from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.user import User @@ -18,10 +20,16 @@ from data.schemas.permission.permission import Permission from data.schemas.permission.permission_dao import permissionDao from data.schemas.permission.role import Role from data.schemas.permission.role_dao import roleDao +from data.schemas.public.domain import Domain +from data.schemas.public.domain_dao import domainDao 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 @@ -48,7 +56,15 @@ class Query(QueryABC): .with_dao(roleDao) .with_filter(RoleFilter) .with_sort(Sort[Role]) - .with_require_any_permission([Permissions.roles]) + .with_require_any_permission( + [ + Permissions.roles, + Permissions.users_create, + Permissions.users_update, + Permissions.groups_create, + Permissions.groups_update, + ] + ) ) self.field( @@ -81,25 +97,55 @@ class Query(QueryABC): ) self.field( - DaoFieldBuilder("groups") - .with_dao(groupDao) - .with_filter(GroupFilter) - .with_sort(Sort[Group]) + DaoFieldBuilder("domains") + .with_dao(domainDao) + .with_filter(DomainFilter) + .with_sort(Sort[Domain]) .with_require_any_permission( [ - Permissions.groups, + Permissions.domains, Permissions.short_urls_create, Permissions.short_urls_update, ] ) ) - # partially public to load redirect if not resolved/redirected by api + self.field( + DaoFieldBuilder("groups") + .with_dao(groupDao) + .with_filter(GroupFilter) + .with_sort(Sort[Group]) + .with_require_any( + [ + Permissions.groups, + Permissions.short_urls_create, + Permissions.short_urls_update, + ], + [group_by_assignment_resolver], + ) + ) self.field( DaoFieldBuilder("shortUrls") .with_dao(shortUrlDao) .with_filter(ShortUrlFilter) .with_sort(Sort[ShortUrl]) - .with_require_any_permission([Permissions.short_urls]) + .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 @@ -132,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/require_any_resolvers.py b/api/src/api_graphql/require_any_resolvers.py new file mode 100644 index 0000000..0d31a90 --- /dev/null +++ b/api/src/api_graphql/require_any_resolvers.py @@ -0,0 +1,30 @@ +from api_graphql.service.collection_result import CollectionResult +from api_graphql.service.query_context import QueryContext +from data.schemas.public.group_dao import groupDao +from service.permission.permissions_enum import Permissions + + +async def group_by_assignment_resolver(ctx: QueryContext) -> bool: + if not isinstance(ctx.data, CollectionResult): + return False + + if ctx.has_permission(Permissions.short_urls_by_assignment): + groups = [await x.group for x in ctx.data.nodes] + role_ids = {x.id for x in await ctx.user.roles} + filtered_groups = [ + g.id + for g in groups + if g is not None + and (roles := await groupDao.get_roles(g.id)) + and all(r.id in role_ids for r in roles) + ] + + ctx.data.nodes = [ + node + for node in ctx.data.nodes + if (await node.group) is not None + and (await node.group).id in filtered_groups + ] + return True + + return True diff --git a/api/src/api_graphql/service/query_context.py b/api/src/api_graphql/service/query_context.py index 8265fa6..40f85da 100644 --- a/api/src/api_graphql/service/query_context.py +++ b/api/src/api_graphql/service/query_context.py @@ -14,7 +14,7 @@ class QueryContext: self, data: Any, user: Optional[User], - user_permissions: Optional[list[Permission]], + user_permissions: Optional[list[Permissions]], *args, **kwargs ): @@ -23,7 +23,7 @@ class QueryContext: self._user = user if user_permissions is None: user_permissions = [] - self._user_permissions: list[str] = [x.name for x in user_permissions] + self._user_permissions: list[str] = [x.value for x in user_permissions] self._resolve_info = None for arg in args: 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/api_graphql/typing.py b/api/src/api_graphql/typing.py index 612b34a..61f7a7d 100644 --- a/api/src/api_graphql/typing.py +++ b/api/src/api_graphql/typing.py @@ -1,11 +1,15 @@ from collections.abc import Awaitable -from typing import Callable, Union, Optional +from typing import Callable, Union, Optional, Coroutine, Any from api_graphql.service.query_context import QueryContext from service.permission.permissions_enum import Permissions TRequireAnyPermissions = Optional[list[Permissions]] TRequireAnyResolvers = list[ - Union[Callable[[QueryContext], bool], Awaitable[[QueryContext], bool]] + Union[ + Callable[[QueryContext], bool], + Awaitable[[QueryContext], bool], + Callable[[QueryContext], Coroutine[Any, Any, bool]], + ] ] TRequireAny = tuple[TRequireAnyPermissions, TRequireAnyResolvers] 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 938ac41..9c510bc 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]: @@ -370,6 +416,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if isinstance(value, NoneType): return "NULL" + if value is None: + return "NULL" + if isinstance(value, Enum): return str(value.value) @@ -381,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 @@ -409,15 +464,18 @@ 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}" - if filters and len(filters) > 0: + 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)}" - if sorts and len(sorts) > 0: + if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0): query += f" ORDER BY {self._build_order_by(sorts)}" - if take: + if take is not None: query += f" LIMIT {take}" - if skip: + if skip is not None: query += f" OFFSET {skip}" return query @@ -435,12 +493,41 @@ 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 = [] @@ -448,18 +535,80 @@ 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( - f"{db_name} = {self._get_value_sql(value)}" + self._get_value_validation_sql(db_name, value) ) conditions.append(f"({' OR '.join(sub_conditions)})") else: - conditions.append(f"{db_name} = {self._get_value_sql(values)}") + conditions.append(self._get_value_validation_sql(db_name, values)) 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"{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: """ Build individual SQL condition based on the operator @@ -520,6 +669,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" @@ -537,6 +693,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/get_value.py b/api/src/core/get_value.py index 8aac73c..e0022d3 100644 --- a/api/src/core/get_value.py +++ b/api/src/core/get_value.py @@ -1,5 +1,4 @@ -import ast -from typing import Type, Optional, Any +from typing import Type, Optional from core.typing import T @@ -22,26 +21,38 @@ def get_value( :rtype: Optional[T] """ - if key in source: - value = source[key] - if isinstance(value, cast_type): - return value - - try: - if cast_type == bool: - return value.lower() in ["true", "1"] - - if cast_type == list: - subtype = ( - cast_type.__args__[0] if hasattr(cast_type, "__args__") else None - ) - value = ast.literal_eval(value) - return [ - subtype(item) if subtype is not None else item for item in value - ] - - return cast_type(value) - except (ValueError, TypeError): - return default - else: + if key not in source: + return default + + value = source[key] + if isinstance( + value, + cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__, + ): + return value + + try: + if cast_type == bool: + return value.lower() in ["true", "1"] + + if ( + cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__ + ) == list: + if ( + not (value.startswith("[") and value.endswith("]")) + and list_delimiter not in value + ): + raise ValueError( + "List values must be enclosed in square brackets or use a delimiter." + ) + + if value.startswith("[") and value.endswith("]"): + value = value[1:-1] + + value = value.split(list_delimiter) + subtype = cast_type.__args__[0] if hasattr(cast_type, "__args__") else None + return [subtype(item) if subtype is not None else item for item in value] + + return cast_type(value) + except (ValueError, TypeError): return default diff --git a/api/src/core/logger.py b/api/src/core/logger.py index 6d83ad7..836b010 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,34 @@ 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 if request.scope == "http" else "ws", + "data": ( + asyncio.create_task(request.body()) + if request.scope == "http" + else None + ), + } + 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 +96,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..91fa109 --- /dev/null +++ b/api/src/core/string.py @@ -0,0 +1,9 @@ +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"(? bool: from data.schemas.administration.user_dao import userDao diff --git a/api/src/data/schemas/administration/user_dao.py b/api/src/data/schemas/administration/user_dao.py index feaba98..7759dd3 100644 --- a/api/src/data/schemas/administration/user_dao.py +++ b/api/src/data/schemas/administration/user_dao.py @@ -33,13 +33,30 @@ class UserDao(DbModelDaoABC[User]): SELECT COUNT(*) FROM permission.role_users ru JOIN permission.role_permissions rp ON ru.roleId = rp.roleId - WHERE ru.userId = {user_id} AND rp.permissionId = {p.id}; + WHERE ru.userId = {user_id} + AND rp.permissionId = {p.id} + AND ru.deleted = FALSE + AND rp.deleted = FALSE; """ ) if result is None or len(result) == 0: return False - return True + return result[0]["count"] > 0 + + async def get_permissions(self, user_id: int) -> list[Permissions]: + result = await self._db.select_map( + f""" + SELECT p.* + FROM permission.permissions p + JOIN permission.role_permissions rp ON p.id = rp.permissionId + JOIN permission.role_users ru ON rp.roleId = ru.roleId + WHERE ru.userId = {user_id} + AND rp.deleted = FALSE + AND ru.deleted = FALSE; + """ + ) + return [Permissions(p["name"]) for p in result] userDao = UserDao() diff --git a/api/src/data/schemas/public/domain.py b/api/src/data/schemas/public/domain.py new file mode 100644 index 0000000..0f0ec30 --- /dev/null +++ b/api/src/data/schemas/public/domain.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Optional + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class Domain(DbModelABC): + def __init__( + self, + id: SerialId, + name: 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._name = name + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + self._name = value diff --git a/api/src/data/schemas/public/domain_dao.py b/api/src/data/schemas/public/domain_dao.py new file mode 100644 index 0000000..dd5317d --- /dev/null +++ b/api/src/data/schemas/public/domain_dao.py @@ -0,0 +1,22 @@ +from core.logger import DBLogger +from data.schemas.public.domain import Domain +from data.schemas.public.group import Group + +logger = DBLogger(__name__) + +from core.database.abc.db_model_dao_abc import DbModelDaoABC + + +class DomainDao(DbModelDaoABC[Group]): + def __init__(self): + DbModelDaoABC.__init__(self, __name__, Group, "public.domains") + self.attribute(Domain.name, str) + + async def get_by_name(self, name: str) -> Group: + result = await self._db.select_map( + f"SELECT * FROM {self._table_name} WHERE Name = '{name}'" + ) + return self.to_object(result[0]) + + +domainDao = DomainDao() diff --git a/api/src/data/schemas/public/group_dao.py b/api/src/data/schemas/public/group_dao.py index cc19ec3..52603f8 100644 --- a/api/src/data/schemas/public/group_dao.py +++ b/api/src/data/schemas/public/group_dao.py @@ -17,5 +17,19 @@ class GroupDao(DbModelDaoABC[Group]): ) return self.to_object(result[0]) + async def get_roles(self, group_id: int): + result = await self._db.select_map( + f""" + SELECT r.* + FROM permission.roles r + JOIN public.group_role_assignments gra ON r.id = gra.roleId + WHERE gra.groupId = {group_id} + AND gra.deleted = FALSE + """ + ) + from data.schemas.permission.role_dao import roleDao + + return [roleDao.to_object(x) for x in result] + groupDao = GroupDao() diff --git a/api/src/data/schemas/public/group_role_assignment.py b/api/src/data/schemas/public/group_role_assignment.py new file mode 100644 index 0000000..cc4618d --- /dev/null +++ b/api/src/data/schemas/public/group_role_assignment.py @@ -0,0 +1,43 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class GroupRoleAssignment(DbModelABC): + def __init__( + self, + id: SerialId, + group_id: SerialId, + role_id: SerialId, + 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._group_id = group_id + self._role_id = role_id + + @property + def group_id(self) -> SerialId: + return self._group_id + + @async_property + async def group(self): + from data.schemas.public.group_dao import groupDao + + return await groupDao.get_by_id(self._group_id) + + @property + def role_id(self) -> SerialId: + return self._role_id + + @async_property + async def role(self): + from data.schemas.permission.role_dao import roleDao + + return await roleDao.get_by_id(self._role_id) diff --git a/api/src/data/schemas/public/group_role_assignment_dao.py b/api/src/data/schemas/public/group_role_assignment_dao.py new file mode 100644 index 0000000..4f2efc7 --- /dev/null +++ b/api/src/data/schemas/public/group_role_assignment_dao.py @@ -0,0 +1,37 @@ +from core.logger import DBLogger +from data.schemas.public.group_role_assignment import GroupRoleAssignment + +logger = DBLogger(__name__) + +from core.database.abc.db_model_dao_abc import DbModelDaoABC + + +class GroupRoleAssignmentDao(DbModelDaoABC[GroupRoleAssignment]): + def __init__(self): + DbModelDaoABC.__init__( + self, __name__, GroupRoleAssignment, "public.group_role_assignments" + ) + + self.attribute(GroupRoleAssignment.group_id, int) + self.attribute(GroupRoleAssignment.role_id, int) + + async def get_by_group_id( + self, gid: int, with_deleted=False + ) -> list[GroupRoleAssignment]: + f = [{GroupRoleAssignment.group_id: gid}] + if not with_deleted: + f.append({GroupRoleAssignment.deleted: False}) + + return await self.find_by(f) + + async def get_by_role_id( + self, rid: int, with_deleted=False + ) -> list[GroupRoleAssignment]: + f = [{GroupRoleAssignment.role_id: rid}] + if not with_deleted: + f.append({GroupRoleAssignment.deleted: False}) + + return await self.find_by(f) + + +groupRoleAssignmentDao = GroupRoleAssignmentDao() diff --git a/api/src/data/schemas/public/short_url.py b/api/src/data/schemas/public/short_url.py index 3c699bf..5b04e65 100644 --- a/api/src/data/schemas/public/short_url.py +++ b/api/src/data/schemas/public/short_url.py @@ -16,6 +16,7 @@ class ShortUrl(DbModelABC): target_url: str, description: Optional[str], group_id: Optional[SerialId], + domain_id: Optional[SerialId], loading_screen: Optional[str] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, @@ -27,6 +28,10 @@ class ShortUrl(DbModelABC): self._target_url = target_url self._description = description self._group_id = group_id + self._domain_id = domain_id + + if loading_screen is None or loading_screen == "": + loading_screen = False self._loading_screen = loading_screen @property @@ -70,6 +75,23 @@ class ShortUrl(DbModelABC): return await groupDao.get_by_id(self._group_id) + @property + def domain_id(self) -> SerialId: + return self._domain_id + + @domain_id.setter + def domain_id(self, value: SerialId): + self._domain_id = value + + @async_property + async def domain(self) -> Optional[Group]: + if self._domain_id is None: + return None + + from data.schemas.public.domain_dao import domainDao + + return await domainDao.get_by_id(self._domain_id) + @async_property async def visit_count(self) -> int: from data.schemas.public.short_url_visit_dao import shortUrlVisitDao diff --git a/api/src/data/schemas/public/short_url_dao.py b/api/src/data/schemas/public/short_url_dao.py index 90165ac..43c5a26 100644 --- a/api/src/data/schemas/public/short_url_dao.py +++ b/api/src/data/schemas/public/short_url_dao.py @@ -13,6 +13,7 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]): self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.group_id, int) + self.attribute(ShortUrl.domain_id, int) self.attribute(ShortUrl.loading_screen, bool) diff --git a/api/src/data/schemas/public/user_setting.py b/api/src/data/schemas/public/user_setting.py new file mode 100644 index 0000000..d07afbc --- /dev/null +++ b/api/src/data/schemas/public/user_setting.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Optional, Union + +from async_property import async_property + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSetting(DbModelABC): + def __init__( + self, + id: SerialId, + user_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._user_id = user_id + self._key = key + self._value = value + + @property + def user_id(self) -> 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-01-10-23-15-domains.sql b/api/src/data/scripts/2025-01-10-23-15-domains.sql new file mode 100644 index 0000000..e1a95c4 --- /dev/null +++ b/api/src/data/scripts/2025-01-10-23-15-domains.sql @@ -0,0 +1,32 @@ +CREATE + SCHEMA IF NOT EXISTS public; + +-- groups +CREATE TABLE IF NOT EXISTS public.domains +( + Id SERIAL PRIMARY KEY, + Name VARCHAR(255) 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 IF NOT EXISTS public.domains_history +( + LIKE public.domains +); + +CREATE TRIGGER domains_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.domains + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +ALTER TABLE public.short_urls + ADD COLUMN domainId INT NULL REFERENCES public.domains (Id); + +ALTER TABLE public.short_urls_history + ADD COLUMN domainId INT NULL REFERENCES public.domains (Id); + diff --git a/api/src/data/scripts/2025-01-17-15-00-group-role-assignment.sql b/api/src/data/scripts/2025-01-17-15-00-group-role-assignment.sql new file mode 100644 index 0000000..66a32a3 --- /dev/null +++ b/api/src/data/scripts/2025-01-17-15-00-group-role-assignment.sql @@ -0,0 +1,27 @@ +CREATE + SCHEMA IF NOT EXISTS public; + +-- groups +CREATE TABLE IF NOT EXISTS public.group_role_assignments +( + Id SERIAL PRIMARY KEY, + GroupId INT NOT NULL REFERENCES public.groups (Id), + RoleId INT NOT NULL REFERENCES permission.roles (Id), + -- 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 IF NOT EXISTS public.group_role_assignments_history +( + LIKE public.group_role_assignments +); + +CREATE TRIGGER group_role_assignment_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.group_role_assignments + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + 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..4c9dc5b --- /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 administration.users (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/permission_seeder.py b/api/src/data/seeder/permission_seeder.py index bf85cf4..9fabe4f 100644 --- a/api/src/data/seeder/permission_seeder.py +++ b/api/src/data/seeder/permission_seeder.py @@ -25,7 +25,8 @@ class PermissionSeeder(DataSeederABC): possible_permissions = [permission.value for permission in Permissions] if len(permissions) == len(possible_permissions): - logger.info("Permissions already completed") + logger.info("Permissions already existing") + await self._update_missing_descriptions() return logger.warning("Permissions incomplete") @@ -41,6 +42,7 @@ class PermissionSeeder(DataSeederABC): if permission not in permission_names ] ) + await self._update_missing_descriptions() await self._add_missing_to_role() await self._add_missing_to_api_key() @@ -78,3 +80,28 @@ class PermissionSeeder(DataSeederABC): if permission.id not in [x.permission_id for x in admin_permissions] ] await apiKeyPermissionDao.create_many(to_assign) + + @staticmethod + async def _update_missing_descriptions(): + permissions = { + permission.name: permission + for permission in await permissionDao.find_by( + [{Permission.description: None}] + ) + } + to_update = [] + + if len(permissions) == 0: + return + + for key in PERMISSION_DESCRIPTIONS: + if key.value not in permissions: + continue + + permissions[key.value].description = PERMISSION_DESCRIPTIONS[key] + to_update.append(permissions[key.value]) + + if len(to_update) == 0: + return + + await permissionDao.update_many(to_update) 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/redirector.py b/api/src/redirector.py index ad0398e..6f11a03 100644 --- a/api/src/redirector.py +++ b/api/src/redirector.py @@ -1,96 +1,190 @@ import asyncio import sys +from typing import Optional -import eventlet -from eventlet import wsgi -from flask import Flask, request, Response, redirect, render_template +import requests +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import RedirectResponse +from starlette.routing import Route, Mount +from starlette.staticfiles import StaticFiles +from starlette.templating import Jinja2Templates -from core.database.database import Database from core.environment import Environment from core.logger import Logger -from data.schemas.public.short_url import ShortUrl -from data.schemas.public.short_url_dao import shortUrlDao -from data.schemas.public.short_url_visit import ShortUrlVisit -from data.schemas.public.short_url_visit_dao import shortUrlVisitDao logger = Logger(__name__) +templates = Jinja2Templates(directory="templates") -class Redirector(Flask): - - def __init__(self, *args, **kwargs): - Flask.__init__(self, *args, **kwargs) +async def index(request: Request): + return templates.TemplateResponse("404.html", {"request": request}, status_code=404) -app = Redirector(__name__) - -@app.route("/") -def index(): - return render_template("404.html"), 404 - -@app.route("/") -async def _handle_request(path: str): - short_url = await _find_short_url_by_url(path) +async def handle_request(request: Request): + path = request.path_params["path"] + short_url = _find_short_url_by_path(path) if short_url is None: - return render_template("404.html"), 404 + return templates.TemplateResponse( + "404.html", {"request": request}, status_code=404 + ) + + domains = Environment.get("DOMAINS", list[str], []) + domain = short_url["domain"] + logger.debug( + f"Domain: {domain["name"] if domain is not None else None}, request.host: {request.headers['host']}" + ) + + host = request.headers["host"] + if ":" in host: + host = host.split(":")[0] + + domain_strict_mode = Environment.get("DOMAIN_STRICT_MODE", bool, False) + if domain is not None and ( + domain["name"] not in domains + or (domain_strict_mode and not host.endswith(domain["name"])) + ): + return templates.TemplateResponse( + "404.html", {"request": request}, status_code=404 + ) user_agent = request.headers.get("User-Agent", "").lower() if "wheregoes" in user_agent or "someothertool" in user_agent: - return await _handle_short_url(path, short_url) + return await _handle_short_url(request, short_url) - if short_url.loading_screen: - await _track_visit(short_url) + if short_url["loadingScreen"]: + await _track_visit(request, short_url) - return render_template( + return templates.TemplateResponse( "redirect.html", - key=short_url.short_url, - target_url=_get_redirect_url(short_url.target_url), + { + "request": request, + "key": short_url["shortUrl"], + "target_url": _get_redirect_url(short_url["targetUrl"]), + }, ) - return await _handle_short_url(path, short_url) + return await _handle_short_url(request, short_url) -async def _handle_short_url(path: str, short_url: ShortUrl): - if path.startswith("api/"): - path = path.replace("api/", "") +def _find_short_url_by_path(path: str) -> Optional[dict]: + api_url = Environment.get("API_URL", str) + if api_url is None: + raise Exception("API_URL is not set") - await _track_visit(short_url) + api_key = Environment.get("API_KEY", str) + if api_key is None: + raise Exception("API_KEY is not set") - return _do_redirect(short_url.target_url) + request = requests.post( + f"{api_url}/graphql", + json={ + "query": f""" + query getShortUrlByPath($path: String!) {{ + shortUrls(filter: {{ shortUrl: {{ equal: $path }}, deleted: {{ equal: false }} }}) {{ + nodes {{ + id + shortUrl + targetUrl + description + group {{ + id + name + }} + domain {{ + id + name + }} + loadingScreen + deleted + }} + }} + }} + """, + "variables": {"path": path}, + }, + headers={"Authorization": f"API-Key {api_key}"}, + ) + data = request.json()["data"]["shortUrls"]["nodes"] + if len(data) == 0: + return None + + return data[0] + + +async def _handle_short_url(request: Request, short_url: dict): + await _track_visit(request, short_url) + + return RedirectResponse(_get_redirect_url(short_url["targetUrl"])) + + +async def _track_visit(r: Request, short_url: dict): + api_url = Environment.get("API_URL", str) + if api_url is None: + raise Exception("API_URL is not set") + + api_key = Environment.get("API_KEY", str) + if api_key is None: + raise Exception("API_KEY is not set") -async def _track_visit(short_url: ShortUrl): try: - await shortUrlVisitDao.create( - ShortUrlVisit(0, short_url.id, request.headers.get("User-Agent")) + request = requests.post( + f"{api_url}/graphql", + json={ + "query": f""" + mutation trackShortUrlVisit($id: ID!, $agent: String) {{ + shortUrl {{ + trackVisit(id: $id, agent: $agent) + }} + }} + """, + "variables": { + "id": short_url["id"], + "agent": r.headers.get("User-Agent"), + }, + }, + headers={"Authorization": f"API-Key {api_key}"}, ) - except Exception as e: - logger.error(f"Failed to update short url {short_url.short_url} with error", e) + if request.status_code != 200: + logger.warning( + f"Failed to track visit for short url {short_url["shortUrl"]}" + ) -async def _find_short_url_by_url(url: str) -> ShortUrl: - return await shortUrlDao.find_single_by({ShortUrl.short_url: url}) + data = request.json() + if "errors" in data: + raise Exception(data["errors"]) + else: + logger.debug(f"Tracked visit for short url {short_url["shortUrl"]}") + except Exception as e: + logger.error( + f"Failed to update short url {short_url["shortUrl"]} with error", e + ) def _get_redirect_url(url: str) -> str: - # todo: multiple protocols like ts3:// - if not url.startswith("http://") and not url.startswith("https://"): + protocols = Environment.get("PROTOCOLS", list[str], ["http", "https"]) + + if not any(url.startswith(f"{protocol}://") for protocol in protocols): url = f"http://{url}" return url -def _do_redirect(url: str) -> Response: - return redirect(_get_redirect_url(url)) - - async def configure(): Logger.set_level(Environment.get("LOG_LEVEL", str, "info")) Environment.set_environment(Environment.get("ENVIRONMENT", str, "production")) logger.info(f"Environment: {Environment.get_environment()}") - app.debug = Environment.get_environment() == "development" - await Database.startup_db() +routes = [ + Route("/", endpoint=index), + Mount("/static", StaticFiles(directory="static"), name="static"), + Route("/{path:path}", endpoint=handle_request), +] + +app = Starlette(routes=routes, on_startup=[configure]) def main(): @@ -99,26 +193,13 @@ def main(): asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) - loop = asyncio.new_event_loop() - loop.run_until_complete(configure()) - loop.close() - - port = Environment.get("PORT", int, 5001) - logger.info(f"Start API on port: {port}") - if Environment.get_environment() == "development": - logger.info(f"Playground: http://localhost:{port}/") - wsgi.server(eventlet.listen(("0.0.0.0", port)), app, log_output=False) + uvicorn.run( + app, + host="0.0.0.0", + port=Environment.get("PORT", int, 5001), + log_config=None, + ) if __name__ == "__main__": main() - -# (( -# ( ) -# ; / , -# / \/ -# / | -# / ~/ -# / ) ) ~ edraft -# ___// | / -# --' \_~-, diff --git a/api/src/service/permission/permissions_enum.py b/api/src/service/permission/permissions_enum.py index fedfc2d..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 """ @@ -31,6 +38,12 @@ class Permissions(Enum): """ Public """ + # domains + domains = "domains" + domains_create = "domains.create" + domains_update = "domains.update" + domains_delete = "domains.delete" + # groups groups = "groups" groups_create = "groups.create" @@ -39,6 +52,7 @@ class Permissions(Enum): # short_urls short_urls = "short_urls" + short_urls_by_assignment = "short_urls.by_assignment" short_urls_create = "short_urls.create" short_urls_update = "short_urls.update" short_urls_delete = "short_urls.delete" @@ -46,4 +60,6 @@ class Permissions(Enum): PERMISSION_DESCRIPTIONS = { Permissions.users_update: "Edit users, including changing their roles", + Permissions.short_urls: "See all URLs", + Permissions.short_urls_by_assignment: "See all short urls assigned to a group by role", } diff --git a/api/src/startup.py b/api/src/startup.py index 582f661..5bb417e 100644 --- a/api/src/startup.py +++ b/api/src/startup.py @@ -1,17 +1,31 @@ -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.middleware.websocket import AuthenticatedGraphQLTransportWSHandler +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 +33,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 +80,68 @@ 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=AuthenticatedGraphQLTransportWSHandler(), + ), + ), + ], + 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/api/src/templates/redirect.html b/api/src/templates/redirect.html index ef8d573..607c54f 100644 --- a/api/src/templates/redirect.html +++ b/api/src/templates/redirect.html @@ -8,17 +8,17 @@
-
-
+
-
+
-

Redirecting...

+

Redirecting...

You will be redirected in 5 seconds.

diff --git a/version.txt b/version.txt deleted file mode 100644 index 1cc5f65..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -1.1.0 \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index 56324c9..a4142eb 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1 +1,6 @@ -config.*.json \ No newline at end of file +config.*.json + +dist/ +.angular/ +node_modules/ +coverage/ \ No newline at end of file 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.component.html b/web/src/app/app.component.html index d0a5c7a..be4ad3a 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index 33e0a6b..b7dc7b5 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -1,16 +1,17 @@ -import { Component, OnDestroy } from "@angular/core"; -import { SidebarService } from "src/app/service/sidebar.service"; -import { Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; -import { AuthService } from "src/app/service/auth.service"; -import { GuiService } from "src/app/service/gui.service"; +import { Component, OnDestroy } from '@angular/core'; +import { SidebarService } from 'src/app/service/sidebar.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { AuthService } from 'src/app/service/auth.service'; +import { GuiService } from 'src/app/service/gui.service'; @Component({ - selector: "app-root", - templateUrl: "./app.component.html", - styleUrl: "./app.component.scss", + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.scss', }) export class AppComponent implements OnDestroy { + theme = 'open-redirect'; showSidebar = false; hideUI = false; isLoggedIn = false; @@ -19,23 +20,27 @@ export class AppComponent implements OnDestroy { constructor( private sidebar: SidebarService, private auth: AuthService, - private gui: GuiService, + private gui: GuiService ) { this.auth.loadUser(); - this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe((user) => { + this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(user => { this.isLoggedIn = user !== null && user !== undefined; }); this.sidebar.visible$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((visible) => { + .subscribe(visible => { this.showSidebar = visible; }); - this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe((hide) => { + this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe(hide => { this.hideUI = hide; }); + + this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => { + this.theme = theme; + }); } ngOnDestroy() { 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.html b/web/src/app/components/header/header.component.html index d5fed22..aad6f93 100644 --- a/web/src/app/components/header/header.component.html +++ b/web/src/app/components/header/header.component.html @@ -17,6 +17,18 @@
+
+ + +
(); @@ -25,31 +30,45 @@ 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 config: ConfigService, + private settings: SettingsService, + private userSettings: UserSettingsService ) { this.guiService.isMobile$ .pipe(takeUntil(this.unsubscribe$)) - .subscribe((isMobile) => { + .subscribe(isMobile => { this.isMobile = isMobile; if (isMobile) { this.sidebarService.hide(); } }); - this.auth.user$ - .pipe(takeUntil(this.unsubscribe$)) - .subscribe(async (user) => { - this.user = user; - await this.initMenuLists(); - }); + this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { + this.user = user; + await this.initMenuLists(); + if (user) { + await this.loadTheme(); + await this.loadLang(); + } + }); + + this.themeList = this.config.settings.themes.map(theme => { + return { + label: theme.label, + command: async () => { + this.guiService.theme$.next(theme.name); + await this.userSettings.set('theme', theme.name); + }, + }; + }); } async ngOnInit() { await this.initMenuLists(); - await this.loadLang(); } ngOnDestroy() { @@ -62,24 +81,49 @@ export class HeaderComponent implements OnInit, OnDestroy { } async initMenuLists() { + await this.initMenuList(); await this.initLangMenuList(); await this.initUserMenuList(); } + async initMenuList() { + this.menu = [ + { + label: 'common.news', + routerLink: ['/'], + icon: 'pi pi-home', + }, + { + label: 'header.menu.about', + routerLink: ['/about'], + icon: 'pi pi-info', + }, + ]; + + if (this.auth.user$.value) { + this.menu.push({ + label: 'header.menu.admin', + routerLink: ['/admin'], + icon: 'pi pi-cog', + visible: await this.auth.isAdmin(), + }); + } + } + async initLangMenuList() { this.langList = [ { - label: "English", - command: () => { - this.translate("en"); - this.setLang("en"); + label: 'English', + command: async () => { + this.translate('en'); + await this.setLang('en'); }, }, { - label: "Deutsch", - command: () => { - this.translate("de"); - this.setLang("de"); + label: 'Deutsch', + command: async () => { + this.translate('de'); + await this.setLang('de'); }, }, ]; @@ -96,13 +140,11 @@ export class HeaderComponent implements OnInit, OnDestroy { separator: true, }, { - label: this.translateService.instant("header.logout"), - command: () => { - this.auth.logout().then(() => { - console.log("logout"); - }); + label: this.translateService.instant('header.logout'), + command: async () => { + await this.auth.logout(); }, - icon: "pi pi-sign-out", + icon: 'pi pi-sign-out', }, ]; } @@ -110,19 +152,37 @@ export class HeaderComponent implements OnInit, OnDestroy { translate(lang: string) { this.translateService.use(lang); this.translateService - .get("primeng") - .subscribe((res) => this.config.setTranslation(res)); + .get('primeng') + .subscribe(res => this.ngConfig.setTranslation(res)); + } + + async loadTheme() { + const defaultTheme = (await this.settings.get('default_theme')) ?? 'maxlan'; + const userTheme = await this.userSettings.get('theme'); + const theme = userTheme ?? defaultTheme; + + this.guiService.theme$.next(theme); + + if (!userTheme) { + await this.userSettings.set('theme', theme); + } } async loadLang() { - const lang = "en"; - this.setLang(lang); + const defaultLang = (await this.settings.get('default_language')) ?? 'en'; + const userLang = await this.userSettings.get('language'); + const lang = userLang ?? defaultLang; + this.translate(lang); + + if (userLang) { + return; + } + await this.userSettings.set('language', lang); } - setLang(lang: string) { - // this.settings.setSetting(`lang`, lang); - console.log("setLang", lang); + async setLang(lang: string) { + await this.userSettings.set('language', lang); } toggleSidebar() { diff --git a/web/src/app/components/home/home.component.spec.ts b/web/src/app/components/home/home.component.spec.ts index bc6a361..5a36317 100644 --- a/web/src/app/components/home/home.component.spec.ts +++ b/web/src/app/components/home/home.component.spec.ts @@ -1,14 +1,38 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HomeComponent } from "./home.component"; +import { HomeComponent } from './home.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("HomeComponent", () => { +describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [HomeComponent], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(HomeComponent); @@ -16,7 +40,7 @@ describe("HomeComponent", () => { fixture.detectChanges(); }); - it("should create", () => { + it('should create', () => { expect(component).toBeTruthy(); }); }); diff --git a/web/src/app/components/home/home.component.ts b/web/src/app/components/home/home.component.ts index bc54070..444635d 100644 --- a/web/src/app/components/home/home.component.ts +++ b/web/src/app/components/home/home.component.ts @@ -1,10 +1,10 @@ -import { Component } from "@angular/core"; -import { KeycloakService } from "keycloak-angular"; +import { Component } from '@angular/core'; +import { KeycloakService } from 'keycloak-angular'; @Component({ - selector: "app-home", - templateUrl: "./home.component.html", - styleUrl: "./home.component.scss", + selector: 'app-home', + templateUrl: './home.component.html', + styleUrl: './home.component.scss', }) export class HomeComponent { constructor(private keycloak: KeycloakService) { 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/core/token.interceptor.ts b/web/src/app/core/token.interceptor.ts index f927bc4..13c0ead 100644 --- a/web/src/app/core/token.interceptor.ts +++ b/web/src/app/core/token.interceptor.ts @@ -1,7 +1,7 @@ -import { HttpInterceptorFn } from "@angular/common/http"; -import { KeycloakService } from "keycloak-angular"; -import { inject } from "@angular/core"; -import { from, switchMap } from "rxjs"; +import { HttpInterceptorFn } from '@angular/common/http'; +import { KeycloakService } from 'keycloak-angular'; +import { inject } from '@angular/core'; +import { from, switchMap } from 'rxjs'; export const tokenInterceptor: HttpInterceptorFn = (req, next) => { const keycloak = inject(KeycloakService); @@ -15,14 +15,14 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => { } return from(keycloak.getToken()).pipe( - switchMap((token) => { + switchMap(token => { const modifiedReq = token ? req.clone({ - headers: req.headers.set("Authorization", `Bearer ${token}`), + headers: req.headers.set('Authorization', `Bearer ${token}`), }) : req; return next(modifiedReq); - }), + }) ); }; diff --git a/web/src/app/model/auth/permissionsEnum.ts b/web/src/app/model/auth/permissionsEnum.ts index 184b4f2..0d62ae2 100644 --- a/web/src/app/model/auth/permissionsEnum.ts +++ b/web/src/app/model/auth/permissionsEnum.ts @@ -1,30 +1,38 @@ export enum PermissionsEnum { // Administration - apiKeys = "api_keys", - apiKeysCreate = "api_keys.create", - apiKeysUpdate = "api_keys.update", - apiKeysDelete = "api_keys.delete", + administrator = 'administrator', + + apiKeys = 'api_keys', + apiKeysCreate = 'api_keys.create', + apiKeysUpdate = 'api_keys.update', + apiKeysDelete = 'api_keys.delete', // Users - users = "users", - usersCreate = "users.create", - usersUpdate = "users.update", - usersDelete = "users.delete", + users = 'users', + usersCreate = 'users.create', + usersUpdate = 'users.update', + usersDelete = 'users.delete', // Permissions - roles = "roles", - rolesCreate = "roles.create", - rolesUpdate = "roles.update", - rolesDelete = "roles.delete", + roles = 'roles', + rolesCreate = 'roles.create', + rolesUpdate = 'roles.update', + rolesDelete = 'roles.delete', // Public - groups = "groups", - groupsCreate = "groups.create", - groupsUpdate = "groups.update", - groupsDelete = "groups.delete", + domains = 'domains', + domainsCreate = 'domains.create', + domainsUpdate = 'domains.update', + domainsDelete = 'domains.delete', - shortUrls = "short_urls", - shortUrlsCreate = "short_urls.create", - shortUrlsUpdate = "short_urls.update", - shortUrlsDelete = "short_urls.delete", + groups = 'groups', + groupsCreate = 'groups.create', + groupsUpdate = 'groups.update', + groupsDelete = 'groups.delete', + + shortUrls = 'short_urls', + shortUrlsByAssignment = 'short_urls.by_assignment', + shortUrlsCreate = 'short_urls.create', + shortUrlsUpdate = 'short_urls.update', + shortUrlsDelete = 'short_urls.delete', } 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/domain.ts b/web/src/app/model/entities/domain.ts new file mode 100644 index 0000000..c81d199 --- /dev/null +++ b/web/src/app/model/entities/domain.ts @@ -0,0 +1,14 @@ +import { DbModel } from 'src/app/model/entities/db-model'; + +export interface Domain extends DbModel { + name: string; +} + +export interface DomainCreateInput { + name: string; +} + +export interface DomainUpdateInput { + id: number; + name: 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/group.ts b/web/src/app/model/entities/group.ts index 5437ff1..94be2dc 100644 --- a/web/src/app/model/entities/group.ts +++ b/web/src/app/model/entities/group.ts @@ -1,14 +1,18 @@ import { DbModel } from 'src/app/model/entities/db-model'; +import { Role } from 'src/app/model/entities/role'; export interface Group extends DbModel { name: string; + roles: Role[]; } export interface GroupCreateInput { name: string; + roles: Role[]; } export interface GroupUpdateInput { id: number; name: string; + roles: Role[]; } 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/model/entities/short-url.ts b/web/src/app/model/entities/short-url.ts index 3007473..8ddaba4 100644 --- a/web/src/app/model/entities/short-url.ts +++ b/web/src/app/model/entities/short-url.ts @@ -1,5 +1,6 @@ import { DbModel } from 'src/app/model/entities/db-model'; import { Group } from 'src/app/model/entities/group'; +import { Domain } from 'src/app/model/entities/domain'; export interface ShortUrl extends DbModel { shortUrl: string; @@ -8,6 +9,7 @@ export interface ShortUrl extends DbModel { loadingScreen: boolean; visits: number; group?: Group; + domain?: Domain; } export interface ShortUrlDto { @@ -22,6 +24,7 @@ export interface ShortUrlCreateInput { description: string; loadingScreen: boolean; groupId: number; + domainId: number; } export interface ShortUrlUpdateInput { @@ -31,4 +34,5 @@ export interface ShortUrlUpdateInput { description: string; loadingScreen: boolean; groupId: number; + domainId: number; } diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index 15f924e..d47a27d 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -6,6 +6,15 @@ import { PermissionGuard } from 'src/app/core/guard/permission.guard'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; const routes: Routes = [ + { + path: 'domains', + loadChildren: () => + import('src/app/modules/admin/domains/domains.module').then( + m => m.DomainsModule + ), + canActivate: [PermissionGuard], + data: { permissions: [PermissionsEnum.domains] }, + }, { path: 'groups', loadChildren: () => @@ -22,7 +31,12 @@ const routes: Routes = [ m => m.ShortUrlsModule ), canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.shortUrls] }, + data: { + permissions: [ + PermissionsEnum.shortUrls, + PermissionsEnum.shortUrlsByAssignment, + ], + }, }, { path: 'administration', 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 09654f7..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 @@ -17,11 +17,11 @@

{{ 'common.id' | translate }}

- +

{{ 'common.name' | translate }}

- +

{{ 'common.description' | translate }}

@@ -29,13 +29,13 @@ pInputText class="value" type="text" - formControlName="description" /> + formControlName="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 4d950f4..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 @@ -1,21 +1,22 @@ -import { Component } from "@angular/core"; +import { Component } from '@angular/core'; import { Permission, Role, RoleCreateInput, RoleUpdateInput, -} from "src/app/model/entities/role"; -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 { RolesDataService } from "src/app/modules/admin/administration/roles/roles.data.service"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; +} from 'src/app/model/entities/role'; +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 { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: "app-role-form-page", - templateUrl: "./role-form-page.component.html", - styleUrl: "./role-form-page.component.scss", + selector: 'app-role-form-page', + templateUrl: './role-form-page.component.html', + styleUrl: './role-form-page.component.scss', }) export class RoleFormPageComponent extends FormPageBase< Role, @@ -26,7 +27,10 @@ export class RoleFormPageComponent extends FormPageBase< permissionGroups: { [key: string]: Permission[] } = {}; allPermissions: Permission[] = []; - constructor(private toast: ToastService) { + constructor( + private toast: ToastService, + private translate: TranslateService + ) { super(); this.initializePermissions().then(() => { if (!this.nodeId) { @@ -36,7 +40,7 @@ export class RoleFormPageComponent extends FormPageBase< return; } - this.dataService.loadById(this.nodeId).subscribe((role) => { + this.dataService.loadById(this.nodeId).subscribe(role => { this.node = role; this.setForm(this.node); }); @@ -45,12 +49,17 @@ export class RoleFormPageComponent extends FormPageBase< async initializePermissions() { const permissions = await firstValueFrom( - this.dataService.getAllPermissions(), + this.dataService.getAllPermissions() ); - this.allPermissions = permissions; + this.allPermissions = permissions.map(x => { + const key = `permission_descriptions.${x.name}`; + const description = this.translate.instant(key); + x.description = description === key ? undefined : description; + return x; + }); 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] = []; @@ -58,10 +67,10 @@ export class RoleFormPageComponent 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)); }); } @@ -76,48 +85,48 @@ export class RoleFormPageComponent extends FormPageBase< name: new FormControl(undefined, Validators.required), description: new FormControl(undefined), }); - this.form.controls["id"].disable(); + this.form.controls['id'].disable(); } setForm(node?: Role) { - this.form.controls["id"].setValue(node?.id); - this.form.controls["name"].setValue(node?.name); - this.form.controls["description"].setValue(node?.description); + this.form.controls['id'].setValue(node?.id); + this.form.controls['name'].setValue(node?.name); + this.form.controls['description'].setValue(node?.description); if (!node) return; const permissions = node.permissions ?? []; - permissions.forEach((p) => { + permissions.forEach(p => { this.form.controls[p.name].setValue(true); }); } getCreateInput(): RoleCreateInput { return { - name: this.form.controls["name"].pristine + name: this.form.controls['name'].pristine ? undefined - : (this.form.controls["name"].value ?? undefined), - description: this.form.controls["description"].pristine + : (this.form.controls['name'].value ?? undefined), + description: this.form.controls['description'].pristine ? undefined - : (this.form.controls["description"].value ?? undefined), + : (this.form.controls['description'].value ?? undefined), permissions: this.allPermissions.filter( - (p) => this.form.controls[p.name].value, + p => this.form.controls[p.name].value ), }; } getUpdateInput(): RoleUpdateInput { return { - id: this.form.controls["id"].value, - name: this.form.controls["name"].pristine + id: this.form.controls['id'].value, + name: this.form.controls['name'].pristine ? undefined - : this.form.controls["name"].value, - description: this.form.controls["description"].pristine + : this.form.controls['name'].value, + description: this.form.controls['description'].pristine ? undefined - : this.form.controls["description"].value, + : this.form.controls['description'].value, permissions: this.allPermissions.filter( - (p) => this.form.controls[p.name].value, + p => this.form.controls[p.name].value ), }; } @@ -125,7 +134,7 @@ export class RoleFormPageComponent extends FormPageBase< create(role: RoleCreateInput): void { this.dataService.create(role).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.created'); this.close(); }); } @@ -133,20 +142,20 @@ 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(); }); } 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/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 909d969..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 @@ -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"; +} from 'src/app/core/base/page.data.service'; import { Permission, Role, RoleCreateInput, RoleUpdateInput, -} 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/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'; @Injectable() export class RolesDataService @@ -32,7 +32,7 @@ export class RolesDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo, + private apollo: Apollo ) { super(); } @@ -41,7 +41,7 @@ export class RolesDataService filter?: Filter[] | undefined, sort?: Sort[] | undefined, skip?: number | undefined, - take?: number | undefined, + take?: number | undefined ): Observable> { return this.apollo .query<{ roles: QueryResult }>({ @@ -75,12 +75,12 @@ export class RolesDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.roles)); + .pipe(map(result => result.data.roles)); } loadById(id: number): Observable { @@ -112,12 +112,24 @@ export class RolesDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.roles.nodes[0])); + .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 { @@ -145,17 +157,17 @@ export class RolesDataService input: { name: object.name, description: object.description, - 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?.role.create)); + .pipe(map(result => result.data?.role.create)); } update(object: RoleUpdateInput): Observable { @@ -184,17 +196,17 @@ export class RolesDataService id: object.id, name: object.name, description: object.description, - 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?.role.update)); + .pipe(map(result => result.data?.role.update)); } delete(object: Role): Observable { @@ -212,12 +224,12 @@ export class RolesDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.role.delete ?? false)); + .pipe(map(result => result.data?.role.delete ?? false)); } restore(object: Role): Observable { @@ -235,12 +247,12 @@ export class RolesDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.role.restore ?? false)); + .pipe(map(result => result.data?.role.restore ?? false)); } getAllPermissions(): Observable { @@ -258,12 +270,12 @@ export class RolesDataService `, }) .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/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 ba8a1b2..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 @@ -1,20 +1,20 @@ -import { Component } from "@angular/core"; -import { FormControl, FormGroup } from "@angular/forms"; -import { ToastService } from "src/app/service/toast.service"; -import { FormPageBase } from "src/app/core/base/form-page-base"; +import { Component } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { ToastService } from 'src/app/service/toast.service'; +import { FormPageBase } from 'src/app/core/base/form-page-base'; import { NotExistingUser, User, UserCreateInput, UserUpdateInput, -} 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"; +} 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'; @Component({ - selector: "app-user-form-page", - templateUrl: "./user-form-page.component.html", - styleUrl: "./user-form-page.component.scss", + selector: 'app-user-form-page', + templateUrl: './user-form-page.component.html', + styleUrl: './user-form-page.component.scss', }) export class UserFormPageComponent extends FormPageBase< User, @@ -27,12 +27,12 @@ export class UserFormPageComponent extends FormPageBase< constructor(private toast: ToastService) { super(); - this.dataService.getAllRoles().subscribe((roles) => { + this.dataService.getAllRoles().subscribe(roles => { this.roles = roles; }); if (!this.nodeId) { - this.dataService.getNotExistingUsersFromKeycloak().subscribe((users) => { + this.dataService.getNotExistingUsersFromKeycloak().subscribe(users => { this.notExistingUsers = users; this.node = this.new(); this.setForm(this.node); @@ -41,7 +41,7 @@ export class UserFormPageComponent extends FormPageBase< return; } - this.dataService.loadById(this.nodeId).subscribe((user) => { + this.dataService.loadById(this.nodeId).subscribe(user => { this.node = user; this.setForm(this.node); }); @@ -59,47 +59,47 @@ export class UserFormPageComponent extends FormPageBase< email: new FormControl(undefined), roles: new FormControl([]), }); - this.form.controls["id"].disable(); - this.form.controls["username"].disable(); - this.form.controls["email"].disable(); + this.form.controls['id'].disable(); + this.form.controls['username'].disable(); + this.form.controls['email'].disable(); } setForm(node?: User) { - this.form.controls["id"].setValue(node?.id); - this.form.controls["username"].setValue(node?.username); - this.form.controls["email"].setValue(node?.email); - this.form.controls["roles"].setValue(node?.roles ?? []); + this.form.controls['id'].setValue(node?.id); + this.form.controls['username'].setValue(node?.username); + this.form.controls['email'].setValue(node?.email); + this.form.controls['roles'].setValue(node?.roles ?? []); if (this.notExistingUsers.length > 0) { - this.form.controls["id"].enable(); - this.form.controls["keycloakId"].reset(undefined, { required: true }); + this.form.controls['id'].enable(); + this.form.controls['keycloakId'].reset(undefined, { required: true }); } } getCreateInput(): UserCreateInput { return { - keycloakId: this.form.controls["keycloakId"].pristine + keycloakId: this.form.controls['keycloakId'].pristine ? undefined - : this.form.controls["keycloakId"].value, - roles: this.form.controls["roles"].pristine + : this.form.controls['keycloakId'].value, + roles: this.form.controls['roles'].pristine ? undefined - : this.form.controls["roles"].value, + : this.form.controls['roles'].value, }; } getUpdateInput(): UserUpdateInput { return { - id: this.form.controls["id"].value, - roles: this.form.controls["roles"].pristine + id: this.form.controls['id'].value, + roles: this.form.controls['roles'].pristine ? undefined - : this.form.controls["roles"].value, + : this.form.controls['roles'].value, }; } create(user: UserCreateInput): void { this.dataService.create(user).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.created'); this.close(); }); } @@ -107,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..f5fe0af 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,17 +13,25 @@ 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, }, + { + name: 'roles', + translationKey: 'common.roles', + type: 'text', + filterable: true, + value: (row: User) => row.roles.map(role => role.name).join(', '), + width: '300px', + }, ...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 f11005d..1361816 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 @@ -1,26 +1,26 @@ -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 { 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 { 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 { NotExistingUser, User, UserCreateInput, UserUpdateInput, -} from "src/app/model/auth/user"; -import { Role } from "src/app/model/entities/role"; +} from 'src/app/model/auth/user'; +import { Role } from 'src/app/model/entities/role'; @Injectable() export class UsersDataService @@ -33,7 +33,7 @@ export class UsersDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo, + private apollo: Apollo ) { super(); } @@ -42,7 +42,7 @@ export class UsersDataService filter?: Filter[] | undefined, sort?: Sort[] | undefined, skip?: number | undefined, - take?: number | undefined, + take?: number | undefined ): Observable> { return this.apollo .query<{ users: QueryResult }>({ @@ -61,6 +61,10 @@ export class UsersDataService keycloakId username email + roles { + id + name + } ...DB_MODEL } @@ -77,12 +81,12 @@ export class UsersDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.users)); + .pipe(map(result => result.data.users)); } loadById(id: number): Observable { @@ -115,12 +119,24 @@ export class UsersDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.users.nodes[0])); + .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 { @@ -145,17 +161,17 @@ export class UsersDataService variables: { input: { keycloakId: object.keycloakId, - roles: object.roles?.map((x) => x.id), + roles: object.roles?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.user.create)); + .pipe(map(result => result.data?.user.create)); } update(object: UserUpdateInput): Observable { @@ -180,17 +196,17 @@ export class UsersDataService variables: { input: { id: object.id, - roles: object.roles?.map((x) => x.id), + roles: object.roles?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.user.update)); + .pipe(map(result => result.data?.user.update)); } delete(object: User): Observable { @@ -208,12 +224,12 @@ export class UsersDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.user.delete ?? false)); + .pipe(map(result => result.data?.user.delete ?? false)); } restore(object: User): Observable { @@ -231,12 +247,12 @@ export class UsersDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.user.restore ?? false)); + .pipe(map(result => result.data?.user.restore ?? false)); } getAllRoles(): Observable { @@ -254,12 +270,12 @@ export class UsersDataService `, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.roles.nodes)); + .pipe(map(result => result.data.roles.nodes)); } getNotExistingUsersFromKeycloak(): Observable { @@ -277,12 +293,12 @@ export class UsersDataService `, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.notExistingUsersFromKeycloak.nodes)); + .pipe(map(result => result.data.notExistingUsersFromKeycloak.nodes)); } static provide(): Provider[] { 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 new file mode 100644 index 0000000..ef239c1 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.columns.ts @@ -0,0 +1,35 @@ +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 { Domain } from 'src/app/model/entities/domain'; + +@Injectable() +export class DomainsColumns extends PageColumns { + get(): TableColumn[] { + return [ + ID_COLUMN, + { + name: 'name', + translationKey: 'common.name', + type: 'text', + filterable: true, + value: (row: Domain) => row.name, + }, + ...DB_MODEL_COLUMNS, + ]; + } + + static provide(): Provider[] { + return [ + { + provide: PageColumns, + useClass: DomainsColumns, + }, + DomainsColumns, + ]; + } +} diff --git a/web/src/app/modules/admin/domains/domains.data.service.ts b/web/src/app/modules/admin/domains/domains.data.service.ts new file mode 100644 index 0000000..d3a6272 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.data.service.ts @@ -0,0 +1,244 @@ +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 { 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 { + Domain, + DomainCreateInput, + DomainUpdateInput, +} from 'src/app/model/entities/domain'; + +@Injectable() +export class DomainsDataService + extends PageDataService + implements + Create, + Update, + Delete, + Restore +{ + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) { + super(); + } + + load( + filter?: Filter[] | undefined, + sort?: Sort[] | undefined, + skip?: number | undefined, + take?: number | undefined + ): Observable> { + return this.apollo + .query<{ domains: QueryResult }>({ + query: gql` + query getDomains( + $filter: [DomainFilter] + $sort: [DomainSort] + $skip: Int + $take: Int + ) { + domains(filter: $filter, sort: $sort, skip: $skip, take: $take) { + count + totalCount + nodes { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + filter: filter, + sort: sort, + skip: skip, + take: take, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.domains)); + } + + loadById(id: number): Observable { + return this.apollo + .query<{ domains: QueryResult }>({ + query: gql` + query getDomain($id: Int) { + domain(filter: { id: { equal: $id } }) { + id + name + + ...DB_MODEL + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + id: id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.domains.nodes[0])); + } + + onChange(): Observable { + return this.apollo + .subscribe<{ domainChange: void }>({ + query: gql` + subscription onDomainChange { + domainChange + } + `, + }) + .pipe(map(result => result.data?.domainChange)); + } + + create(object: DomainCreateInput): Observable { + return this.apollo + .mutate<{ domain: { create: Domain } }>({ + mutation: gql` + mutation createDomain($input: DomainCreateInput!) { + domain { + create(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + name: object.name, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.domain.create)); + } + + update(object: DomainUpdateInput): Observable { + return this.apollo + .mutate<{ domain: { update: Domain } }>({ + mutation: gql` + mutation updateDomain($input: DomainUpdateInput!) { + domain { + update(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + id: object.id, + name: object.name, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.domain.update)); + } + + delete(object: Domain): Observable { + return this.apollo + .mutate<{ domain: { delete: boolean } }>({ + mutation: gql` + mutation deleteDomain($id: ID!) { + domain { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.domain.delete ?? false)); + } + + restore(object: Domain): Observable { + return this.apollo + .mutate<{ domain: { restore: boolean } }>({ + mutation: gql` + mutation restoreDomain($id: ID!) { + domain { + restore(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.domain.restore ?? false)); + } + + static provide(): Provider[] { + return [ + { + provide: PageDataService, + useClass: DomainsDataService, + }, + DomainsDataService, + ]; + } +} diff --git a/web/src/app/modules/admin/domains/domains.module.ts b/web/src/app/modules/admin/domains/domains.module.ts new file mode 100644 index 0000000..62fe081 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.module.ts @@ -0,0 +1,43 @@ +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 { DomainsPage } from 'src/app/modules/admin/domains/domains.page'; +import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component'; +import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; +import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; + +const routes: Routes = [ + { + path: '', + title: 'Domains', + component: DomainsPage, + children: [ + { + path: 'create', + component: DomainFormPageComponent, + canActivate: [PermissionGuard], + data: { + permissions: [PermissionsEnum.apiKeysCreate], + }, + }, + { + path: 'edit/:id', + component: DomainFormPageComponent, + canActivate: [PermissionGuard], + data: { + permissions: [PermissionsEnum.apiKeysUpdate], + }, + }, + ], + }, +]; + +@NgModule({ + declarations: [DomainsPage, DomainFormPageComponent], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [DomainsDataService.provide(), DomainsColumns.provide()], +}) +export class DomainsModule {} diff --git a/web/src/app/modules/admin/domains/domains.page.html b/web/src/app/modules/admin/domains/domains.page.html new file mode 100644 index 0000000..dd8b0a9 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.page.html @@ -0,0 +1,19 @@ + + + diff --git a/web/src/app/modules/admin/domains/domains.page.scss b/web/src/app/modules/admin/domains/domains.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/domains/domains.page.spec.ts b/web/src/app/modules/admin/domains/domains.page.spec.ts new file mode 100644 index 0000000..f1ba746 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.page.spec.ts @@ -0,0 +1,51 @@ +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", () => { + let component: ApiKeysPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ApiKeysPage], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + { + provide: PageDataService, + useClass: ApiKeysDataService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ApiKeysPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/domains/domains.page.ts b/web/src/app/modules/admin/domains/domains.page.ts new file mode 100644 index 0000000..337b4e3 --- /dev/null +++ b/web/src/app/modules/admin/domains/domains.page.ts @@ -0,0 +1,72 @@ +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 { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; +import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; +import { Domain } from 'src/app/model/entities/domain'; + +@Component({ + selector: 'app-domains', + templateUrl: './domains.page.html', + styleUrl: './domains.page.scss', +}) +export class DomainsPage extends PageBase< + Domain, + DomainsDataService, + DomainsColumns +> { + constructor( + private toast: ToastService, + private confirmation: ConfirmationDialogService + ) { + super(true, { + read: [PermissionsEnum.domains], + create: [PermissionsEnum.domainsCreate], + update: [PermissionsEnum.domainsUpdate], + delete: [PermissionsEnum.domainsDelete], + restore: [PermissionsEnum.domainsDelete], + }); + } + + load(silent?: boolean): void { + if (!silent) this.loading = true; + this.dataService + .load(this.filter, this.sort, this.skip, this.take) + .subscribe(result => { + this.result = result; + this.loading = false; + }); + } + + delete(domain: Domain): void { + this.confirmation.confirmDialog({ + header: 'dialog.delete.header', + message: 'dialog.delete.message', + accept: () => { + this.loading = true; + this.dataService.delete(domain).subscribe(() => { + this.toast.success('action.deleted'); + this.load(); + }); + }, + messageParams: { entity: domain.name }, + }); + } + + restore(domain: Domain): void { + this.confirmation.confirmDialog({ + header: 'dialog.restore.header', + message: 'dialog.restore.message', + accept: () => { + this.loading = true; + this.dataService.restore(domain).subscribe(() => { + this.toast.success('action.restored'); + this.load(); + }); + }, + messageParams: { entity: domain.name }, + }); + } +} diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.html b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.html new file mode 100644 index 0000000..22274ea --- /dev/null +++ b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.html @@ -0,0 +1,31 @@ + + +

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

+
+ + +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.name' | translate }}

+ +
+
+
diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts new file mode 100644 index 0000000..d7bfe12 --- /dev/null +++ b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts @@ -0,0 +1,50 @@ +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", () => { + let component: RoleFormPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RoleFormPageComponent], + imports: [ + BrowserAnimationsModule, + SharedModule, + TranslateModule.forRoot(), + ], + providers: [ + AuthService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ApiKeysDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RoleFormPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts new file mode 100644 index 0000000..f5994bb --- /dev/null +++ b/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts @@ -0,0 +1,93 @@ +import { Component } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastService } from 'src/app/service/toast.service'; +import { FormPageBase } from 'src/app/core/base/form-page-base'; +import { + Domain, + DomainCreateInput, + DomainUpdateInput, +} from 'src/app/model/entities/domain'; +import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; + +@Component({ + selector: 'app-domain-form-page', + templateUrl: './domain-form-page.component.html', + styleUrl: './domain-form-page.component.scss', +}) +export class DomainFormPageComponent extends FormPageBase< + Domain, + DomainCreateInput, + DomainUpdateInput, + DomainsDataService +> { + constructor(private toast: ToastService) { + super(); + if (!this.nodeId) { + this.node = this.new(); + this.setForm(this.node); + + return; + } + + this.dataService + .load([{ id: { equal: this.nodeId } }]) + .subscribe(apiKey => { + this.node = apiKey.nodes[0]; + this.setForm(this.node); + }); + } + + new(): Domain { + return {} as Domain; + } + + buildForm() { + this.form = new FormGroup({ + id: new FormControl(undefined), + name: new FormControl(undefined, Validators.required), + }); + this.form.controls['id'].disable(); + } + + setForm(node?: Domain) { + this.form.controls['id'].setValue(node?.id); + this.form.controls['name'].setValue(node?.name); + } + + getCreateInput(): DomainCreateInput { + return { + name: this.form.controls['name'].pristine + ? undefined + : (this.form.controls['name'].value ?? undefined), + }; + } + + getUpdateInput(): DomainUpdateInput { + if (!this.node?.id) { + throw new Error('Node id is missing'); + } + + return { + id: this.form.controls['id'].value, + name: this.form.controls['name'].pristine + ? undefined + : (this.form.controls['name'].value ?? undefined), + }; + } + + create(apiKey: DomainCreateInput): void { + this.dataService.create(apiKey).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } + + update(apiKey: DomainUpdateInput): void { + this.dataService.update(apiKey).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } +} diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html index 82d8e33..150c081 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html @@ -27,5 +27,13 @@ type="text" formControlName="name"/>
+
+ diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts index e86ca33..a462d6a 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts @@ -1,18 +1,20 @@ -import { Component } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { ToastService } from "src/app/service/toast.service"; -import { FormPageBase } from "src/app/core/base/form-page-base"; +import { Component } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastService } from 'src/app/service/toast.service'; +import { FormPageBase } from 'src/app/core/base/form-page-base'; import { Group, GroupCreateInput, GroupUpdateInput, -} from "src/app/model/entities/group"; -import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; +} from 'src/app/model/entities/group'; +import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service'; +import { Role } from 'src/app/model/entities/role'; +import { CommonDataService } from 'src/app/modules/shared/service/common-data.service'; @Component({ - selector: "app-group-form-page", - templateUrl: "./group-form-page.component.html", - styleUrl: "./group-form-page.component.scss", + selector: 'app-group-form-page', + templateUrl: './group-form-page.component.html', + styleUrl: './group-form-page.component.scss', }) export class GroupFormPageComponent extends FormPageBase< Group, @@ -20,8 +22,17 @@ export class GroupFormPageComponent extends FormPageBase< GroupUpdateInput, GroupsDataService > { - constructor(private toast: ToastService) { + roles: Role[] = []; + + constructor( + private toast: ToastService, + private cds: CommonDataService + ) { super(); + this.cds.getAllRoles().subscribe(roles => { + this.roles = roles; + }); + if (!this.nodeId) { this.node = this.new(); this.setForm(this.node); @@ -31,7 +42,7 @@ export class GroupFormPageComponent extends FormPageBase< this.dataService .load([{ id: { equal: this.nodeId } }]) - .subscribe((apiKey) => { + .subscribe(apiKey => { this.node = apiKey.nodes[0]; this.setForm(this.node); }); @@ -45,40 +56,48 @@ export class GroupFormPageComponent extends FormPageBase< this.form = new FormGroup({ id: new FormControl(undefined), name: new FormControl(undefined, Validators.required), + roles: new FormControl([]), }); - this.form.controls["id"].disable(); + this.form.controls['id'].disable(); } setForm(node?: Group) { - this.form.controls["id"].setValue(node?.id); - this.form.controls["name"].setValue(node?.name); + this.form.controls['id'].setValue(node?.id); + this.form.controls['name'].setValue(node?.name); + this.form.controls['roles'].setValue(node?.roles ?? []); } getCreateInput(): GroupCreateInput { return { - name: this.form.controls["name"].pristine + name: this.form.controls['name'].pristine ? undefined - : (this.form.controls["name"].value ?? undefined), + : (this.form.controls['name'].value ?? undefined), + roles: this.form.controls['roles'].pristine + ? undefined + : this.form.controls['roles'].value, }; } getUpdateInput(): GroupUpdateInput { 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, - name: this.form.controls["name"].pristine + id: this.form.controls['id'].value, + name: this.form.controls['name'].pristine ? undefined - : (this.form.controls["name"].value ?? undefined), + : (this.form.controls['name'].value ?? undefined), + roles: this.form.controls['roles'].pristine + ? undefined + : this.form.controls['roles'].value, }; } create(apiKey: GroupCreateInput): void { this.dataService.create(apiKey).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.created'); this.close(); }); } @@ -86,7 +105,7 @@ export class GroupFormPageComponent extends FormPageBase< update(apiKey: GroupUpdateInput): void { this.dataService.update(apiKey).subscribe(() => { this.spinner.hide(); - this.toast.success("action.created"); + this.toast.success('action.created'); this.close(); }); } diff --git a/web/src/app/modules/admin/groups/groups.columns.ts b/web/src/app/modules/admin/groups/groups.columns.ts index 20cc3c0..d00facf 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,12 +13,20 @@ 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, }, + { + name: 'roles', + translationKey: 'common.roles', + type: 'text', + filterable: true, + value: (row: Group) => row.roles.map(role => role.name).join(', '), + width: '300px', + }, ...DB_MODEL_COLUMNS, ]; } 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 d2620e0..93be7ec 100644 --- a/web/src/app/modules/admin/groups/groups.data.service.ts +++ b/web/src/app/modules/admin/groups/groups.data.service.ts @@ -1,24 +1,24 @@ -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 { 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 { 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 { Group, GroupCreateInput, GroupUpdateInput, -} from "src/app/model/entities/group"; +} from 'src/app/model/entities/group'; @Injectable() export class GroupsDataService @@ -31,7 +31,7 @@ export class GroupsDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo, + private apollo: Apollo ) { super(); } @@ -40,7 +40,7 @@ export class GroupsDataService filter?: Filter[] | undefined, sort?: Sort[] | undefined, skip?: number | undefined, - take?: number | undefined, + take?: number | undefined ): Observable> { return this.apollo .query<{ groups: QueryResult }>({ @@ -57,6 +57,10 @@ export class GroupsDataService nodes { id name + roles { + id + name + } ...DB_MODEL } @@ -73,12 +77,12 @@ export class GroupsDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.groups)); + .pipe(map(result => result.data.groups)); } loadById(id: number): Observable { @@ -89,6 +93,10 @@ export class GroupsDataService group(filter: { id: { equal: $id } }) { id name + roles { + id + name + } ...DB_MODEL } @@ -101,12 +109,24 @@ export class GroupsDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data.groups.nodes[0])); + .pipe(map(result => result.data.groups.nodes[0])); + } + + onChange(): Observable { + return this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onGroupChange { + groupChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)); } create(object: GroupCreateInput): Observable { @@ -129,16 +149,17 @@ export class GroupsDataService variables: { input: { name: object.name, + roles: object.roles?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.group.create)); + .pipe(map(result => result.data?.group.create)); } update(object: GroupUpdateInput): Observable { @@ -162,16 +183,17 @@ export class GroupsDataService input: { id: object.id, name: object.name, + roles: object.roles?.map(x => x.id), }, }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.group.update)); + .pipe(map(result => result.data?.group.update)); } delete(object: Group): Observable { @@ -189,12 +211,12 @@ export class GroupsDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.group.delete ?? false)); + .pipe(map(result => result.data?.group.delete ?? false)); } restore(object: Group): Observable { @@ -212,12 +234,12 @@ export class GroupsDataService }, }) .pipe( - catchError((err) => { + catchError(err => { this.spinner.hide(); throw err; - }), + }) ) - .pipe(map((result) => result.data?.group.restore ?? false)); + .pipe(map(result => result.data?.group.restore ?? false)); } static provide(): Provider[] { diff --git a/web/src/app/modules/admin/groups/groups.page.ts b/web/src/app/modules/admin/groups/groups.page.ts index f6a835e..e1447e7 100644 --- a/web/src/app/modules/admin/groups/groups.page.ts +++ b/web/src/app/modules/admin/groups/groups.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 { Group } from "src/app/model/entities/group"; -import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; -import { GroupsColumns } from "src/app/modules/admin/groups/groups.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 { Group } from 'src/app/model/entities/group'; +import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service'; +import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns'; @Component({ - selector: "app-groups", - templateUrl: "./groups.page.html", - styleUrl: "./groups.page.scss", + selector: 'app-groups', + templateUrl: './groups.page.html', + styleUrl: './groups.page.scss', }) export class GroupsPage extends PageBase< Group, @@ -19,7 +19,7 @@ export class GroupsPage extends PageBase< > { constructor( private toast: ToastService, - private confirmation: ConfirmationDialogService, + private confirmation: ConfirmationDialogService ) { super(true, { read: [PermissionsEnum.groups], @@ -30,11 +30,11 @@ export class GroupsPage 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 GroupsPage extends PageBase< delete(group: Group): 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(group).subscribe(() => { - this.toast.success("action.deleted"); + this.toast.success('action.deleted'); this.load(); }); }, @@ -57,12 +57,12 @@ export class GroupsPage extends PageBase< restore(group: Group): 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(group).subscribe(() => { - this.toast.success("action.restored"); + this.toast.success('action.restored'); this.load(); }); }, 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/form-page/short-url-form-page.component.html b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html index a078c82..8240320 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html @@ -6,7 +6,7 @@ (onClose)="close()">

- {{ 'common.group' | translate }} + {{ 'common.short_url' | translate }} {{ (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') | translate @@ -65,5 +65,20 @@ >

+
+

{{ 'common.domain' | translate }}

+
+ +
+
diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts index e8849f8..1143817 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts @@ -9,6 +9,7 @@ import { } from 'src/app/model/entities/short-url'; import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { Group } from 'src/app/model/entities/group'; +import { Domain } from 'src/app/model/entities/domain'; @Component({ selector: 'app-short-url-form-page', @@ -22,12 +23,16 @@ export class ShortUrlFormPageComponent extends FormPageBase< ShortUrlsDataService > { groups: Group[] = []; + domains: Domain[] = []; constructor(private toast: ToastService) { super(); this.dataService.getAllGroups().subscribe(groups => { this.groups = groups; }); + this.dataService.getAllDomains().subscribe(domains => { + this.domains = domains; + }); if (!this.nodeId) { this.node = this.new(); @@ -62,6 +67,7 @@ export class ShortUrlFormPageComponent extends FormPageBase< description: new FormControl(undefined), loadingScreen: new FormControl(undefined), groupId: new FormControl(undefined), + domainId: new FormControl(undefined), }); this.form.controls['id'].disable(); } @@ -86,6 +92,7 @@ export class ShortUrlFormPageComponent extends FormPageBase< this.form.controls['description'].setValue(node?.description); this.form.controls['loadingScreen'].setValue(node?.loadingScreen); this.form.controls['groupId'].setValue(node?.group?.id); + this.form.controls['domainId'].setValue(node?.domain?.id); } getCreateInput(): ShortUrlCreateInput { @@ -103,6 +110,9 @@ export class ShortUrlFormPageComponent extends FormPageBase< groupId: this.form.controls['groupId'].pristine ? undefined : (this.form.controls['groupId'].value ?? undefined), + domainId: this.form.controls['domainId'].pristine + ? undefined + : (this.form.controls['domainId'].value ?? undefined), }; } @@ -128,6 +138,9 @@ export class ShortUrlFormPageComponent extends FormPageBase< groupId: this.form.controls['groupId'].pristine ? undefined : (this.form.controls['groupId'].value ?? undefined), + domainId: this.form.controls['domainId'].pristine + ? undefined + : (this.form.controls['domainId'].value ?? undefined), }; } 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 7b865f8..7dca99f 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 @@ -20,6 +20,7 @@ import { ShortUrlUpdateInput, } from 'src/app/model/entities/short-url'; import { Group } from 'src/app/model/entities/group'; +import { Domain } from 'src/app/model/entities/domain'; @Injectable() export class ShortUrlsDataService @@ -66,6 +67,10 @@ export class ShortUrlsDataService id name } + domain { + id + name + } ...DB_MODEL } @@ -106,6 +111,10 @@ export class ShortUrlsDataService id name } + domain { + id + name + } ...DB_MODEL } @@ -126,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 onShortUrlChange { + shortUrlChange + } + `, + }) + .pipe(map(result => result.data?.shortUrlChange)); + } + create(object: ShortUrlCreateInput): Observable { return this.apollo .mutate<{ shortUrl: { create: ShortUrl } }>({ @@ -150,6 +171,7 @@ export class ShortUrlsDataService description: object.description, loadingScreen: object.loadingScreen, groupId: object.groupId, + domainId: object.domainId, }, }, }) @@ -187,6 +209,7 @@ export class ShortUrlsDataService description: object.description, loadingScreen: object.loadingScreen, groupId: object.groupId, + domainId: object.domainId, }, }, }) @@ -268,6 +291,29 @@ export class ShortUrlsDataService .pipe(map(result => result.data.groups.nodes)); } + getAllDomains() { + return this.apollo + .query<{ domains: QueryResult }>({ + query: gql` + query getGroups { + domains { + nodes { + id + name + } + } + } + `, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.domains.nodes)); + } + static provide(): Provider[] { return [ { diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.html b/web/src/app/modules/admin/short-urls/short-urls.page.html index 9515224..1a54a6c 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.page.html +++ b/web/src/app/modules/admin/short-urls/short-urls.page.html @@ -22,6 +22,10 @@
+
+ {{ 'common.domain' | translate }}: + {{ url.domain?.name }} +
@@ -105,7 +109,7 @@
-
+
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..a591713 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 @@ -8,9 +8,9 @@ import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-url import { ShortUrlsColumns } from 'src/app/modules/admin/short-urls/short-urls.columns'; import { AuthService } from 'src/app/service/auth.service'; 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 +64,7 @@ export class ShortUrlsPage private toast: ToastService, private confirmation: ConfirmationDialogService, private auth: AuthService, - private settings: SettingsService + private config: ConfigService ) { super(true, { read: [PermissionsEnum.shortUrls], @@ -95,8 +95,8 @@ export class ShortUrlsPage }; } - 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 => { @@ -174,7 +174,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 +206,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/service/common-data.service.ts b/web/src/app/modules/shared/service/common-data.service.ts new file mode 100644 index 0000000..90b22ba --- /dev/null +++ b/web/src/app/modules/shared/service/common-data.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Role } from 'src/app/model/entities/role'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { Apollo, gql } from 'apollo-angular'; +import { catchError, map } from 'rxjs/operators'; +import { SpinnerService } from 'src/app/service/spinner.service'; + +@Injectable({ + providedIn: 'root', +}) +export class CommonDataService { + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) {} + + 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)); + } +} diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts index ed6dfdd..ff7c0fe 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,11 +54,23 @@ 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'; +import { KeycloakService } from 'keycloak-angular'; const sharedModules = [ StepsModule, @@ -119,21 +128,82 @@ 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 keycloak = inject(KeycloakService); 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: async () => { + return { + Authorization: `Bearer ${await keycloak.getToken()}`, + }; + }, + 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 + }, + }) + ); + + const link = split( + ({ 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/auth.service.ts b/web/src/app/service/auth.service.ts index c4a939d..ed26d56 100644 --- a/web/src/app/service/auth.service.ts +++ b/web/src/app/service/auth.service.ts @@ -1,16 +1,16 @@ -import { Injectable } from "@angular/core"; -import { User } from "src/app/model/auth/user"; -import { BehaviorSubject, firstValueFrom, Observable } from "rxjs"; -import { Apollo, gql } from "apollo-angular"; -import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; -import { KeycloakService } from "keycloak-angular"; -import { map } from "rxjs/operators"; -import { Logger } from "src/app/service/logger.service"; +import { Injectable } from '@angular/core'; +import { User } from 'src/app/model/auth/user'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; +import { Apollo, gql } from 'apollo-angular'; +import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import { KeycloakService } from 'keycloak-angular'; +import { map } from 'rxjs/operators'; +import { Logger } from 'src/app/service/logger.service'; -const log = new Logger("AuthService"); +const log = new Logger('AuthService'); @Injectable({ - providedIn: "root", + providedIn: 'root', }) export class AuthService { protected anyPermissionForAdminPage = [ @@ -23,7 +23,7 @@ export class AuthService { constructor( private apollo: Apollo, - private keycloakService: KeycloakService, + private keycloakService: KeycloakService ) {} private requestUser() { @@ -60,11 +60,11 @@ export class AuthService { permission, }, }) - .pipe(map((result) => result.data.userHasPermission)); + .pipe(map(result => result.data.userHasPermission)); } private userHasAnyPermission( - permissions: PermissionsEnum[], + permissions: PermissionsEnum[] ): Observable { return this.apollo .query<{ userHasAnyPermission: boolean }>({ @@ -77,7 +77,7 @@ export class AuthService { permissions, }, }) - .pipe(map((result) => result.data.userHasAnyPermission)); + .pipe(map(result => result.data.userHasAnyPermission)); } loadUser() { @@ -88,8 +88,8 @@ export class AuthService { return; } - this.requestUser().subscribe((result) => { - log.info("User loaded"); + this.requestUser().subscribe(result => { + log.info('User loaded'); this.user$.next(result.data.user); }); } @@ -103,6 +103,14 @@ export class AuthService { return this.keycloakService.logout(); } + isLoggedIn(): boolean { + return ( + this.keycloakService.isLoggedIn() && + !this.keycloakService.isTokenExpired() && + !!this.user$.value + ); + } + async isAdmin(): Promise { return await this.hasAnyPermissionLazy(this.anyPermissionForAdminPage); } @@ -111,18 +119,15 @@ export class AuthService { if (!this.user$.value) return false; const userPermissions = this.user$.value.roles - .map((role) => (role.permissions ?? []).map((p) => p.name)) + .map(role => (role.permissions ?? []).map(p => p.name)) .flat(); - return permissions.every((permission) => - userPermissions.includes(permission), - ); + return permissions.some(permission => userPermissions.includes(permission)); } async hasAnyPermissionLazy(permissions: PermissionsEnum[]): Promise { if (this.user$.value && this.user$.value.roles) { return this.hasAnyPermission(permissions); } - return await firstValueFrom(this.userHasAnyPermission(permissions)); } @@ -130,7 +135,7 @@ export class AuthService { if (!this.user$.value) return false; const permissions = this.user$.value.roles - .map((role) => (role.permissions ?? []).map((p) => p.name)) + .map(role => (role.permissions ?? []).map(p => p.name)) .flat(); return permissions.includes(permission); } 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/error-handling.service.ts b/web/src/app/service/error-handling.service.ts index 1688cd8..802994a 100644 --- a/web/src/app/service/error-handling.service.ts +++ b/web/src/app/service/error-handling.service.ts @@ -1,22 +1,22 @@ -import { TranslateService } from "@ngx-translate/core"; -import { Logger } from "src/app/service/logger.service"; -import { ErrorHandler, Injectable } from "@angular/core"; -import { ToastService } from "src/app/service/toast.service"; -import { HttpErrorResponse } from "@angular/common/http"; -import { ApolloError } from "@apollo/client/errors"; -import { MissingPermissionException } from "src/app/model/utils/error"; -import { GraphQLError } from "graphql/error/GraphQLError"; -import { ToastOptions } from "src/app/model/utils/toast-options"; +import { TranslateService } from '@ngx-translate/core'; +import { Logger } from 'src/app/service/logger.service'; +import { ErrorHandler, Injectable } from '@angular/core'; +import { ToastService } from 'src/app/service/toast.service'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ApolloError } from '@apollo/client/errors'; +import { MissingPermissionException } from 'src/app/model/utils/error'; +import { GraphQLError } from 'graphql/error/GraphQLError'; +import { ToastOptions } from 'src/app/model/utils/toast-options'; -const logger = new Logger("ErrorHandler"); +const logger = new Logger('ErrorHandler'); @Injectable({ - providedIn: "root", + providedIn: 'root', }) export class ErrorHandlingService implements ErrorHandler { constructor( private t: TranslateService, - private toast: ToastService, + private toast: ToastService ) {} handleError(error: HttpErrorResponse | ApolloError) { @@ -30,7 +30,7 @@ export class ErrorHandlingService implements ErrorHandler { return; } console.error(error); - // this.handleHttpError(error); + this.handleHttpError(error); } private handleHttpError(e: HttpErrorResponse) { @@ -40,14 +40,14 @@ export class ErrorHandlingService implements ErrorHandler { error?: string; options?: ToastOptions; } = { - summary: this.t.instant("common.error"), + summary: this.t.instant('common.error'), detail: e.message, }; if (e.status === 401) { toast = { - summary: this.t.instant("common.error"), - detail: this.t.instant("error.unauthorized"), + summary: this.t.instant('common.error'), + detail: this.t.instant('error.unauthorized'), }; } @@ -57,15 +57,15 @@ export class ErrorHandlingService implements ErrorHandler { .permissions; toast = { - summary: this.t.instant("common.error"), - detail: this.t.instant("error.missing_permissions", { - permissions: missingPermissions.join(", "), + summary: this.t.instant('common.error'), + detail: this.t.instant('error.missing_permissions', { + permissions: missingPermissions.join(', '), }), }; } else { toast = { - summary: this.t.instant("common.error"), - detail: this.t.instant("error.permission_denied"), + summary: this.t.instant('common.error'), + detail: this.t.instant('error.permission_denied'), }; } } @@ -73,7 +73,7 @@ export class ErrorHandlingService implements ErrorHandler { if (e.status === 500 && e.error && e.error.errors) { if (e.error.errors.length > 0) { toast = { - summary: this.t.instant("common.api_error"), + summary: this.t.instant('common.api_error'), detail: e.error.errors[0].message, }; } @@ -86,8 +86,8 @@ export class ErrorHandlingService implements ErrorHandler { private handleGraphQlErrors(errors: GraphQLError[]) { errors.forEach((e: GraphQLError) => { this.toast.error( - this.t.instant("common.api_error"), - `${e.message}${e.path ? " " + e.path.join(".") : ""}`, + this.t.instant('common.api_error'), + `${e.message}${e.path ? ' ' + e.path.join('.') : ''}` ); }); } diff --git a/web/src/app/service/gui.service.ts b/web/src/app/service/gui.service.ts index 565f058..10fae79 100644 --- a/web/src/app/service/gui.service.ts +++ b/web/src/app/service/gui.service.ts @@ -3,6 +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 { ConfigService } from 'src/app/service/config.service'; const logger = new Logger('GuiService'); @@ -14,10 +15,12 @@ export class GuiService { isTablet$ = new BehaviorSubject(this.isTabletByWindowWith()); hideGui$ = new BehaviorSubject(false); + theme$ = new BehaviorSubject(this.config.settings.themes[0].name); constructor( private router: Router, - private sidebarService: SidebarService + private sidebarService: SidebarService, + 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 de948e3..dc25e41 100644 --- a/web/src/app/service/settings.service.ts +++ b/web/src/app/service/settings.service.ts @@ -1,30 +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 { Injectable } from '@angular/core'; +import { throwError } from 'rxjs'; +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", + 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; + .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 6c9d162..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(); @@ -30,6 +42,14 @@ export class SidebarService { // trust me, you'll need this async async setElements() { const elements: MenuElement[] = [ + { + label: 'common.domains', + icon: 'pi pi-sitemap', + routerLink: ['/admin/domains'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.domains, + ]), + }, { label: 'common.groups', icon: 'pi pi-tags', @@ -42,6 +62,7 @@ export class SidebarService { routerLink: ['/admin/urls'], visible: await this.auth.hasAnyPermissionLazy([ PermissionsEnum.shortUrls, + PermissionsEnum.shortUrlsByAssignment, ]), }, await this.groupAdministration(), @@ -84,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/app/service/user_settings.service.ts b/web/src/app/service/user_settings.service.ts new file mode 100644 index 0000000..1cd0539 --- /dev/null +++ b/web/src/app/service/user_settings.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@angular/core'; +import { throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { Setting } from 'src/app/model/entities/setting'; +import { Apollo, gql } from 'apollo-angular'; +import { Logger } from 'src/app/service/logger.service'; +import { AuthService } from 'src/app/service/auth.service'; + +const log = new Logger('UserSettings'); + +@Injectable({ + providedIn: 'root', +}) +export class UserSettingsService { + constructor( + private apollo: Apollo, + private auth: AuthService + ) {} + + get(key: string): Promise { + if (!this.auth.isLoggedIn()) { + log.debug('get', key, 'not logged in'); + return Promise.resolve(undefined); + } + + log.debug('get', key); + return new Promise((resolve, reject) => { + this.apollo + .query<{ userSettings: Setting[] }>({ + query: gql` + query getUserSetting($key: String!) { + userSettings(key: $key) { + key + value + } + } + `, + variables: { + key, + }, + }) + .pipe( + catchError(err => { + throw err; + }) + ) + .pipe( + map(result => + result.data.userSettings?.length > 0 + ? result.data.userSettings[0] + : undefined + ) + ) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(setting => { + log.debug('Got', key, setting); + resolve(setting?.value); + }); + }); + } + + set(key: string, value: string): Promise { + if (!this.auth.isLoggedIn()) { + log.debug('set', key, value, 'not logged in'); + return Promise.resolve(undefined); + } + + log.debug('set', key, value); + return new Promise((resolve, reject) => { + this.apollo + .mutate<{ userSetting: { change: Setting } }>({ + mutation: gql` + mutation changeUserSetting($input: UserSettingInput!) { + userSetting { + change(input: $input) { + key + value + } + } + } + `, + variables: { + input: { + key: key, + value: value, + }, + }, + }) + .pipe( + catchError(err => { + throw err; + }) + ) + .pipe(map(result => result.data?.userSetting.change)) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(() => { + resolve(); + }); + }); + } +} diff --git a/web/src/assets/config/config.json b/web/src/assets/config/config.json index e605683..a9090ca 100644 --- a/web/src/assets/config/config.json +++ b/web/src/assets/config/config.json @@ -5,11 +5,16 @@ "themes": [ { "label": "Open-redirect", - "name": "Open-redirect" + "name": "open-redirect" + }, + { + "label": "Maxlan", + "name": "maxlan" } ], "api": { "url": "", + "wsUrl": "", "redirector": "" }, "keycloak": { diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index c007eea..abff6b2 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -16,7 +16,6 @@ "all": "Alle", "api_error": "API Fehler", "api_key": "API Key", - "by": "von", "cancel": "Abbrechen", "choose": "Auswählen", "close": "Schließen", @@ -25,8 +24,9 @@ "created": "Erstellt", "deleted": "Gelöscht", "description": "Beschreibung", + "domain": "Domain", + "domains": "Domains", "download": "Herunterladen", - "edited_at": "Bearbeitet am", "editor": "Bearbeiter", "error": "Fehler", "group": "Gruppe", @@ -35,9 +35,10 @@ "identifier": "Identifier", "key": "Schlüssel", "name": "Name", - "news": "News", "role": "Rolle", + "roles": "Rollen", "save": "Speichern", + "short_url": "Url", "updated": "Bearbeitet", "upload": "Hochladen", "urls": "URLs", @@ -45,9 +46,7 @@ }, "dialog": { "abort": "Abbrechen", - "back": "Zurück", "confirm": "Bestätigen", - "continue": "Fortsetzen", "delete": { "header": "Löschen bestätigen", "message": "Sind Sie sicher, dass Sie {{entity}} löschen möchten?" @@ -55,19 +54,18 @@ "restore": { "header": "Wiederherstellung bestätigen", "message": "Sind Sie sicher, dass Sie {{entity}} wiederherstellen möchten?" - }, - "save": "Speichern" + } + }, + "domain": { + "count_header": "Domain(s)" }, "error": { "404": "404 - Nicht gefunden", - "create_failed": "Erstellung fehlgeschlagen", - "delete_failed": "Löschen fehlgeschlagen", "missing_permissions": "Fehlende Berechtigungen: {{permissions}}", - "network_error": "Netzwerkfehler", "permission_denied": "Fehlende Berechtigung", - "restore_failed": "Wiederherstellung fehlgeschlagen", - "unauthorized": "Nicht autorisiert", - "update_failed": "Bearbeitung fehlgeschlagen" + "retry": "Erneut versuchen", + "server_unavailable": "Server nicht erreichbar", + "unauthorized": "Nicht autorisiert" }, "footer": { "imprint": "Impressum", @@ -78,42 +76,51 @@ "count_header": "Gruppe(n)" }, "header": { - "logout": "Ausloggen", - "menu": { - "about": "Über uns", - "admin": "Admin" - } + "logout": "Ausloggen" + }, + "permission_descriptions": { + "short_urls": "Alle URLs sehen", + "short_urls.by_assignment": "Alle Kurz-URLs anzeigen, die einer Gruppe nach Rolle zugewiesen sind", + "users.update": "Benutzer inkl. der Rollen ändern" }, "permissions": { + "administrator": "Administrator", "api_keys": "API Keys", "api_keys.create": "Erstellen", "api_keys.delete": "Löschen", "api_keys.update": "Bearbeiten", + "domains": "Domains", + "domains.create": "Erstellen", + "domains.delete": "Löschen", + "domains.update": "Bearbeiten", + "groups": "Gruppen", + "groups.create": "Erstellen", + "groups.delete": "Löschen", + "groups.update": "Bearbeiten", "ip_list": "IP Liste", "ip_list.create": "Erstellen", "ip_list.delete": "Löschen", "ip_list.update": "Bearbeiten", - "news": "News", - "news.create": "Erstellen", - "news.delete": "Löschen", - "news.update": "Bearbeiten", "roles": "Rollen", "roles.create": "Erstellen", "roles.delete": "Löschen", "roles.update": "Bearbeiten", + "settings": "Einstellungen", + "settings.update": "Bearbeiten", + "short_urls": "Kurz-URLs", + "short_urls.by_assignment": "Zuweisung", + "short_urls.create": "Erstellen", + "short_urls.delete": "Löschen", + "short_urls.update": "Bearbeiten", "users": "Benutzer", "users.create": "Erstellen", "users.delete": "Löschen", "users.update": "Bearbeiten" }, - "qr": { - "width": "Breite" - }, "role": { "count_header": "Rolle(n)" }, "short_url": { - "count_header": "Url(s)", "loading_screen": "Ladebildschirm", "short_url": "URL", "target_url": "Ziel", @@ -122,12 +129,13 @@ "sidebar": { "administration": "Administration", "api_keys": "API Keys", + "feature_flags": "Funktionen", "header": { "create": "erstellen", "update": "bearbeiten" }, - "public": "Öffentlich", "roles": "Rollen", + "settings": "Einstellungen", "users": "Benutzer" }, "table": { @@ -139,11 +147,13 @@ "reset_filters": "Filter zurücksetzen", "reset_sort": "Sortierung zurücksetzen", "restore": "Wiederherstellen", + "select_columns": "Spalten auswählen", "show_deleted": "Gelöschte anzeigen", "to": "bis", "update": "Bearbeiten" }, "user": { + "assign_roles": "Rollen zuweisen", "count_header": "Benutzer", "email": "E-Mail", "user": "Benutzer", diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 72595c2..d8bfea7 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -16,7 +16,6 @@ "all": "All", "api_error": "API error", "api_key": "API Key", - "by": "by", "cancel": "Cancel", "choose": "Choose", "close": "Close", @@ -25,8 +24,9 @@ "created": "Created", "deleted": "Deleted", "description": "Description", + "domain": "Domain", + "domains": "Domains", "download": "Download", - "edited_at": "Edited at", "editor": "Editor", "error": "Fehler", "group": "Group", @@ -35,9 +35,10 @@ "identifier": "Identifier", "key": "Key", "name": "Name", - "news": "News", "role": "Role", + "roles": "Roles", "save": "Save", + "short_url": "Url", "updated": "Updated", "upload": "Upload", "urls": "URLs", @@ -45,9 +46,7 @@ }, "dialog": { "abort": "Abort", - "back": "Back", "confirm": "Confirm", - "continue": "Continue", "delete": { "header": "Confirm delete", "message": "Are you sure you want to delete {{entity}}?" @@ -55,19 +54,18 @@ "restore": { "header": "Confirm restore", "message": "Are you sure you want to restore {{entity}}?" - }, - "save": "Save" + } + }, + "domain": { + "count_header": "Domain(s)" }, "error": { "404": "404 - Not found", - "create_failed": "Create failed", - "delete_failed": "Delete failed", "missing_permissions": "Missing permissions: {{permissions}}", - "network_error": "Network Error", "permission_denied": "Permission Denied", - "restore_failed": "Restore failed", - "unauthorized": "Unauthorized", - "update_failed": "Update failed" + "retry": "Retry", + "server_unavailable": "Server unavailable", + "unauthorized": "Unauthorized" }, "footer": { "imprint": "Imprint", @@ -78,42 +76,51 @@ "count_header": "Group(s)" }, "header": { - "logout": "Logout", - "menu": { - "about": "About", - "admin": "Admin" - } + "logout": "Logout" + }, + "permission_descriptions": { + "short_urls": "See all URLs", + "short_urls.by_assignment": "See all short urls assigned to a group by role", + "users.update": "Change users including their roles" }, "permissions": { + "administrator": "Administrator", "api_keys": "API Keys", "api_keys.create": "Create", "api_keys.delete": "Delete", "api_keys.update": "Update", + "domains": "Domains", + "domains.create": "Create", + "domains.delete": "Delete", + "domains.update": "Update", + "groups": "Groups", + "groups.create": "Create", + "groups.delete": "Delete", + "groups.update": "Update", "ip_list": "IP list", "ip_list.create": "Create", "ip_list.delete": "Delete", "ip_list.update": "Update", - "news": "News", - "news.create": "Create", - "news.delete": "Delete", - "news.update": "Update", "roles": "Roles", "roles.create": "Create", "roles.delete": "Delete", "roles.update": "Update", + "settings": "Settings", + "settings.update": "Update", + "short_urls": "Short URLs", + "short_urls.by_assignment": "By assignment", + "short_urls.create": "Create", + "short_urls.delete": "Delete", + "short_urls.update": "Update", "users": "Users", "users.create": "Create", "users.delete": "Delete", "users.update": "Update" }, - "qr": { - "width": "Width" - }, "role": { "count_header": "Role(s)" }, "short_url": { - "count_header": "Url(s)", "loading_screen": "Loading screen", "short_url": "URL", "target_url": "Target", @@ -122,12 +129,13 @@ "sidebar": { "administration": "Administration", "api_keys": "API Keys", + "feature_flags": "Features", "header": { "create": "create", "update": "update" }, - "public": "Public", "roles": "Roles", + "settings": "Settings", "users": "Users" }, "table": { @@ -139,11 +147,13 @@ "reset_filters": "Reset filters", "reset_sort": "Reset sort", "restore": "Restore", + "select_columns": "Select columns", "show_deleted": "Show deleted", "to": "to", "update": "Update" }, "user": { + "assign_roles": "Assign roles", "count_header": "User(s)", "email": "E-Mail", "user": "User", diff --git a/web/src/styles.scss b/web/src/styles.scss index c823e30..ce42dc9 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -10,6 +10,7 @@ @import 'tailwindcss/utilities'; @import 'styles/theme'; +@import 'styles/theme_maxlan'; @import 'styles/tablet'; @import 'styles/mobile'; @@ -58,7 +59,6 @@ body { input, .p-checkbox-box, .p-dropdown { - border: 1px solid $accentColor; padding: 10px; } @@ -70,16 +70,6 @@ body { } } -@layer utilities { - .divider { - border-bottom: 1px solid $accentColor; - } - - .v-divider { - border-left: 1px solid $accentColor; - } -} - header { height: $headerHeight; display: flex; @@ -129,7 +119,6 @@ main { .component { margin: 0 10px; overflow: auto; - background-color: $backgroundColor; flex: 1; padding: 10px; } @@ -141,7 +130,6 @@ footer { display: flex; justify-content: center; align-items: center; - color: $textColor; } .btn { diff --git a/web/src/styles/theme.scss b/web/src/styles/theme.scss index 1ccb21d..c22df45 100644 --- a/web/src/styles/theme.scss +++ b/web/src/styles/theme.scss @@ -1,406 +1,414 @@ -$headerColor: #a2271f; +.open-redirect { + $headerColor: #ef9d0d; -// generated with https://colorffy.com/dark-theme-generator?colors=314390-121212 -$textColor: #fff; -$textColorHighlight: #314390; -$textColorHighlight2: #a2271f; + // generated with https://colorffy.com/dark-theme-generator?colors=314390-121212 + $textColor: #fff; + $textColorHighlight: #ef9d0d; + $textColorHighlight2: #b76f00; -// https://tailwindcss.com/docs/customizing-colors -$backgroundColor: #1e293b; // slate-800 -$backgroundColor2: #0f172a; // slate-900 -$backgroundColor3: #334155; // slate-700 + // https://tailwindcss.com/docs/customizing-colors + $backgroundColor: #1e293b; // slate-800 + $backgroundColor2: #0f172a; // slate-900 + $backgroundColor3: #334155; // slate-700 -$accentColor: #475569; // slate-600 -$infoColor: #1ea97c; -$warningColor: #ffc107; -$errorColor: #ff5252; + $accentColor: #475569; // slate-600 + $infoColor: #1ea97c; + $warningColor: #ffc107; + $errorColor: #ff5252; -body { background-color: $backgroundColor2; -} -@layer utilities { - .highlight { - color: $textColorHighlight; - } - - .highlight2 { - color: $textColorHighlight2 !important; - } - - .bg { - background-color: $backgroundColor; - } - - .bg2 { - background-color: $backgroundColor2; - } - - .bg3 { - background-color: $backgroundColor3; - } - - .accent { - color: $accentColor; - } - - .info { - color: $infoColor; - } - - .warning { - color: $warningColor; - } - - .error { - color: $errorColor; - } - - .deleted { - color: $accentColor !important; - } -} - -h1, -h2, -h3 { - color: $headerColor; -} - -.app { - .component { - background-color: $backgroundColor; - } -} - -.btn-base { - color: $textColor; - background: $textColorHighlight !important; - border: 1px solid $textColorHighlight; - - &:focus { - box-shadow: none !important; - } - - &:hover { - background: transparent !important; - } -} - -.btn { - .p-button { - @extend .btn-base; - } -} - -.icon-btn { - background-color: transparent !important; - border: none; - - .p-button { - color: $textColor; - background: transparent !important; - padding: 0; - - &:hover { + @layer utilities { + .highlight { color: $textColorHighlight; } - } -} -.text-btn { - background-color: transparent !important; - - .p-button { - color: $textColor; - background: transparent !important; - border: none !important; - - &:hover { - color: $textColorHighlight; - background-color: transparent !important; + .highlight2 { + color: $textColorHighlight2 !important; } - } -} -.icon-btn-without-hover { - &:hover { - background-color: transparent !important; - } -} - -.icon-btn { - &:hover { - background-color: transparent !important; - } -} - -.danger-btn, -.danger-icon-btn { - background-color: transparent !important; - - .p-button { - color: $textColor; - background: transparent !important; - - &:hover { - color: $errorColor; - } - } -} - -.hidden-columns-select { - .active-while-panel-open { - .p-button { - color: $textColorHighlight; - } - } -} - -.custom-spinner { - .p-progress-spinner-circle { - stroke: $textColorHighlight !important; - } -} - -p-paginator { - .p-paginator-element { - &:hover { - color: $textColorHighlight; - } - } -} - -.p-highlight { - color: $textColorHighlight2; - box-shadow: none !important; - - &:hover { - color: $textColorHighlight2 !important; - } -} - -.p-multiselect, -.p-paginator, -.p-dropdown, -input { - background-color: $backgroundColor; -} - -.p-inputtext:enabled:focus, -.p-inputtext:enabled:hover, -.p-multiselect:not(.p-disabled):hover, -.p-multiselect:not(.p-disabled).p-focus, -.p-dropdown:not(.p-disabled):hover, -.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover, -.p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus { - border-color: $textColorHighlight; - box-shadow: none !important; -} - -.p-checkbox .p-checkbox-box.p-highlight { - border-color: $textColorHighlight; - background: $textColorHighlight; - box-shadow: none !important; -} - -p-checkbox { - .p-checkbox { - .p-checkbox-box { + .bg { background-color: $backgroundColor; } - .p-checkbox-box.p-highlight { - border-color: $textColorHighlight; - background-color: $textColorHighlight; + .bg2 { + background-color: $backgroundColor2; + } + + .bg3 { + background-color: $backgroundColor3; + } + + .accent { + color: $accentColor; + } + + .info { + color: $infoColor; + } + + .warning { + color: $warningColor; + } + + .error { + color: $errorColor; + } + + .deleted { + color: $accentColor !important; + } + + .divider { + border-bottom: 1px solid $accentColor; } } -} -p-inputSwitch { - .p-inputswitch { - &.p-focus .p-inputswitch-slider { + h1, + h2, + h3 { + color: $headerColor; + } + + input, .p-checkbox-box, .p-dropdown { + border: 1px solid $accentColor; + } + + .app { + .component { + background-color: $backgroundColor; + } + } + + .btn-base { + color: $textColor; + background: $textColorHighlight !important; + border: 1px solid $textColorHighlight; + + &:focus { box-shadow: none !important; } - .p-inputswitch-slider { - background-color: $headerColor; + &:hover { + background: transparent !important; + } + } - &.p-highlight { - border-color: $textColorHighlight; - background: $textColorHighlight; - box-shadow: none !important; + .btn { + .p-button { + @extend .btn-base; + } + } + + .icon-btn { + background-color: transparent !important; + border: none; + + .p-button { + color: $textColor; + background: transparent !important; + padding: 0; + + &:hover { + color: $textColorHighlight; } } + } + + .text-btn { + background-color: transparent !important; + + .p-button { + color: $textColor; + background: transparent !important; + border: none !important; + + &:hover { + color: $textColorHighlight; + background-color: transparent !important; + } + } + } + + .icon-btn-without-hover { + &:hover { + background-color: transparent !important; + } + } + + .icon-btn { + &:hover { + background-color: transparent !important; + } + } + + .danger-btn, + .danger-icon-btn { + background-color: transparent !important; + + .p-button { + color: $textColor; + background: transparent !important; + + &:hover { + color: $errorColor; + } + } + } + + .hidden-columns-select { + .active-while-panel-open { + .p-button { + color: $textColorHighlight; + } + } + } + + .custom-spinner { + .p-progress-spinner-circle { + stroke: $textColorHighlight !important; + } + } + + p-paginator { + .p-paginator-element { + &:hover { + color: $textColorHighlight; + } + } + } + + .p-highlight { + color: $textColorHighlight2; + box-shadow: none !important; + + &:hover { + color: $textColorHighlight2 !important; + } + } + + .p-multiselect, + .p-paginator, + .p-dropdown, + input { + background-color: $backgroundColor; + } + + .p-inputtext:enabled:focus, + .p-inputtext:enabled:hover, + .p-multiselect:not(.p-disabled):hover, + .p-multiselect:not(.p-disabled).p-focus, + .p-dropdown:not(.p-disabled):hover, + .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover, + .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus { + border-color: $textColorHighlight; + box-shadow: none !important; + } + + .p-checkbox .p-checkbox-box.p-highlight { + border-color: $textColorHighlight; + background: $textColorHighlight; + box-shadow: none !important; + } + + p-checkbox { + .p-checkbox { + .p-checkbox-box { + background-color: $backgroundColor; + } + + .p-checkbox-box.p-highlight { + border-color: $textColorHighlight; + background-color: $textColorHighlight; + } + } + } + + p-inputSwitch { + .p-inputswitch { + &.p-focus .p-inputswitch-slider { + box-shadow: none !important; + } - &.p-inputswitch-checked { .p-inputswitch-slider { background-color: $headerColor; - &:before { - background-color: $textColor; + &.p-highlight { + border-color: $textColorHighlight; + background: $textColorHighlight; + box-shadow: none !important; + } + } + + &.p-inputswitch-checked { + .p-inputswitch-slider { + background-color: $headerColor; + + &:before { + background-color: $textColor; + } } } } } -} -p-panel-menu { - .p-panelmenu { - .p-panelmenu-content + p-panel-menu { + .p-panelmenu { + .p-panelmenu-content .p-menuitem:not(.p-highlight):not(.p-disabled) > .p-menuitem-content:hover .p-menuitem-link .p-menuitem-text, - .p-panelmenu-content + .p-panelmenu-content .p-menuitem:not(.p-highlight):not(.p-disabled) > .p-menuitem-content:hover .p-menuitem-link .p-menuitem-icon, - .p-panelmenu + .p-panelmenu .p-panelmenu-content .p-menuitem:not(.p-highlight):not(.p-disabled) > .p-menuitem-content:hover .p-menuitem-link .p-submenu-icon { - color: $textColorHighlight; - } + color: $textColorHighlight; + } - .p-panelmenu-header-content { - &:hover { - .p-panelmenu-header-action { - color: $textColorHighlight; + .p-panelmenu-header-content { + &:hover { + .p-panelmenu-header-action { + color: $textColorHighlight; + } } } } } -} -p-menubar { - .p-menubar { - background-color: $backgroundColor2; - } + p-menubar { + .p-menubar { + background-color: $backgroundColor2; + } - .p-menubar-root-list { - display: flex; - gap: 10px; - } + .p-menubar-root-list { + display: flex; + gap: 10px; + } - .p-menuitem { - border-radius: 0.5rem; + .p-menuitem { + border-radius: 0.5rem; - &:hover { - .p-menuitem-content .p-menuitem-link .p-menuitem-text { - color: $textColorHighlight2; + &:hover { + .p-menuitem-content .p-menuitem-link .p-menuitem-text { + color: $textColorHighlight2; + } + } + } + + .p-focus { + background-color: $backgroundColor2; + + .p-menuitem-content { + background-color: $backgroundColor2; } } } - .p-focus { - background-color: $backgroundColor2; + p-dropdown { + .p-dropdown:not(.p-disabled).p-focus { + box-shadow: none !important; + border-color: $headerColor !important; + } - .p-menuitem-content { + .p-dropdown-panel { background-color: $backgroundColor2; } } -} -p-dropdown { - .p-dropdown:not(.p-disabled).p-focus { - box-shadow: none !important; - border-color: $headerColor !important; + p-multiselect { + .p-multiselect-panel, + .p-multiselect-panel .p-multiselect-header { + background-color: $backgroundColor2; + } } - .p-dropdown-panel { - background-color: $backgroundColor2; - } -} + p-table { + .p-datatable { + .p-datatable-header, + .p-datatable-footer, + .p-datatable-thead > tr > th, + .p-datatable-tbody > tr { + background-color: $backgroundColor; + border-bottom: 1px solid $accentColor; + } -p-multiselect { - .p-multiselect-panel, - .p-multiselect-panel .p-multiselect-header { - background-color: $backgroundColor2; - } -} + .p-sortable-column.p-highlight, + .p-sortable-column:not(.p-highlight):hover { + background-color: transparent !important; + } -p-table { - .p-datatable { - .p-datatable-header, - .p-datatable-footer, - .p-datatable-thead > tr > th, - .p-datatable-tbody > tr { + .p-sortable-column:not(.p-highlight):hover, + .p-sortable-column:not(.p-highlight):hover .p-sortable-column-icon { + color: $textColorHighlight; + } + } + } + + p-sidebar { + .p-sidebar { background-color: $backgroundColor; - border-bottom: 1px solid $accentColor; - } - - .p-sortable-column.p-highlight, - .p-sortable-column:not(.p-highlight):hover { - background-color: transparent !important; - } - - .p-sortable-column:not(.p-highlight):hover, - .p-sortable-column:not(.p-highlight):hover .p-sortable-column-icon { - color: $textColorHighlight; } } -} -p-sidebar { - .p-sidebar { - background-color: $backgroundColor; - } -} - -.p-steps .p-steps-item.p-highlight .p-steps-number { - background-color: $headerColor; - color: $textColor; -} - -.p-dialog { - .p-dialog-header { - background-color: $backgroundColor; + .p-steps .p-steps-item.p-highlight .p-steps-number { + background-color: $headerColor; + color: $textColor; } - .p-dialog-content { - background-color: $backgroundColor; - padding-top: 1rem; + .p-dialog { + .p-dialog-header { + background-color: $backgroundColor; + } + + .p-dialog-content { + background-color: $backgroundColor; + padding-top: 1rem; + } + + .p-dialog-footer { + background-color: $backgroundColor; + } } - .p-dialog-footer { - background-color: $backgroundColor; + .p-badge { + background: $headerColor; + color: $textColor; } -} -.p-badge { - background: $headerColor; - color: $textColor; -} - -p-password { - background-color: transparent; - border: none; - - .p-password-panel { + p-password { background-color: transparent; - } -} + border: none; -p-slider { - .p-slider { - .p-slider-range { - background: $headerColor; + .p-password-panel { + background-color: transparent; } + } - .p-slider-handle { - border-color: $headerColor; - - &:hover { + p-slider { + .p-slider { + .p-slider-range { background: $headerColor; } - &:focus { - box-shadow: none !important; + .p-slider-handle { + border-color: $headerColor; + + &:hover { + background: $headerColor; + } + + &:focus { + box-shadow: none !important; + } } } } -} +} \ No newline at end of file diff --git a/web/src/styles/theme_maxlan.scss b/web/src/styles/theme_maxlan.scss new file mode 100644 index 0000000..bf27ab8 --- /dev/null +++ b/web/src/styles/theme_maxlan.scss @@ -0,0 +1,414 @@ +.maxlan { + $headerColor: #a2271f; + + // generated with https://colorffy.com/dark-theme-generator?colors=314390-121212 + $textColor: #fff; + $textColorHighlight: #314390; + $textColorHighlight2: #a2271f; + + // https://tailwindcss.com/docs/customizing-colors + $backgroundColor: #1e293b; // slate-800 + $backgroundColor2: #0f172a; // slate-900 + $backgroundColor3: #334155; // slate-700 + + $accentColor: #475569; // slate-600 + $infoColor: #1ea97c; + $warningColor: #ffc107; + $errorColor: #ff5252; + + background-color: $backgroundColor2; + + @layer utilities { + .highlight { + color: $textColorHighlight; + } + + .highlight2 { + color: $textColorHighlight2 !important; + } + + .bg { + background-color: $backgroundColor; + } + + .bg2 { + background-color: $backgroundColor2; + } + + .bg3 { + background-color: $backgroundColor3; + } + + .accent { + color: $accentColor; + } + + .info { + color: $infoColor; + } + + .warning { + color: $warningColor; + } + + .error { + color: $errorColor; + } + + .deleted { + color: $accentColor !important; + } + + .divider { + border-bottom: 1px solid $accentColor; + } + } + + h1, + h2, + h3 { + color: $headerColor; + } + + input, .p-checkbox-box, .p-dropdown { + border: 1px solid $accentColor; + } + + .app { + .component { + background-color: $backgroundColor; + } + } + + .btn-base { + color: $textColor; + background: $textColorHighlight !important; + border: 1px solid $textColorHighlight; + + &:focus { + box-shadow: none !important; + } + + &:hover { + background: transparent !important; + } + } + + .btn { + .p-button { + @extend .btn-base; + } + } + + .icon-btn { + background-color: transparent !important; + border: none; + + .p-button { + color: $textColor; + background: transparent !important; + padding: 0; + + &:hover { + color: $textColorHighlight; + } + } + } + + .text-btn { + background-color: transparent !important; + + .p-button { + color: $textColor; + background: transparent !important; + border: none !important; + + &:hover { + color: $textColorHighlight; + background-color: transparent !important; + } + } + } + + .icon-btn-without-hover { + &:hover { + background-color: transparent !important; + } + } + + .icon-btn { + &:hover { + background-color: transparent !important; + } + } + + .danger-btn, + .danger-icon-btn { + background-color: transparent !important; + + .p-button { + color: $textColor; + background: transparent !important; + + &:hover { + color: $errorColor; + } + } + } + + .hidden-columns-select { + .active-while-panel-open { + .p-button { + color: $textColorHighlight; + } + } + } + + .custom-spinner { + .p-progress-spinner-circle { + stroke: $textColorHighlight !important; + } + } + + p-paginator { + .p-paginator-element { + &:hover { + color: $textColorHighlight; + } + } + } + + .p-highlight { + color: $textColorHighlight2; + box-shadow: none !important; + + &:hover { + color: $textColorHighlight2 !important; + } + } + + .p-multiselect, + .p-paginator, + .p-dropdown, + input { + background-color: $backgroundColor; + } + + .p-inputtext:enabled:focus, + .p-inputtext:enabled:hover, + .p-multiselect:not(.p-disabled):hover, + .p-multiselect:not(.p-disabled).p-focus, + .p-dropdown:not(.p-disabled):hover, + .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box:hover, + .p-checkbox:not(.p-checkbox-disabled) .p-checkbox-box.p-focus { + border-color: $textColorHighlight; + box-shadow: none !important; + } + + .p-checkbox .p-checkbox-box.p-highlight { + border-color: $textColorHighlight; + background: $textColorHighlight; + box-shadow: none !important; + } + + p-checkbox { + .p-checkbox { + .p-checkbox-box { + background-color: $backgroundColor; + } + + .p-checkbox-box.p-highlight { + border-color: $textColorHighlight; + background-color: $textColorHighlight; + } + } + } + + p-inputSwitch { + .p-inputswitch { + &.p-focus .p-inputswitch-slider { + box-shadow: none !important; + } + + .p-inputswitch-slider { + background-color: $headerColor; + + &.p-highlight { + border-color: $textColorHighlight; + background: $textColorHighlight; + box-shadow: none !important; + } + } + + &.p-inputswitch-checked { + .p-inputswitch-slider { + background-color: $headerColor; + + &:before { + background-color: $textColor; + } + } + } + } + } + + p-panel-menu { + .p-panelmenu { + .p-panelmenu-content + .p-menuitem:not(.p-highlight):not(.p-disabled) + > .p-menuitem-content:hover + .p-menuitem-link + .p-menuitem-text, + .p-panelmenu-content + .p-menuitem:not(.p-highlight):not(.p-disabled) + > .p-menuitem-content:hover + .p-menuitem-link + .p-menuitem-icon, + .p-panelmenu + .p-panelmenu-content + .p-menuitem:not(.p-highlight):not(.p-disabled) + > .p-menuitem-content:hover + .p-menuitem-link + .p-submenu-icon { + color: $textColorHighlight; + } + + .p-panelmenu-header-content { + &:hover { + .p-panelmenu-header-action { + color: $textColorHighlight; + } + } + } + } + } + + p-menubar { + .p-menubar { + background-color: $backgroundColor2; + } + + .p-menubar-root-list { + display: flex; + gap: 10px; + } + + .p-menuitem { + border-radius: 0.5rem; + + &:hover { + .p-menuitem-content .p-menuitem-link .p-menuitem-text { + color: $textColorHighlight2; + } + } + } + + .p-focus { + background-color: $backgroundColor2; + + .p-menuitem-content { + background-color: $backgroundColor2; + } + } + } + + p-dropdown { + .p-dropdown:not(.p-disabled).p-focus { + box-shadow: none !important; + border-color: $headerColor !important; + } + + .p-dropdown-panel { + background-color: $backgroundColor2; + } + } + + p-multiselect { + .p-multiselect-panel, + .p-multiselect-panel .p-multiselect-header { + background-color: $backgroundColor2; + } + } + + p-table { + .p-datatable { + .p-datatable-header, + .p-datatable-footer, + .p-datatable-thead > tr > th, + .p-datatable-tbody > tr { + background-color: $backgroundColor; + border-bottom: 1px solid $accentColor; + } + + .p-sortable-column.p-highlight, + .p-sortable-column:not(.p-highlight):hover { + background-color: transparent !important; + } + + .p-sortable-column:not(.p-highlight):hover, + .p-sortable-column:not(.p-highlight):hover .p-sortable-column-icon { + color: $textColorHighlight; + } + } + } + + p-sidebar { + .p-sidebar { + background-color: $backgroundColor; + } + } + + .p-steps .p-steps-item.p-highlight .p-steps-number { + background-color: $headerColor; + color: $textColor; + } + + .p-dialog { + .p-dialog-header { + background-color: $backgroundColor; + } + + .p-dialog-content { + background-color: $backgroundColor; + padding-top: 1rem; + } + + .p-dialog-footer { + background-color: $backgroundColor; + } + } + + .p-badge { + background: $headerColor; + color: $textColor; + } + + p-password { + background-color: transparent; + border: none; + + .p-password-panel { + background-color: transparent; + } + } + + p-slider { + .p-slider { + .p-slider-range { + background: $headerColor; + } + + .p-slider-handle { + border-color: $headerColor; + + &:hover { + background: $headerColor; + } + + &:focus { + box-shadow: none !important; + } + } + } + } +} \ No newline at end of file