Compare commits

..

1 Commits

Author SHA1 Message Date
9f68929b34 Changed to asgi
Some checks failed
Test before pr merge / test-translation-lint (pull_request) Failing after 1m20s
Test before pr merge / test-lint (pull_request) Failing after 1m24s
Test before pr merge / test-before-merge (pull_request) Failing after 2m27s
2025-03-08 09:32:10 +01:00
118 changed files with 3052 additions and 1004 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -9,3 +9,4 @@ starlette==0.46.0
requests==2.32.3
python-keycloak==5.3.1
python-multipart==0.0.20
websockets==15.0

View File

@ -2,12 +2,12 @@ import functools
from functools import wraps
from inspect import iscoroutinefunction
from typing import Callable, Union, Optional
from urllib.request import Request
from flask import request
from flask_cors import cross_origin
from starlette.requests import Request
from starlette.routing import Route as StarletteRoute
from api.errors import unauthorized
from api.middleware.request import get_request
from api.route_user_extension import RouteUserExtension
from core.environment import Environment
from data.schemas.administration.api_key import ApiKey
@ -16,10 +16,10 @@ from data.schemas.administration.user import User
class Route(RouteUserExtension):
registered_routes = {}
registered_routes: list[StarletteRoute] = []
@classmethod
async def get_api_key(cls) -> ApiKey:
async def get_api_key(cls, request: Request) -> ApiKey:
auth_header = request.headers.get("Authorization", None)
api_key = auth_header.split(" ")[1]
return await apiKeyDao.find_by_key(api_key)
@ -35,41 +35,55 @@ class Route(RouteUserExtension):
return api_key_from_db is not None and not api_key_from_db.deleted
@classmethod
async def _get_auth_type(cls, auth_header: str) -> Optional[Union[User, ApiKey]]:
async def _get_auth_type(
cls, request: Request, auth_header: str
) -> Optional[Union[User, ApiKey]]:
if auth_header.startswith("Bearer "):
return await cls.get_user()
elif auth_header.startswith("API-Key "):
return await cls.get_api_key()
return await cls.get_api_key(request)
elif (
auth_header.startswith("DEV-User ")
and Environment.get_environment() == "development"
auth_header.startswith("DEV-User ")
and Environment.get_environment() == "development"
):
return await cls.get_dev_user()
return None
@classmethod
async def get_authenticated_user_or_api_key(cls) -> Union[User, ApiKey]:
request = get_request()
if request is None:
raise ValueError("No request found")
auth_header = request.headers.get("Authorization", None)
if not auth_header:
raise Exception("No Authorization header found")
user_or_api_key = await cls._get_auth_type(auth_header)
user_or_api_key = await cls._get_auth_type(request, auth_header)
if user_or_api_key is None:
raise Exception("Invalid Authorization header")
return user_or_api_key
@classmethod
async def get_authenticated_user_or_api_key_or_default(
cls,
cls,
) -> Optional[Union[User, ApiKey]]:
request = get_request()
if request is None:
return None
auth_header = request.headers.get("Authorization", None)
if not auth_header:
return None
return await cls._get_auth_type(auth_header)
return await cls._get_auth_type(request, auth_header)
@classmethod
async def is_authorized(cls) -> bool:
request = get_request()
if request is None:
return False
auth_header = request.headers.get("Authorization", None)
if not auth_header:
return False
@ -79,8 +93,8 @@ class Route(RouteUserExtension):
elif auth_header.startswith("API-Key "):
return await cls._verify_api_key(request)
elif (
auth_header.startswith("DEV-User ")
and Environment.get_environment() == "development"
auth_header.startswith("DEV-User ")
and Environment.get_environment() == "development"
):
user = await cls.get_dev_user()
return user is not None
@ -88,10 +102,10 @@ class Route(RouteUserExtension):
@classmethod
def authorize(
cls,
f: Callable = None,
skip_in_dev=False,
by_api_key=False,
cls,
f: Callable = None,
skip_in_dev=False,
by_api_key=False,
):
if f is None:
return functools.partial(
@ -99,26 +113,25 @@ class Route(RouteUserExtension):
)
@wraps(f)
async def decorator(*args, **kwargs):
async def decorator(request: Request, *args, **kwargs):
if skip_in_dev and Environment.get_environment() == "development":
if iscoroutinefunction(f):
return await f(*args, **kwargs)
return f(*args, **kwargs)
return await f(request, *args, **kwargs)
return f(request, *args, **kwargs)
if not await cls.is_authorized():
return unauthorized()
if iscoroutinefunction(f):
return await f(*args, **kwargs)
return f(*args, **kwargs)
return await f(request, *args, **kwargs)
return f(request, *args, **kwargs)
return decorator
@classmethod
def route(cls, path=None, **kwargs):
def inner(fn):
cross_origin(fn)
cls.registered_routes[path] = (fn, kwargs)
cls.registered_routes.append(StarletteRoute(path, fn, **kwargs))
return fn
return inner

View File

@ -1,10 +1,11 @@
from typing import Optional
from flask import request, Request, has_request_context
from keycloak import KeycloakAuthenticationError, KeycloakConnectionError
from starlette.requests import Request
from api.auth.keycloak_client import Keycloak
from api.auth.keycloak_user import KeycloakUser
from api.middleware.request import get_request
from core.get_value import get_value
from core.logger import Logger
from data.schemas.administration.user import User
@ -19,8 +20,8 @@ logger = Logger(__name__)
class RouteUserExtension:
@classmethod
def _get_user_id_from_token(cls) -> Optional[str]:
token = cls.get_token()
def _get_user_id_from_token(cls, request: Request) -> Optional[str]:
token = cls.get_token(request)
if not token:
return None
@ -34,7 +35,7 @@ class RouteUserExtension:
return get_value(user_info, "sub", str)
@staticmethod
def get_token() -> Optional[str]:
def get_token(request: Request) -> Optional[str]:
if "Authorization" not in request.headers:
return None
@ -45,23 +46,24 @@ class RouteUserExtension:
@classmethod
async def get_user(cls) -> Optional[User]:
if not has_request_context():
request = get_request()
if request is None:
return None
user_id = cls._get_user_id_from_token()
user_id = cls._get_user_id_from_token(request)
if not user_id:
return None
user = await userDao.find_by_keycloak_id(user_id)
if user is None:
return None
return user
return await userDao.find_by_keycloak_id(user_id)
@classmethod
async def get_dev_user(cls) -> Optional[User]:
request = get_request()
if request is None:
return None
return await userDao.find_single_by(
[{User.keycloak_id: cls.get_token()}, {User.deleted: False}]
[{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}]
)
@classmethod
@ -71,56 +73,6 @@ class RouteUserExtension:
user = await cls.get_dev_user()
return user
@classmethod
def _flatten_groups(cls, groups):
flat_list = []
for group in groups:
flat_list.append(group)
if "subGroups" in group and group["subGroups"]:
flat_list.extend(cls._flatten_groups(group["subGroups"]))
return flat_list
@classmethod
async def _map_keycloak_groups_with_roles(cls, user: User):
try:
roles = {x.name: x for x in await roleDao.get_all()}
groups = cls._flatten_groups(Keycloak.admin.get_groups(full_hierarchy=True))
groups_with_role = [x["name"] for x in groups if x["name"] in roles.keys()]
user_groups_with_role = [
x["name"]
for x in Keycloak.admin.get_user_groups(user.keycloak_id)
if x["name"] in roles.keys()
]
user_roles = set(
x.name for x in await user.roles if x.name in groups_with_role
)
missing_groups = set(user_groups_with_role) - set(user_roles)
missing_roles = set(user_roles) - set(user_groups_with_role)
if len(missing_groups) > 0:
await roleUserDao.create_many(
[
RoleUser(0, (await roleDao.get_by_name(group)).id, user.id)
for group in missing_groups
]
)
if len(missing_roles) > 0:
await roleUserDao.delete_many(
[
await roleUserDao.get_single_by(
[
{RoleUser.role_id: roles[role].id},
{RoleUser.user_id: user.id},
]
)
for role in missing_roles
]
)
except Exception as e:
logger.error("Failed to map user groups", e)
@classmethod
async def _create_user(cls, kc_user: KeycloakUser):
try:
@ -140,8 +92,8 @@ class RouteUserExtension:
logger.error("Failed to find or create user", e)
@classmethod
async def verify_login(cls, req: Request) -> bool:
auth_header = req.headers.get("Authorization", None)
async def verify_login(cls, request: Request) -> bool:
auth_header = request.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("Bearer "):
return False
@ -155,11 +107,8 @@ class RouteUserExtension:
user = await cls.get_user()
if user is None:
u_id = await cls._create_user(KeycloakUser(user_info))
await cls._map_keycloak_groups_with_roles(await userDao.get_by_id(u_id))
await cls._create_user(KeycloakUser(user_info))
return True
else:
await cls._map_keycloak_groups_with_roles(user)
if user.deleted:
return False

View File

@ -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)

View File

@ -4,12 +4,13 @@ from enum import Enum
from types import NoneType
from typing import Callable, Type, get_args, Any, Union
from ariadne import ObjectType
from ariadne import ObjectType, SubscriptionType
from graphql import GraphQLResolveInfo
from typing_extensions import deprecated
from api.route import Route
from api_graphql.abc.collection_filter_abc import CollectionFilterABC
from api_graphql.abc.field_abc import FieldABC
from api_graphql.abc.input_abc import InputABC
from api_graphql.abc.sort_abc import Sort
from api_graphql.field.collection_field import CollectionField
@ -20,6 +21,7 @@ from api_graphql.field.mutation_field import MutationField
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.field.resolver_field import ResolverField
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.field.subscription_field import SubscriptionField
from api_graphql.service.collection_result import CollectionResult
from api_graphql.service.exceptions import (
UnauthorizedException,
@ -29,6 +31,7 @@ from api_graphql.service.exceptions import (
from api_graphql.service.query_context import QueryContext
from api_graphql.typing import TRequireAnyPermissions, TRequireAnyResolvers
from core.logger import APILogger
from core.string import first_to_lower
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
@ -40,6 +43,7 @@ class QueryABC(ObjectType):
@abstractmethod
def __init__(self, name: str = __name__):
ObjectType.__init__(self, name)
self._subscriptions: dict[str, SubscriptionType] = {}
@staticmethod
async def _authorize():
@ -60,19 +64,19 @@ class QueryABC(ObjectType):
@classmethod
async def _require_any(
cls,
data: Any,
permissions: TRequireAnyPermissions,
resolvers: TRequireAnyResolvers,
*args,
**kwargs,
cls,
data: Any,
permissions: TRequireAnyPermissions,
resolvers: TRequireAnyResolvers,
*args,
**kwargs,
):
info = args[0]
if len(permissions) > 0:
user = await Route.get_authenticated_user_or_api_key_or_default()
perms = await user.permissions
has_perms = [await user.has_permission(x) for x in permissions]
if user is not None and all(
has_perms
[await user.has_permission(x) for x in permissions]
):
return
@ -93,13 +97,13 @@ class QueryABC(ObjectType):
raise AccessDenied()
def field(
self,
builder: Union[
DaoFieldBuilder,
CollectionFieldBuilder,
ResolverFieldBuilder,
MutationFieldBuilder,
],
self,
builder: Union[
DaoFieldBuilder,
CollectionFieldBuilder,
ResolverFieldBuilder,
MutationFieldBuilder,
],
):
"""
Add a field to the query
@ -134,7 +138,12 @@ class QueryABC(ObjectType):
skip = kwargs["skip"]
collection = await field.dao.find_by(filters, sorts, take, skip)
res = CollectionResult(await field.dao.count(), len(collection), collection)
if field.direct_result:
return collection
res = CollectionResult(
await field.dao.count(filters), len(collection), collection
)
return res
async def collection_wrapper(*args, **kwargs):
@ -171,11 +180,12 @@ class QueryABC(ObjectType):
)
async def resolver_wrapper(*args, **kwargs):
return (
result = (
await field.resolver(*args, **kwargs)
if iscoroutinefunction(field.resolver)
else field.resolver(*args, **kwargs)
)
return result
if isinstance(field, DaoField):
resolver = dao_wrapper
@ -189,7 +199,7 @@ class QueryABC(ObjectType):
elif isinstance(field, MutationField):
async def input_wrapper(
mutation: QueryABC, info: GraphQLResolveInfo, **kwargs
mutation: QueryABC, info: GraphQLResolveInfo, **kwargs
):
if field.input_type is None:
return await resolver_wrapper(mutation, info, **kwargs)
@ -205,6 +215,13 @@ class QueryABC(ObjectType):
resolver = input_wrapper
elif isinstance(field, SubscriptionField):
async def sub_wrapper(sub: QueryABC, info: GraphQLResolveInfo, **kwargs):
return await resolver_wrapper(sub, info, **kwargs)
resolver = sub_wrapper
else:
raise ValueError(f"Unknown field type: {field.name}")
@ -213,16 +230,21 @@ class QueryABC(ObjectType):
await self._authorize()
if (
field.require_any is None
and not field.public
and field.require_any_permission
field.require_any is None
and not field.public
and field.require_any_permission
):
await self._require_any_permission(field.require_any_permission)
result = await resolver(*args, **kwargs)
if field.require_any is not None:
await self._require_any(result, *field.require_any, *args, **kwargs)
await self._require_any(
result,
*field.require_any,
*args,
**kwargs,
)
return result
@ -230,13 +252,13 @@ class QueryABC(ObjectType):
@deprecated("Use field(FieldBuilder()) instead")
def mutation(
self,
name: str,
f: Callable,
input_type: Type[InputABC] = None,
input_key: str = "input",
require_any_permission: list[Permissions] = None,
public: bool = False,
self,
name: str,
f: Callable,
input_type: Type[InputABC] = None,
input_key: str = "input",
require_any_permission: list[Permissions] = None,
public: bool = False,
):
"""
Adds a mutation to the query
@ -252,6 +274,9 @@ class QueryABC(ObjectType):
self.field(
MutationFieldBuilder(name)
.with_resolver(f)
.with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
)
.with_input(input_type, input_key)
.with_require_any_permission(require_any_permission)
.with_public(public)
@ -259,13 +284,13 @@ class QueryABC(ObjectType):
@classmethod
def _resolve_collection(
cls,
collection: list,
*_,
filters: list[CollectionFilterABC] = None,
sort: list[Sort] = None,
skip: int = None,
take: int = None,
cls,
collection: list,
*_,
filters: list[CollectionFilterABC] = None,
sort: list[Sort] = None,
skip: int = None,
take: int = None,
) -> CollectionResult:
total_count = len(collection)
@ -273,6 +298,8 @@ class QueryABC(ObjectType):
for f in filters:
collection = list(filter(lambda x: f.filter(x), collection))
total_count = len(collection)
if sort is not None:
def f_sort(x: object, k: str):
@ -286,7 +313,7 @@ class QueryABC(ObjectType):
return attr
for s in reversed(
sort
sort
): # Apply sorting in reverse order to make first primary "orderBy" and other secondary "thenBy"
attrs = [a for a in dir(s) if not a.startswith("_")]
for k in attrs:

View File

@ -0,0 +1,50 @@
from abc import abstractmethod
from asyncio import iscoroutinefunction
from ariadne import SubscriptionType
from api_graphql.abc.query_abc import QueryABC
from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder
from core.logger import APILogger
logger = APILogger(__name__)
class SubscriptionABC(SubscriptionType, QueryABC):
@abstractmethod
def __init__(self):
SubscriptionType.__init__(self)
def subscribe(self, builder: SubscriptionFieldBuilder):
field = builder.build()
async def wrapper(*args, **kwargs):
if not field.public:
await self._authorize()
if (
field.require_any is None
and not field.public
and field.require_any_permission
):
await self._require_any_permission(field.require_any_permission)
result = (
await field.resolver(*args, **kwargs)
if iscoroutinefunction(field.resolver)
else field.resolver(*args, **kwargs)
)
if field.require_any is not None:
await self._require_any(
result,
*field.require_any,
*args,
**kwargs,
)
return result
self.set_field(field.name, wrapper)
self.set_source(field.name, field.generator)

View File

@ -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],

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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,
)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -26,10 +26,22 @@ input DomainSort {
updatedUtc: SortOrder
}
enum DomainFuzzyFields {
name
}
input DomainFuzzy {
fields: [DomainFuzzyFields]
term: String
threshold: Int
}
input DomainFilter {
id: IntFilter
name: StringFilter
fuzzy: DomainFuzzy
deleted: BooleanFilter
editor: IntFilter
createdUtc: DateFilter

View File

@ -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!
}

View File

@ -27,10 +27,22 @@ input GroupSort {
updatedUtc: SortOrder
}
enum GroupFuzzyFields {
name
}
input GroupFuzzy {
fields: [GroupFuzzyFields]
term: String
threshold: Int
}
input GroupFilter {
id: IntFilter
name: StringFilter
fuzzy: GroupFuzzy
deleted: BooleanFilter
editor: IntFilter
createdUtc: DateFilter

View File

@ -14,4 +14,8 @@ type Query {
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult
settings(key: String): [Setting]
userSettings(key: String): [Setting]
featureFlags(key: String): [FeatureFlag]
}

View File

@ -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
}

View File

@ -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!
}

View File

@ -32,12 +32,26 @@ input ShortUrlSort {
updatedUtc: SortOrder
}
enum ShortUrlFuzzyFields {
shortUrl
targetUrl
description
}
input ShortUrlFuzzy {
fields: [ShortUrlFuzzyFields]
term: String
threshold: Int
}
input ShortUrlFilter {
id: IntFilter
name: StringFilter
description: StringFilter
loadingScreen: BooleanFilter
fuzzy: ShortUrlFuzzy
deleted: BooleanFilter
editor: IntFilter
createdUtc: DateFilter

View File

@ -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
}

View File

@ -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
}

View File

@ -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!
}

View File

@ -26,6 +26,10 @@ from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.system.feature_flag_dao import featureFlagDao
from data.schemas.system.setting_dao import settingsDao
from service.permission.permissions_enum import Permissions
@ -127,6 +131,23 @@ class Query(QueryABC):
.with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
)
self.field(
ResolverFieldBuilder("settings")
.with_resolver(self._resolve_settings)
.with_direct_result()
.with_public(True)
)
self.field(
ResolverFieldBuilder("userSettings")
.with_resolver(self._resolve_user_settings)
.with_direct_result()
)
self.field(
ResolverFieldBuilder("featureFlags")
.with_resolver(self._resolve_feature_flags)
.with_direct_result()
)
@staticmethod
async def _get_user(*_):
return await Route.get_user()
@ -157,3 +178,27 @@ class Query(QueryABC):
for x in kc_users
if x["id"] not in existing_user_keycloak_ids
]
@staticmethod
async def _resolve_settings(*args, **kwargs):
if "key" in kwargs:
return [await settingsDao.find_by_key(kwargs["key"])]
return await settingsDao.get_all()
@staticmethod
async def _resolve_user_settings(*args, **kwargs):
user = await Route.get_user()
if user is None:
return None
if "key" in kwargs:
return await userSettingsDao.find_by(
{UserSetting.user_id: user.id, UserSetting.key: kwargs["key"]}
)
return await userSettingsDao.find_by({UserSetting.user_id: user.id})
@staticmethod
async def _resolve_feature_flags(*args, **kwargs):
if "key" in kwargs:
return [await featureFlagDao.find_by_key(kwargs["key"])]
return await featureFlagDao.get_all()

View File

@ -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,
)

View File

@ -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])
)

1
api/src/core/const.py Normal file
View File

@ -0,0 +1 @@
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f %z"

View File

@ -4,9 +4,12 @@ from enum import Enum
from types import NoneType
from typing import Generic, Optional, Union, TypeVar, Any, Type
from core.const import DATETIME_FORMAT
from core.database.abc.db_model_abc import DbModelABC
from core.database.database import Database
from core.get_value import get_value
from core.logger import DBLogger
from core.string import camel_to_snake
from core.typing import T, Attribute, AttributeFilters, AttributeSorts
T_DBM = TypeVar("T_DBM", bound=DbModelABC)
@ -23,7 +26,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
self._default_filter_condition = None
self.__attributes: dict[str, type] = {}
self.__joins: dict[str, str] = {}
self.__db_names: dict[str, str] = {}
self.__foreign_tables: dict[str, str] = {}
self.__date_attributes: set[str] = set()
self.__ignored_attributes: set[str] = set()
@ -69,6 +76,40 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if attr_type in [datetime, datetime.datetime]:
self.__date_attributes.add(db_name)
def reference(
self,
attr: Attribute,
primary_attr: Attribute,
foreign_attr: Attribute,
table_name: str,
):
"""
Add a reference to another table for the given attribute
:param str primary_attr: Name of the primary key in the foreign object
:param str foreign_attr: Name of the foreign key in the object
:param str table_name: Name of the table to reference
:return:
"""
if table_name == self._table_name:
return
if isinstance(attr, property):
attr = attr.fget.__name__
if isinstance(primary_attr, property):
primary_attr = primary_attr.fget.__name__
primary_attr = primary_attr.lower().replace("_", "")
if isinstance(foreign_attr, property):
foreign_attr = foreign_attr.fget.__name__
foreign_attr = foreign_attr.lower().replace("_", "")
self.__joins[foreign_attr] = (
f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
)
self.__foreign_tables[attr] = table_name
def to_object(self, result: dict) -> T_DBM:
"""
Convert a result from the database to an object
@ -89,8 +130,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return self._model_type(**value_map)
async def count(self) -> int:
result = await self._db.select_map(f"SELECT COUNT(*) FROM {self._table_name}")
async def count(self, filters: AttributeFilters = None) -> int:
query = f"SELECT COUNT(*) FROM {self._table_name}"
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {self._build_conditions(filters)}"
result = await self._db.select_map(query)
return result[0]["count"]
async def get_all(self) -> list[T_DBM]:
@ -384,6 +430,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return "ARRAY[]::text[]"
return f"ARRAY[{", ".join([DataAccessObjectABC._get_value_sql(x) for x in value])}]"
if isinstance(value, datetime.datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=datetime.timezone.utc)
return f"'{value.strftime(DATETIME_FORMAT)}'"
return str(value)
@staticmethod
@ -412,7 +464,10 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
take: int = None,
skip: int = None,
) -> str:
query = f"SELECT * FROM {self._table_name}"
query = f"SELECT {self._table_name}.* FROM {self._table_name}"
for join in self.__joins:
query += f" {self.__joins[join]}"
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {self._build_conditions(filters)}"
@ -438,12 +493,37 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
for attr, values in f.items():
if isinstance(attr, property):
attr = attr.fget.__name__
if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr]
conditions.extend(
self._build_foreign_conditions(foreign_table, values)
)
continue
if attr == "fuzzy":
conditions.append(
" OR ".join(
self._build_fuzzy_conditions(
[
self.__db_names[x] if x in self.__db_names else self.__db_names[camel_to_snake(x)]
for x in get_value(values, "fields", list[str])
],
get_value(values, "term", str),
get_value(values, "threshold", int, 5),
)
)
)
continue
db_name = self.__db_names[attr]
if isinstance(values, dict):
for operator, value in values.items():
conditions.append(
self._build_condition(db_name, operator, value)
self._build_condition(
f"{self._table_name}.{db_name}", operator, value
)
)
elif isinstance(values, list):
sub_conditions = []
@ -451,7 +531,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if isinstance(value, dict):
for operator, val in value.items():
sub_conditions.append(
self._build_condition(db_name, operator, val)
self._build_condition(
f"{self._table_name}.{db_name}", operator, val
)
)
else:
sub_conditions.append(
@ -463,12 +545,65 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return " AND ".join(conditions)
def _build_fuzzy_conditions(
self, fields: list[str], term: str, threshold: int = 10
) -> list[str]:
conditions = []
for field in fields:
conditions.append(
f"levenshtein({field}, '{term}') <= {threshold}"
) # Adjust the threshold as needed
return conditions
def _build_foreign_conditions(self, table: str, values: dict) -> list[str]:
"""
Build SQL conditions for foreign key references
:param table: Foreign table name
:param values: Filter values
:return: List of conditions
"""
conditions = []
for attr, sub_values in values.items():
if isinstance(attr, property):
attr = attr.fget.__name__
if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr]
conditions.extend(
self._build_foreign_conditions(foreign_table, sub_values)
)
continue
db_name = f"{table}.{attr.lower().replace('_', '')}"
if isinstance(sub_values, dict):
for operator, value in sub_values.items():
conditions.append(self._build_condition(db_name, operator, value))
elif isinstance(sub_values, list):
sub_conditions = []
for value in sub_values:
if isinstance(value, dict):
for operator, val in value.items():
sub_conditions.append(
self._build_condition(db_name, operator, val)
)
else:
sub_conditions.append(
self._get_value_validation_sql(db_name, value)
)
conditions.append(f"({' OR '.join(sub_conditions)})")
else:
conditions.append(self._get_value_validation_sql(db_name, sub_values))
return conditions
def _get_value_validation_sql(self, field: str, value: Any):
value = self._get_value_sql(value)
if value == "NULL":
return f"{field} IS NULL"
return f"{field} = {value}"
return f"{self._table_name}.{field} IS NULL"
return f"{self._table_name}.{field} = {value}"
def _build_condition(self, db_name: str, operator: str, value: Any) -> str:
"""
@ -530,6 +665,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if isinstance(attr, property):
attr = attr.fget.__name__
if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr]
sort_clauses.extend(
self._build_foreign_order_by(foreign_table, direction)
)
continue
match attr:
case "createdUtc":
attr = "created"
@ -547,6 +689,30 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return ", ".join(sort_clauses)
def _build_foreign_order_by(self, table: str, direction: str) -> list[str]:
"""
Build SQL order by clause for foreign key references
:param table: Foreign table name
:param direction: Sort direction
:return: List of order by clauses
"""
sort_clauses = []
for attr, sub_direction in direction.items():
if isinstance(attr, property):
attr = attr.fget.__name__
if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr]
sort_clauses.extend(
self._build_foreign_order_by(foreign_table, sub_direction)
)
continue
db_name = f"{table}.{attr.lower().replace('_', '')}"
sort_clauses.append(f"{db_name} {sub_direction.upper()}")
return sort_clauses
@staticmethod
async def _get_editor_id(obj: T_DBM):
editor_id = obj.editor_id

View File

@ -1,2 +1,8 @@
import re
def first_to_lower(s: str) -> str:
return s[0].lower() + s[1:] if s else s
def camel_to_snake(s: str) -> str:
return re.sub(r'(?<!^)(?=[A-Z])', '_', s).lower()

View File

@ -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
"""

26
web/ngx-translate-lint.json Executable file
View File

@ -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"
}

209
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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({

View File

@ -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<void> {
return (): Promise<void> =>
new Promise<void>((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,

View File

@ -1,8 +1,10 @@
<div class="w-full h-full flex flex-col justify-center items-center">
<div class="bg-2 padding-10 rounded-15">
<h1 class="flex justify-center items-center">
{{ 'error.404' | translate }}
</h1>
<img src="/assets/not_found.gif" alt="" />
<div class="bg2 flex p-10 rounded-xl text-center">
<div class="flex flex-col gap-5">
<h1 class="flex justify-center items-center">
{{ 'error.404' | translate }}
</h1>
<img src="/assets/not_found.gif" alt="" />
</div>
</div>
</div>

View File

@ -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<NotFoundComponent>;
@ -20,7 +20,7 @@ describe("NotFoundComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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 {}

View File

@ -0,0 +1,12 @@
<div class="w-full h-full flex flex-col justify-center items-center">
<div class="bg2 flex p-10 rounded-xl text-center">
<div class="flex flex-col gap-5">
<h1 class="flex justify-center items-center">
{{ 'error.server_unavailable' | translate }}
</h1>
<p-button (onClick)="retryConnection()" class="btn btn-primary">
{{ 'error.retry' | translate }}
</p-button>
</div>
</div>
</div>

View File

@ -0,0 +1,4 @@
h1 {
color: #a03033;
font-size: 3rem !important;
}

View File

@ -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<NotFoundComponent>;
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();
});
});

View File

@ -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(['/']);
}
}

View File

@ -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;
}
}

View File

@ -9,6 +9,7 @@ import { AuthService } from 'src/app/service/auth.service';
import { MenuElement } from 'src/app/model/view/menu-element';
import { SidebarService } from 'src/app/service/sidebar.service';
import { SettingsService } from 'src/app/service/settings.service';
import { ConfigService } from 'src/app/service/config.service';
@Component({
selector: 'app-header',
@ -28,11 +29,11 @@ export class HeaderComponent implements OnInit, OnDestroy {
constructor(
private translateService: TranslateService,
private config: PrimeNGConfig,
private ngConfig: PrimeNGConfig,
private guiService: GuiService,
private auth: AuthService,
private sidebarService: SidebarService,
private settings: SettingsService
private config: ConfigService
) {
this.guiService.isMobile$
.pipe(takeUntil(this.unsubscribe$))
@ -48,7 +49,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
await this.initMenuLists();
});
this.themeList = this.settings.settings.themes.map(theme => {
this.themeList = this.config.settings.themes.map(theme => {
return {
label: theme.label,
command: () => {
@ -122,7 +123,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.translateService.use(lang);
this.translateService
.get('primeng')
.subscribe(res => this.config.setTranslation(res));
.subscribe(res => this.ngConfig.setTranslation(res));
}
async loadLang() {

View File

@ -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();

View File

@ -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<T>[] =
"get" in this.columnsService ? this.columnsService.get() : [];
'get' in this.columnsService ? this.columnsService.get() : [];
protected unsubscribe$ = new Subject<void>();
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;
}

View File

@ -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<T> {
abstract get(): TableColumn<T>[];
}
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 = [

View File

@ -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<T> {
abstract load(
filter?: Filter[],
sort?: Sort[],
skip?: number,
take?: number,
take?: number
): Observable<QueryResult<T>>;
abstract loadById(id: number): Observable<T>;
abstract onChange(): Observable<void>;
}
export interface Create<T, C> {
@ -29,12 +31,12 @@ export interface Update<T, U> {
export interface Delete<T> {
delete(
object: T,
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}
export interface Restore<T> {
restore(
object: T,
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}

View File

@ -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<boolean> {
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',

View File

@ -1,5 +1,7 @@
export enum PermissionsEnum {
// Administration
administrator = 'administrator',
apiKeys = 'api_keys',
apiKeysCreate = 'api_keys.create',
apiKeysUpdate = 'api_keys.update',

View File

@ -23,5 +23,6 @@ export interface KeycloakSettings {
export interface ApiSettings {
url: string;
wsUrl: string;
redirector: string;
}

View File

@ -0,0 +1,4 @@
export interface FeatureFlag {
key: string;
value: boolean;
}

View File

@ -0,0 +1,4 @@
export interface Setting {
key: string;
value: string;
}

View File

@ -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({

View File

@ -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<ApiKey> {
@ -13,16 +13,16 @@ export class ApiKeysColumns extends PageColumns<ApiKey> {
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,

View File

@ -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<QueryResult<ApiKey>> {
return this.apollo
.query<{ apiKeys: QueryResult<ApiKey> }>({
@ -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<ApiKey> {
@ -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<void> {
return this.apollo
.subscribe<{ apiKeyChange: void }>({
query: gql`
subscription onApiKeyChange {
apiKeyChange
}
`,
})
.pipe(map(result => result.data?.apiKeyChange));
}
create(object: ApiKeyCreateInput): Observable<ApiKey | undefined> {
@ -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<ApiKey | undefined> {
@ -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<boolean> {
@ -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<boolean> {
@ -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<Permission[]> {
@ -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[] {

View File

@ -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: {

View File

@ -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<ApiKeysPage>;
@ -45,7 +45,7 @@ describe("ApiKeysComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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();
});
},

View File

@ -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<RoleFormPageComponent>;
@ -44,7 +44,7 @@ describe("ApiKeyFormpageComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean>(false));
});
}
@ -77,48 +77,48 @@ export class ApiKeyFormPageComponent extends FormPageBase<
id: new FormControl<number | undefined>(undefined),
identifier: new FormControl<string | undefined>(
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
);
}

View File

@ -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<FeatureFlag[]> {
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<FeatureFlag> {
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<void> {
return this.apollo
.subscribe<{ featureFlagChange: void }>({
query: gql`
subscription onFeatureFlagChange {
featureFlagChange
}
`,
})
.pipe(map(result => result.data?.featureFlagChange));
}
change(object: FeatureFlag): Observable<FeatureFlag | undefined> {
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];
}
}

View File

@ -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 {}

View File

@ -0,0 +1,10 @@
<div class="flex flex-col gap-2">
<div class="grid grid-cols-4 gap-1" *ngFor="let flag of flags">
<div><h2>{{ flag.key }}</h2></div>
<div>
<p-inputSwitch
[ngModel]="flag.value"
(onChange)="change(flag, $event)"></p-inputSwitch>
</div>
</div>
</div>

View File

@ -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<FeatureFlagsPage>;
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();
});
});

View File

@ -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<void>();
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();
});
}
}

View File

@ -1,73 +1,73 @@
<app-form-page
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.role' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.role' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p>
<input pInputText class="value" type="text" formControlName="name"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.description' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="description"/>
</div>
<div class="divider"></div>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p>
<input pInputText class="value" type="text" formControlName="name"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.description' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="description"/>
</div>
<div class="divider"></div>
<div class="flex flex-col gap-5">
<div *ngFor="let group of Object.keys(permissionGroups)">
<div class="flex flex-col gap-2">
<div class="flex justify-between">
<label class="flex-1" for="roles.permission_groups.{{ group }}">
<h3>
{{ 'permissions.' + group | translate }}
</h3>
</label>
<form #form="ngForm">
<p-inputSwitch
name="roles.permission_groups.{{ group }}"
(onChange)="toggleGroup($event, group)"
[ngModel]="isGroupChecked(group)"></p-inputSwitch>
</form>
</div>
<div class="flex flex-col gap-5">
<div *ngFor="let group of Object.keys(permissionGroups)">
<div class="flex flex-col gap-2">
<div class="flex justify-between">
<label class="flex-1" for="roles.permission_groups.{{ group }}">
<h3>
{{ 'permissions.' + group | translate }}
</h3>
</label>
<form #form="ngForm">
<p-inputSwitch
name="roles.permission_groups.{{ group }}"
(onChange)="toggleGroup($event, group)"
[ngModel]="isGroupChecked(group)"></p-inputSwitch>
</form>
</div>
<div
*ngFor="let permission of permissionGroups[group]"
class="flex flex-col">
<div class="flex items-center justify-between w-full">
<label class="flex-1" for="{{ permission.name }}">
{{ 'permissions.' + permission.name | translate }}
</label>
<p-inputSwitch class="flex items-center justify-center" [formControlName]="permission.name">
</p-inputSwitch>
</div>
<div *ngIf="permission.description">
<p class="text-sm text-gray-500">
{{ permission.description }}
</p>
</div>
</div>
</div>
<div
*ngFor="let permission of permissionGroups[group]"
class="flex flex-col">
<div class="flex items-center justify-between w-full">
<label class="flex-1" for="{{ permission.name }}">
{{ 'permissions.' + permission.name | translate }}
</label>
<p-inputSwitch class="flex items-center justify-center" [formControlName]="permission.name">
</p-inputSwitch>
</div>
<div *ngIf="permission.description">
<p class="text-sm text-gray-500">
{{ permission.description }}
</p>
</div>
</div>
</div>
</ng-template>
</div>
</div>
</ng-template>
</app-form-page>

View File

@ -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<RoleFormPageComponent>;
@ -44,7 +44,7 @@ describe("RoleFormpageComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -142,7 +142,7 @@ export class RoleFormPageComponent extends FormPageBase<
update(role: RoleUpdateInput): void {
this.dataService.update(role).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.toast.success('action.updated');
this.close();
});
}

View File

@ -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<Role> {

View File

@ -120,6 +120,18 @@ export class RolesDataService
.pipe(map(result => result.data.roles.nodes[0]));
}
onChange(): Observable<void> {
return this.apollo
.subscribe<{ roleChange: void }>({
query: gql`
subscription onRoleChange {
roleChange
}
`,
})
.pipe(map(result => result.data?.roleChange));
}
create(object: RoleCreateInput): Observable<Role | undefined> {
return this.apollo
.mutate<{ role: { create: Role } }>({
@ -252,7 +264,6 @@ export class RolesDataService
nodes {
id
name
description
}
}
}

View File

@ -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: {

View File

@ -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<RolesPage>;
@ -45,7 +45,7 @@ describe("RolesComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<Role, RolesDataService, RolesColumns> {
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService,
private confirmation: ConfirmationDialogService
) {
super(true, {
read: [PermissionsEnum.roles],
@ -26,11 +26,11 @@ export class RolesPage extends PageBase<Role, RolesDataService, RolesColumns> {
});
}
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<Role, RolesDataService, RolesColumns> {
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<Role, RolesDataService, RolesColumns> {
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();
});
},

View File

@ -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<Setting[]> {
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<Setting> {
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<void> {
return this.apollo
.subscribe<{ settingChange: void }>({
query: gql`
subscription onSettingChange {
settingChange
}
`,
})
.pipe(map(result => result.data?.settingChange));
}
change(object: Setting): Observable<Setting | undefined> {
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];
}
}

View File

@ -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 {}

View File

@ -0,0 +1,20 @@
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-2" [formGroup]="settingsForm">
<div class="grid grid-cols-4 gap-1" *ngFor="let setting of settings">
<div><h2>{{ setting.key }}</h2></div>
<div>
<input type="text" pInputText [formControlName]="setting.key" />
</div>
</div>
</div>
<div class="flex items-start">
<p-button
label="{{ 'common.save' | translate }}"
class="btn"
icon="pi pi-save"
(onClick)="save()"
[disabled]="settingsForm.invalid || !hasChanges">
</p-button>
</div>
</div>

View File

@ -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<SettingsPage>;
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();
});
});

View File

@ -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<void>();
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());
}
}
}

View File

@ -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<RoleFormPageComponent>;
@ -44,7 +44,7 @@ describe("UserFormpageComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -10,7 +10,6 @@ import {
} from 'src/app/model/auth/user';
import { Role } from 'src/app/model/entities/role';
import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service';
import { CommonDataService } from 'src/app/modules/shared/service/common-data.service';
@Component({
selector: 'app-user-form-page',
@ -26,12 +25,9 @@ export class UserFormPageComponent extends FormPageBase<
notExistingUsers: NotExistingUser[] = [];
roles: Role[] = [];
constructor(
private toast: ToastService,
private cds: CommonDataService
) {
constructor(private toast: ToastService) {
super();
this.cds.getAllRoles().subscribe(roles => {
this.dataService.getAllRoles().subscribe(roles => {
this.roles = roles;
});
@ -111,7 +107,7 @@ export class UserFormPageComponent extends FormPageBase<
update(user: UserUpdateInput): void {
this.dataService.update(user).subscribe(() => {
this.spinner.hide();
this.toast.success('action.created');
this.toast.success('action.updated');
this.close();
});
}

View File

@ -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<User> {
@ -13,15 +13,15 @@ export class UsersColumns extends PageColumns<User> {
return [
ID_COLUMN,
{
name: "username",
label: "user.username",
type: "text",
name: 'username',
translationKey: 'user.username',
type: 'text',
value: (row: User) => row.username,
},
{
name: "email",
label: "user.email",
type: "text",
name: 'email',
translationKey: 'user.email',
type: 'text',
value: (row: User) => row.email,
},
...DB_MODEL_COLUMNS,

View File

@ -123,6 +123,18 @@ export class UsersDataService
.pipe(map(result => result.data.users.nodes[0]));
}
onChange(): Observable<void> {
return this.apollo
.subscribe<{ userChange: void }>({
query: gql`
subscription onUserChange {
userChange
}
`,
})
.pipe(map(result => result.data?.userChange));
}
create(object: UserCreateInput): Observable<User | undefined> {
return this.apollo
.mutate<{ user: { create: User } }>({
@ -239,6 +251,29 @@ export class UsersDataService
.pipe(map(result => result.data?.user.restore ?? false));
}
getAllRoles(): Observable<Role[]> {
return this.apollo
.query<{ roles: QueryResult<Role> }>({
query: gql`
query getRoles {
roles {
nodes {
id
name
}
}
}
`,
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data.roles.nodes));
}
getNotExistingUsersFromKeycloak(): Observable<NotExistingUser[]> {
return this.apollo
.query<{ notExistingUsersFromKeycloak: QueryResult<NotExistingUser> }>({

View File

@ -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: {

View File

@ -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<UsersPage>;
@ -45,7 +45,7 @@ describe("UsersComponent", () => {
fixture.detectChanges();
});
it("should create", () => {
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<User, UsersDataService, UsersColumns> {
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService,
private confirmation: ConfirmationDialogService
) {
super(true, {
read: [PermissionsEnum.users],
@ -26,11 +26,11 @@ export class UsersPage extends PageBase<User, UsersDataService, UsersColumns> {
});
}
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<User, UsersDataService, UsersColumns> {
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<User, UsersDataService, UsersColumns> {
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();
});
},

View File

@ -14,7 +14,7 @@ export class DomainsColumns extends PageColumns<Domain> {
ID_COLUMN,
{
name: 'name',
label: 'common.name',
translationKey: 'common.name',
type: 'text',
filterable: true,
value: (row: Domain) => row.name,

View File

@ -109,6 +109,18 @@ export class DomainsDataService
.pipe(map(result => result.data.domains.nodes[0]));
}
onChange(): Observable<void> {
return this.apollo
.subscribe<{ domainChange: void }>({
query: gql`
subscription onRoleChange {
domainChange
}
`,
})
.pipe(map(result => result.data?.domainChange));
}
create(object: DomainCreateInput): Observable<Domain | undefined> {
return this.apollo
.mutate<{ domain: { create: Domain } }>({

View File

@ -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<Group> {
@ -13,9 +13,9 @@ export class GroupsColumns extends PageColumns<Group> {
return [
ID_COLUMN,
{
name: "name",
label: "common.name",
type: "text",
name: 'name',
translationKey: 'common.name',
type: 'text',
filterable: true,
value: (row: Group) => row.name,
},

View File

@ -117,6 +117,18 @@ export class GroupsDataService
.pipe(map(result => result.data.groups.nodes[0]));
}
onChange(): Observable<void> {
return this.apollo
.subscribe<{ groupChange: void }>({
query: gql`
subscription onRoleChange {
groupChange
}
`,
})
.pipe(map(result => result.data?.groupChange));
}
create(object: GroupCreateInput): Observable<Group | undefined> {
return this.apollo
.mutate<{ group: { create: Group } }>({

View File

@ -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 {}

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