diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index 585390d..ec2cebd 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -51,6 +51,10 @@ class RouteUserExtension: if request is None: return None + dev_user = await cls.get_dev_user() + if dev_user is not None: + return dev_user + user_id = cls._get_user_id_from_token(request) if not user_id: return None @@ -63,6 +67,15 @@ class RouteUserExtension: if request is None: return None + if "Authorization" not in request.headers: + return None + + if len(request.headers.get("Authorization").split()) < 2: + return None + + if request.headers.get("Authorization").split()[0] != "DEV-User": + return None + return await userDao.find_by_keycloak_id(cls.get_token(request)) @classmethod diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index c3d2ca6..84975cf 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -174,9 +174,9 @@ class QueryABC(ObjectType): kwargs["sort"] = None if iscoroutinefunction(field.resolver): - collection = await field.collection_resolver(*args) + collection = await field.resolver(*args) else: - collection = field.collection_resolver(*args) + collection = field.resolver(*args) return self._resolve_collection( collection, diff --git a/api/src/api_graphql/field/collection_field.py b/api/src/api_graphql/field/collection_field.py index e41d81e..0c586a9 100644 --- a/api/src/api_graphql/field/collection_field.py +++ b/api/src/api_graphql/field/collection_field.py @@ -15,7 +15,7 @@ class CollectionField(FieldABC): require_any_permission: list[Permissions] = None, require_any: TRequireAny = None, public: bool = False, - collection_resolver: Callable = None, + resolver: Callable = None, filter_type: Type[CollectionFilterABC] = None, sort_type: Type[T] = None, ): @@ -23,13 +23,13 @@ class CollectionField(FieldABC): self._name = name self._public = public - self._collection_resolver = collection_resolver + self._resolver = resolver self._filter_type = filter_type self._sort_type = sort_type @property - def collection_resolver(self) -> Optional[Callable]: - return self._collection_resolver + def resolver(self) -> Optional[Callable]: + return self._resolver @property def filter_type( diff --git a/api/src/api_graphql/field/collection_field_builder.py b/api/src/api_graphql/field/collection_field_builder.py index 8a52b9c..355fda7 100644 --- a/api/src/api_graphql/field/collection_field_builder.py +++ b/api/src/api_graphql/field/collection_field_builder.py @@ -11,13 +11,13 @@ class CollectionFieldBuilder(FieldBuilderABC): def __init__(self, name: str): FieldBuilderABC.__init__(self, name) - self._collection_resolver = None + self._resolver = None self._filter_type = None self._sort_type = None - def with_collection_resolver(self, collection_resolver: Callable) -> Self: - assert collection_resolver is not None, "collection_resolver cannot be None" - self._collection_resolver = collection_resolver + def with_resolver(self, resolver: Callable) -> Self: + assert resolver is not None, "resolver cannot be None" + self._resolver = resolver return self def with_filter(self, filter_type: Type[CollectionFilterABC]) -> Self: @@ -31,16 +31,14 @@ class CollectionFieldBuilder(FieldBuilderABC): return self def build(self) -> CollectionField: - assert ( - self._collection_resolver is not None - ), "collection_resolver cannot be None" + assert self._resolver is not None, "resolver cannot be None" return CollectionField( self._name, self._require_any_permission, self._require_any, self._public, - self._collection_resolver, + self._resolver, self._filter_type, self._sort_type, ) diff --git a/api/src/api_graphql/filter/collection/api_key_collection_filter.py b/api/src/api_graphql/filter/collection/api_key_collection_filter.py deleted file mode 100644 index 3bd1f7c..0000000 --- a/api/src/api_graphql/filter/collection/api_key_collection_filter.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class ApiKeyCollectionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("identifier", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py b/api/src/api_graphql/filter/collection/ip_list_collection_filter.py deleted file mode 100644 index 21c040e..0000000 --- a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class IpListFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("ip", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) - self.add_field("mac", StringCollectionFilter) - self.add_field("dns", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/news_collection_filter.py b/api/src/api_graphql/filter/collection/news_collection_filter.py deleted file mode 100644 index 78be285..0000000 --- a/api/src/api_graphql/filter/collection/news_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.bool_filter import BoolCollectionFilter -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class NewsFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("title", StringCollectionFilter) - self.add_field("published", BoolCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/permission_collection_filter.py b/api/src/api_graphql/filter/collection/permission_collection_filter.py deleted file mode 100644 index 03132c0..0000000 --- a/api/src/api_graphql/filter/collection/permission_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class PermissionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/role_collection_filter.py b/api/src/api_graphql/filter/collection/role_collection_filter.py deleted file mode 100644 index 6c01b12..0000000 --- a/api/src/api_graphql/filter/collection/role_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class RoleFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/user_collection_filter.py b/api/src/api_graphql/filter/collection/user_collection_filter.py deleted file mode 100644 index 8e64940..0000000 --- a/api/src/api_graphql/filter/collection/user_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class UserFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("keycloakId", StringCollectionFilter) - self.add_field("username", StringCollectionFilter) - self.add_field("email", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/group_filter.py b/api/src/api_graphql/filter/group_filter.py index f71148d..3b053db 100644 --- a/api/src/api_graphql/filter/group_filter.py +++ b/api/src/api_graphql/filter/group_filter.py @@ -1,5 +1,6 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class GroupFilter(DbModelFilterABC): @@ -11,6 +12,7 @@ class GroupFilter(DbModelFilterABC): self.add_field("name", StringFilter) self.add_field("description", StringFilter) + self.add_field("userSpace", UserSpaceFilter, "userspace") self.add_field("isNull", bool) self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/filter/short_url_filter.py b/api/src/api_graphql/filter/short_url_filter.py index 02a5097..c925eef 100644 --- a/api/src/api_graphql/filter/short_url_filter.py +++ b/api/src/api_graphql/filter/short_url_filter.py @@ -2,6 +2,7 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.filter.domain_filter import DomainFilter from api_graphql.filter.group_filter import GroupFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class ShortUrlFilter(DbModelFilterABC): @@ -17,3 +18,5 @@ class ShortUrlFilter(DbModelFilterABC): self.add_field("group", GroupFilter) self.add_field("domain", DomainFilter) + + self.add_field("userSpace", UserSpaceFilter, "userspace") diff --git a/api/src/api_graphql/filter/user_space_filter.py b/api/src/api_graphql/filter/user_space_filter.py new file mode 100644 index 0000000..17052a2 --- /dev/null +++ b/api/src/api_graphql/filter/user_space_filter.py @@ -0,0 +1,17 @@ +from api_graphql.abc.db_model_filter_abc import DbModelFilterABC +from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_filter import UserFilter + + +class UserSpaceFilter(DbModelFilterABC): + def __init__( + self, + obj: dict, + ): + DbModelFilterABC.__init__(self, obj) + + self.add_field("name", StringFilter) + self.add_field("owner", UserFilter) + + self.add_field("isNull", bool) + self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index f7f9315..9282d72 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -8,6 +8,8 @@ type GroupHistory implements DbHistoryModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -21,6 +23,8 @@ type Group implements DbModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -55,6 +59,8 @@ input GroupFilter { id: IntFilter name: StringFilter + userSpace: UserSpaceFilter + fuzzy: GroupFuzzy deleted: BooleanFilter @@ -74,6 +80,7 @@ type GroupMutation { } input GroupCreateInput { + userSpaceId: Int! name: String! roles: [Int] } diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index 6dff6f7..a49d9a6 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -4,8 +4,10 @@ type Mutation { user: UserMutation role: RoleMutation - group: GroupMutation domain: DomainMutation + + userSpace: UserSpaceMutation + group: GroupMutation shortUrl: ShortUrlMutation setting: SettingMutation diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 6b6e454..85acf01 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -12,6 +12,9 @@ type Query { notExistingUsersFromKeycloak: [KeycloakUser] domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult + + assignedUserSpaces: UserSpaceResult + userSpaces(filter: [UserSpaceFilter], sort: [UserSpaceSort], skip: Int, take: Int): UserSpaceResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index c766a66..154328a 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -14,6 +14,7 @@ type ShortUrlHistory implements DbHistoryModel { group: Group domain: Domain + userSpace: UserSpace deleted: Boolean editor: String @@ -27,9 +28,11 @@ type ShortUrl implements DbModel { targetUrl: String description: String visits: Int + loadingScreen: Boolean + group: Group domain: Domain - loadingScreen: Boolean + userSpace: UserSpace deleted: Boolean editor: User @@ -68,9 +71,12 @@ input ShortUrlFilter { targetUrl: StringFilter description: StringFilter loadingScreen: BooleanFilter + group: GroupFilter domain: DomainFilter + userSpace: UserSpaceFilter + fuzzy: ShortUrlFuzzy deleted: BooleanFilter @@ -88,6 +94,7 @@ type ShortUrlMutation { } input ShortUrlCreateInput { + userSpaceId: Int! shortUrl: String! targetUrl: String! description: String diff --git a/api/src/api_graphql/graphql/subscription.gql b/api/src/api_graphql/graphql/subscription.gql index 8ee85eb..e228c04 100644 --- a/api/src/api_graphql/graphql/subscription.gql +++ b/api/src/api_graphql/graphql/subscription.gql @@ -12,6 +12,7 @@ type Subscription { userLogout: SubscriptionChange domainChange: SubscriptionChange + userSpaceChange: SubscriptionChange groupChange: SubscriptionChange shortUrlChange: SubscriptionChange } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql new file mode 100644 index 0000000..cc28baf --- /dev/null +++ b/api/src/api_graphql/graphql/user_space.gql @@ -0,0 +1,88 @@ +type UserSpaceResult { + totalCount: Int + count: Int + nodes: [UserSpace] +} + +type UserSpaceHistory implements DbHistoryModel { + id: Int + name: String + owner: User + + users: [User] + + deleted: Boolean + editor: String + created: String + updated: String +} + +type UserSpace implements DbModel { + id: Int + name: String + owner: User + + users: [User] + + deleted: Boolean + editor: User + created: String + updated: String + history: [UserSpaceHistory] +} + +input UserSpaceSort { + id: SortOrder + name: SortOrder + owner: SortOrder + + deleted: SortOrder + editorId: SortOrder + created: SortOrder + updated: SortOrder +} + +enum UserSpaceFuzzyFields { + name + owner +} + +input UserSpaceFuzzy { + fields: [UserSpaceFuzzyFields] + term: String + threshold: Int +} + +input UserSpaceFilter { + id: IntFilter + name: StringFilter + owner: UserFilter + + fuzzy: UserSpaceFuzzy + + deleted: BooleanFilter + editor: IntFilter + created: DateFilter + updated: DateFilter + + isNull: Boolean + isNotNull: Boolean +} + +type UserSpaceMutation { + create(input: UserSpaceCreateInput!): UserSpace + update(input: UserSpaceUpdateInput!): UserSpace + delete(id: Int!): Boolean + restore(id: Int!): Boolean +} + +input UserSpaceCreateInput { + name: String! + users: [Int] +} + +input UserSpaceUpdateInput { + id: Int! + name: String + users: [Int] +} \ No newline at end of file diff --git a/api/src/api_graphql/input/group_create_input.py b/api/src/api_graphql/input/group_create_input.py index 994224e..29a0d71 100644 --- a/api/src/api_graphql/input/group_create_input.py +++ b/api/src/api_graphql/input/group_create_input.py @@ -6,9 +6,14 @@ class GroupCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._name = self.option("name", str, required=True) self._roles = self.option("roles", list[int]) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def name(self) -> str: return self._name diff --git a/api/src/api_graphql/input/short_url_create_input.py b/api/src/api_graphql/input/short_url_create_input.py index 94c4b9e..19a8847 100644 --- a/api/src/api_graphql/input/short_url_create_input.py +++ b/api/src/api_graphql/input/short_url_create_input.py @@ -8,6 +8,7 @@ class ShortUrlCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._short_url = self.option("shortUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True) self._description = self.option("description", str) @@ -15,6 +16,10 @@ class ShortUrlCreateInput(InputABC): self._domain_id = self.option("domainId", int) self._loading_screen = self.option("loadingScreen", bool) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def short_url(self) -> str: return self._short_url diff --git a/api/src/api_graphql/input/user_space_create_input.py b/api/src/api_graphql/input/user_space_create_input.py new file mode 100644 index 0000000..e12fadc --- /dev/null +++ b/api/src/api_graphql/input/user_space_create_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._name = self.option("name", str, required=True) + self._users = self.option("users", list[int]) + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/input/user_space_update_input.py b/api/src/api_graphql/input/user_space_update_input.py new file mode 100644 index 0000000..9d40397 --- /dev/null +++ b/api/src/api_graphql/input/user_space_update_input.py @@ -0,0 +1,23 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._name = self.option("name", str) + self._users = self.option("users", list[int]) + + @property + def id(self) -> int: + return self._id + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index f996321..e23a529 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -1,5 +1,4 @@ from api_graphql.abc.mutation_abc import MutationABC -from api_graphql.require_any_resolvers import by_user_setup_mutation from service.permission.permissions_enum import Permissions @@ -44,28 +43,34 @@ class Mutation(MutationABC): ], ) self.add_mutation_type( - "group", - "Group", + "userSpace", + "UserSpace", require_any=( [ - Permissions.groups_create, - Permissions.groups_update, - Permissions.groups_delete, + Permissions.user_spaces_create, + Permissions.user_spaces_update, + Permissions.user_spaces_delete, ], - [by_user_setup_mutation], + [self._test], ), ) + self.add_mutation_type( + "group", + "Group", + require_any_permission=[ + Permissions.groups_create, + Permissions.groups_update, + Permissions.groups_delete, + ], + ) self.add_mutation_type( "shortUrl", "ShortUrl", - require_any=( - [ - Permissions.short_urls_create, - Permissions.short_urls_update, - Permissions.short_urls_delete, - ], - [by_user_setup_mutation], - ), + require_any_permission=[ + Permissions.short_urls_create, + Permissions.short_urls_update, + Permissions.short_urls_delete, + ], ) self.add_mutation_type( @@ -90,3 +95,7 @@ class Mutation(MutationABC): "privacy", "Privacy", ) + + @staticmethod + async def _test(*args, **kwargs): + return True diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index 16addbd..fc1c2ca 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -1,14 +1,11 @@ from typing import Optional -from api.route import Route from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_update_input import GroupUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment import GroupRoleAssignment @@ -26,26 +23,38 @@ class GroupMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(GroupCreateInput) - .with_require_any([Permissions.groups_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_create]) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(GroupUpdateInput) - .with_require_any([Permissions.groups_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_update]) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_delete]) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_delete]) ) @staticmethod @@ -87,12 +96,9 @@ class GroupMutation(MutationABC): group = Group( 0, obj.name, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) + gid = await groupDao.create(group) await cls._handle_group_role_assignments(gid, obj.roles) diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py index b471fba..03102b3 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -3,10 +3,8 @@ from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.domain_dao import domainDao from data.schemas.public.group_dao import groupDao from data.schemas.public.short_url import ShortUrl @@ -26,31 +24,46 @@ class ShortUrlMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(ShortUrlCreateInput) - .with_require_any([Permissions.short_urls_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_create]) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(ShortUrlUpdateInput) - .with_require_any([Permissions.short_urls_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_update]) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_delete]) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_delete]) ) self.field( MutationFieldBuilder("trackVisit") .with_resolver(self.resolve_track_visit) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) .with_require_any_permission([Permissions.short_urls_update]) ) @@ -70,11 +83,7 @@ class ShortUrlMutation(MutationABC): obj.group_id, obj.domain_id, obj.loading_screen, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) nid = await shortUrlDao.create(short_url) return await shortUrlDao.get_by_id(nid) diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py new file mode 100644 index 0000000..36690db --- /dev/null +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -0,0 +1,125 @@ +from api.route import Route +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.field.mutation_field_builder import MutationFieldBuilder +from api_graphql.input.user_space_create_input import UserSpaceCreateInput +from api_graphql.input.user_space_update_input import UserSpaceUpdateInput +from core.logger import APILogger +from core.string import first_to_lower +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao +from data.schemas.public.user_space_user import UserSpaceUser +from data.schemas.public.user_space_user_dao import userSpaceUserDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class UserSpaceMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "UserSpace") + + self.field( + MutationFieldBuilder("create") + .with_resolver(self.resolve_create) + .with_input(UserSpaceCreateInput) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_create]) + ) + + self.field( + MutationFieldBuilder("update") + .with_resolver(self.resolve_update) + .with_input(UserSpaceUpdateInput) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_update]) + ) + + self.field( + MutationFieldBuilder("delete") + .with_resolver(self.resolve_delete) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_delete]) + ) + + self.field( + MutationFieldBuilder("restore") + .with_resolver(self.resolve_restore) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_delete]) + ) + + async def resolve_create(self, obj: UserSpaceCreateInput, *_): + logger.debug(f"create user_space: {obj.__dict__}") + + already_exists = await userSpaceDao.find_by({UserSpace.name: obj.name}) + if len(already_exists) > 0: + raise ValueError(f"UserSpace {obj.name} already exists") + + user_space = UserSpace(0, obj.name, (await Route.get_user()).id) + gid = await userSpaceDao.create(user_space) + + await self._resolve_assignments( + obj.get("users", []), + user_space, + UserSpaceUser.user_space_id, + UserSpaceUser.user_id, + userSpaceDao, + userSpaceUserDao, + UserSpaceUser, + userDao, + ) + + return await userSpaceDao.get_by_id(gid) + + async def resolve_update(self, obj: UserSpaceUpdateInput, *_): + logger.debug(f"update user_space: {input}") + + if await userSpaceDao.find_by_id(obj.id) is None: + raise ValueError(f"UserSpace with id {obj.id} not found") + + user_space = await userSpaceDao.get_by_id(obj.id) + if obj.name is not None: + already_exists = await userSpaceDao.find_by( + {UserSpace.name: obj.name, UserSpace.id: {"ne": obj.id}} + ) + if len(already_exists) > 0: + raise ValueError(f"UserSpace {obj.name} already exists") + + user_space.name = obj.name + await userSpaceDao.update(user_space) + + await self._resolve_assignments( + obj.get("users", []), + user_space, + UserSpaceUser.user_space_id, + UserSpaceUser.user_id, + userSpaceDao, + userSpaceUserDao, + UserSpaceUser, + userDao, + ) + + return await userSpaceDao.get_by_id(obj.id) + + @staticmethod + async def resolve_delete(*_, id: str): + logger.debug(f"delete user_space: {id}") + user_space = await userSpaceDao.get_by_id(id) + await userSpaceDao.delete(user_space) + return True + + @staticmethod + async def resolve_restore(*_, id: str): + logger.debug(f"restore user_space: {id}") + user_space = await userSpaceDao.get_by_id(id) + await userSpaceDao.restore(user_space) + return True diff --git a/api/src/api_graphql/queries/group_history_query.py b/api/src/api_graphql/queries/group_history_query.py index dbfe220..28695ef 100644 --- a/api/src/api_graphql/queries/group_history_query.py +++ b/api/src/api_graphql/queries/group_history_query.py @@ -1,6 +1,6 @@ from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC from api_graphql.field.resolver_field_builder import ResolverFieldBuilder -from api_graphql.require_any_resolvers import by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_assignment_resolver from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao @@ -14,6 +14,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): DbHistoryModelQueryABC.__init__(self, "Group") self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +22,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field( diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index 8d5b559..be7334c 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -1,6 +1,7 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.field.resolver_field_builder import ResolverFieldBuilder -from api_graphql.require_any_resolvers import by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_assignment_resolver + from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao @@ -14,6 +15,7 @@ class GroupQuery(DbModelQueryABC): DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True) self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +23,7 @@ class GroupQuery(DbModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field("roles", self._get_roles) diff --git a/api/src/api_graphql/queries/short_url_history_query.py b/api/src/api_graphql/queries/short_url_history_query.py index 2d56696..facfaed 100644 --- a/api/src/api_graphql/queries/short_url_history_query.py +++ b/api/src/api_graphql/queries/short_url_history_query.py @@ -8,7 +8,10 @@ class ShortUrlQuery(DbHistoryModelQueryABC): self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("description", lambda x, *_: x.description) - self.set_field("group", lambda x, *_: x.group) - self.set_field("domain", lambda x, *_: x.domain) self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("loadingScreen", lambda x, *_: x.loading_screen) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/short_url_query.py b/api/src/api_graphql/queries/short_url_query.py index 1a103d6..c16d672 100644 --- a/api/src/api_graphql/queries/short_url_query.py +++ b/api/src/api_graphql/queries/short_url_query.py @@ -9,7 +9,10 @@ class ShortUrlQuery(DbModelQueryABC): self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("description", lambda x, *_: x.description) - self.set_field("group", lambda x, *_: x.group) - self.set_field("domain", lambda x, *_: x.domain) self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("loadingScreen", lambda x, *_: x.loading_screen) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/user_space_history_query.py b/api/src/api_graphql/queries/user_space_history_query.py new file mode 100644 index 0000000..a74483f --- /dev/null +++ b/api/src/api_graphql/queries/user_space_history_query.py @@ -0,0 +1,22 @@ +from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao + + +class UserSpaceHistoryQuery(DbHistoryModelQueryABC): + def __init__(self): + DbHistoryModelQueryABC.__init__(self, "UserSpace") + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field( + "users", + lambda x, *_: self._resolve_foreign_history( + x.updated, + x.id, + userSpaceUserDao, + userDao, + lambda y: y.user_id, + obj_key="userspaceid", + ), + ) diff --git a/api/src/api_graphql/queries/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py new file mode 100644 index 0000000..cdd1b72 --- /dev/null +++ b/api/src/api_graphql/queries/user_space_query.py @@ -0,0 +1,21 @@ +from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from data.schemas.public.group_dao import groupDao +from data.schemas.public.short_url_dao import shortUrlDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao + + +class UserSpaceQuery(DbModelQueryABC): + def __init__(self): + DbModelQueryABC.__init__(self, "UserSpace", userSpaceDao, with_history=True) + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field("users", self._get_users) + + self.set_history_reference_dao(groupDao, "userspaceid") + self.set_history_reference_dao(shortUrlDao, "userspaceid") + + @staticmethod + async def _get_users(group: UserSpace, *_): + return await userSpaceDao.get(group.id) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 2e80df3..657ffc6 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -2,6 +2,7 @@ from api.auth.keycloak_client import Keycloak from api.route import Route from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.sort_abc import Sort +from api_graphql.field.collection_field_builder import CollectionFieldBuilder from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.filter.api_key_filter import ApiKeyFilter @@ -11,12 +12,10 @@ from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.user_filter import UserFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter from api_graphql.require_any_resolvers import ( - by_assignment_resolver, - by_user_setup_resolver, + by_group_assignment_resolver, ) -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.user import User @@ -33,6 +32,8 @@ 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 userSettingDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao from data.schemas.system.feature_flag_dao import featureFlagDao from data.schemas.system.setting_dao import settingDao from service.permission.permissions_enum import Permissions @@ -109,13 +110,31 @@ class Query(QueryABC): .with_require_any_permission( [ Permissions.domains, - Permissions.short_urls_create, - Permissions.short_urls_update, + Permissions.domains_create, + Permissions.domains_update, ] ) ) - group_field = ( + self.field( + CollectionFieldBuilder("assignedUserSpaces").with_resolver( + self._resolve_assigned_user_spaces + ) + ) + + self.field( + DaoFieldBuilder("userSpaces") + .with_dao(userSpaceDao) + .with_filter(UserSpaceFilter) + .with_sort(Sort[UserSpace]) + .with_require_any_permission( + [ + Permissions.user_spaces, + ] + ) + ) + + self.field( DaoFieldBuilder("groups") .with_dao(groupDao) .with_filter(GroupFilter) @@ -126,35 +145,21 @@ class Query(QueryABC): Permissions.short_urls_create, Permissions.short_urls_update, ], - [by_assignment_resolver, by_user_setup_resolver], + [by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - group_field = group_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(group_field) - - short_url_field = ( + self.field( DaoFieldBuilder("shortUrls") .with_dao(shortUrlDao) .with_filter(ShortUrlFilter) .with_sort(Sort[ShortUrl]) .with_require_any( [Permissions.short_urls], - [by_assignment_resolver, by_user_setup_resolver], + [by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - short_url_field = short_url_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(short_url_field) - self.field( ResolverFieldBuilder("settings") .with_resolver(self._resolve_settings) @@ -230,3 +235,11 @@ class Query(QueryABC): @staticmethod async def _resolve_default_user_filter(*args, **kwargs) -> dict: return {"user": {"id": {"equal": (await Route.get_user()).id}}} + + @staticmethod + async def _resolve_assigned_user_spaces(*args, **kwargs): + user = await Route.get_user() + if user is None: + return [] + + return await userSpaceDao.get_assigned_by_user_id(user.id) diff --git a/api/src/api_graphql/require_any_resolvers.py b/api/src/api_graphql/require_any_resolvers.py index 8eea17f..153aaaf 100644 --- a/api/src/api_graphql/require_any_resolvers.py +++ b/api/src/api_graphql/require_any_resolvers.py @@ -1,12 +1,32 @@ from api_graphql.service.collection_result import CollectionResult from api_graphql.service.query_context import QueryContext -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.public.group_dao import groupDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao from service.permission.permissions_enum import Permissions -async def by_assignment_resolver(ctx: QueryContext) -> bool: +async def by_user_space_assignment_resolver(ctx: QueryContext) -> bool: + if not isinstance(ctx.data, CollectionResult): + return False + + user = ctx.user + + assigned_user_space_ids = { + us.user_space_id for us in await userSpaceUserDao.find_by_user_id(user.id) + } + + for node in ctx.data.nodes: + user_space = await node.user_space + if user_space is None: + continue + + if user_space.owner_id == user.id or user_space.id in assigned_user_space_ids: + return True + + return False + + +async def by_group_assignment_resolver(ctx: QueryContext) -> bool: if not isinstance(ctx.data, CollectionResult): return False @@ -27,17 +47,3 @@ async def by_assignment_resolver(ctx: QueryContext) -> bool: ) return False - - -async def by_user_setup_resolver(ctx: QueryContext) -> bool: - if not isinstance(ctx.data, CollectionResult): - return False - - if not FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup): - return False - - return all(x.user_id == ctx.user.id for x in ctx.data.nodes) - - -async def by_user_setup_mutation(ctx: QueryContext) -> bool: - return await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index 02f0e57..6821afd 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -60,6 +60,11 @@ class Subscription(SubscriptionABC): .with_resolver(lambda message, *_: message.message) .with_require_any_permission([Permissions.domains]) ) + self.subscribe( + SubscriptionFieldBuilder("userSpaceChange").with_resolver( + lambda message, *_: message.message + ) + ) self.subscribe( SubscriptionFieldBuilder("groupChange") .with_resolver(lambda message, *_: message.message) diff --git a/api/src/core/configuration/feature_flags.py b/api/src/core/configuration/feature_flags.py index 8eaa333..4bccd0f 100644 --- a/api/src/core/configuration/feature_flags.py +++ b/api/src/core/configuration/feature_flags.py @@ -9,14 +9,9 @@ class FeatureFlags: _flags = { FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025 FeatureFlagsEnum.technical_demo_banner.value: False, # 18.04.2025 - FeatureFlagsEnum.per_user_setup.value: Environment.get( - "PER_USER_SETUP", bool, False - ), # 18.04.2025 } - _overwrite_flags = [ - FeatureFlagsEnum.per_user_setup.value, - ] + _overwrite_flags = [] @staticmethod def overwrite_flag(key: str): diff --git a/api/src/core/configuration/feature_flags_enum.py b/api/src/core/configuration/feature_flags_enum.py index c5af428..2c6ac5d 100644 --- a/api/src/core/configuration/feature_flags_enum.py +++ b/api/src/core/configuration/feature_flags_enum.py @@ -4,4 +4,3 @@ from enum import Enum class FeatureFlagsEnum(Enum): version_endpoint = "VersionEndpoint" technical_demo_banner = "TechnicalDemoBanner" - per_user_setup = "PerUserSetup" diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py index a3003db..64518f7 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -37,6 +37,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self.__db_names: dict[str, str] = {} self.__foreign_tables: dict[str, tuple[str, str]] = {} self.__foreign_table_keys: dict[str, str] = {} + self.__foreign_dao: dict[str, "DataAccessObjectABC"] = {} self.__date_attributes: set[str] = set() self.__ignored_attributes: set[str] = set() @@ -108,6 +109,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): primary_attr: Attribute, foreign_attr: Attribute, table_name: str, + reference_dao: "DataAccessObjectABC" = None, ): """ Add a reference to another table for the given attribute @@ -115,6 +117,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :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 + :param DataAccessObjectABC reference_dao: The data access object for the referenced table :return: """ if isinstance(attr, property): @@ -131,6 +134,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): foreign_attr = foreign_attr.lower().replace("_", "") self.__foreign_table_keys[attr] = foreign_attr + if reference_dao is not None: + self.__foreign_dao[attr] = reference_dao + if table_name == self._table_name: return @@ -622,7 +628,27 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): external_table_deps.append(external_field) parse_node(value, f"{external_field}.{key}") elif parent_key in self.__foreign_table_keys: - parse_node({key: value}, self.__foreign_table_keys[parent_key]) + if key in operators: + parse_node({key: value}, self.__foreign_table_keys[parent_key]) + continue + + if parent_key in self.__foreign_dao: + foreign_dao = self.__foreign_dao[parent_key] + if key in foreign_dao.__foreign_tables: + x = f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}" + parse_node( + value, + f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}", + ) + continue + + if parent_key in self.__foreign_tables: + parse_node( + value, f"{self.__foreign_tables[parent_key][0]}.{key}" + ) + continue + + parse_node({parent_key: value}) elif key in operators: operator = operators[key] if key == "contains" or key == "notContains": @@ -633,6 +659,23 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): value = f"{value}%" elif key == "endsWith": value = f"%{value}" + elif key == "isNull" or key == "isNotNull": + is_null_value = ( + value.get("equal", None) + if isinstance(value, dict) + else value + ) + + if is_null_value is None: + operator = operators[key] + elif (key == "isNull" and is_null_value) or ( + key == "isNotNull" and not is_null_value + ): + operator = "IS NULL" + else: + operator = "IS NOT NULL" + + conditions.append((parent_key, operator, None)) elif (key == "equal" or key == "notEqual") and value is None: operator = operators["isNull"] @@ -641,6 +684,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): elif isinstance(value, dict): if key in self.__foreign_table_keys: parse_node(value, key) + elif key in self.__db_names and parent_key is not None: + parse_node({f"{parent_key}": value}) elif key in self.__db_names: parse_node(value, self.__db_names[key]) else: diff --git a/api/src/data/schemas/permission/role_user.py b/api/src/data/schemas/permission/role_user.py index 6af5bfc..f210aed 100644 --- a/api/src/data/schemas/permission/role_user.py +++ b/api/src/data/schemas/permission/role_user.py @@ -3,11 +3,12 @@ from typing import Optional from async_property import async_property +from core.database.abc.db_join_model_abc import DbJoinModelABC from core.typing import SerialId from core.database.abc.db_model_abc import DbModelABC -class RoleUser(DbModelABC): +class RoleUser(DbJoinModelABC): def __init__( self, id: SerialId, @@ -18,7 +19,9 @@ class RoleUser(DbModelABC): created: Optional[datetime] = None, updated: Optional[datetime] = None, ): - DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + DbJoinModelABC.__init__( + self, id, role_id, user_id, deleted, editor_id, created, updated + ) self._role_id = role_id self._user_id = user_id diff --git a/api/src/data/schemas/public/group.py b/api/src/data/schemas/public/group.py index da11936..a67398d 100644 --- a/api/src/data/schemas/public/group.py +++ b/api/src/data/schemas/public/group.py @@ -12,7 +12,7 @@ class Group(DbModelABC): self, id: SerialId, name: str, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -20,7 +20,7 @@ class Group(DbModelABC): ): DbModelABC.__init__(self, id, deleted, editor_id, created, updated) self._name = name - self._user_id = user_id + self._user_space_id = user_space_id @property def name(self) -> str: @@ -31,15 +31,14 @@ class Group(DbModelABC): self._name = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) diff --git a/api/src/data/schemas/public/group_dao.py b/api/src/data/schemas/public/group_dao.py index e856f04..2fc8a70 100644 --- a/api/src/data/schemas/public/group_dao.py +++ b/api/src/data/schemas/public/group_dao.py @@ -11,8 +11,8 @@ class GroupDao(DbModelDaoABC[Group]): DbModelDaoABC.__init__(self, __name__, Group, "public.groups") self.attribute(Group.name, str) - self.attribute(Group.user_id, int) - self.reference("user", "id", Group.user_id, "administration.users") + self.attribute(Group.user_space_id, int) + self.reference("userspace", "id", Group.user_space_id, "public.user_spaces") async def get_by_name(self, name: str) -> Group: result = await self._db.select_map( diff --git a/api/src/data/schemas/public/short_url.py b/api/src/data/schemas/public/short_url.py index 8d23653..b36ac9b 100644 --- a/api/src/data/schemas/public/short_url.py +++ b/api/src/data/schemas/public/short_url.py @@ -18,7 +18,7 @@ class ShortUrl(DbModelABC): group_id: Optional[SerialId], domain_id: Optional[SerialId], loading_screen: Optional[str] = None, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -35,7 +35,7 @@ class ShortUrl(DbModelABC): loading_screen = False self._loading_screen = loading_screen - self._user_id = user_id + self._user_space_id = user_space_id @property def short_url(self) -> str: @@ -110,18 +110,17 @@ class ShortUrl(DbModelABC): self._loading_screen = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) def to_dto(self) -> dict: return { diff --git a/api/src/data/schemas/public/short_url_dao.py b/api/src/data/schemas/public/short_url_dao.py index 66e0003..ced0e19 100644 --- a/api/src/data/schemas/public/short_url_dao.py +++ b/api/src/data/schemas/public/short_url_dao.py @@ -13,13 +13,17 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]): self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.group_id, int) - self.reference("group", "id", ShortUrl.group_id, "public.groups") + from data.schemas.public.group_dao import groupDao + + self.reference("group", "id", ShortUrl.group_id, "public.groups", groupDao) self.attribute(ShortUrl.domain_id, int) - self.reference("domain", "id", ShortUrl.domain_id, "public.domains") + from data.schemas.public.domain_dao import domainDao + + self.reference("domain", "id", ShortUrl.domain_id, "public.domains", domainDao) self.attribute(ShortUrl.loading_screen, bool) - self.attribute(ShortUrl.user_id, int) - self.reference("user", "id", ShortUrl.user_id, "administration.users") + self.attribute(ShortUrl.user_space_id, int) + self.reference("userspace", "id", ShortUrl.user_space_id, "public.user_spaces") shortUrlDao = ShortUrlDao() diff --git a/api/src/data/schemas/public/user_space.py b/api/src/data/schemas/public/user_space.py new file mode 100644 index 0000000..ac451ee --- /dev/null +++ b/api/src/data/schemas/public/user_space.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSpace(DbModelABC): + def __init__( + self, + id: SerialId, + name: str, + owner_id: SerialId, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + self._name = name + self._owner_id = owner_id + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + @property + def owner_id(self) -> SerialId: + return self._owner_id + + @owner_id.setter + def owner_id(self, value: SerialId): + self._owner_id = value + + @async_property + async def owner(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._owner_id) diff --git a/api/src/data/schemas/public/user_space_dao.py b/api/src/data/schemas/public/user_space_dao.py new file mode 100644 index 0000000..aedac49 --- /dev/null +++ b/api/src/data/schemas/public/user_space_dao.py @@ -0,0 +1,49 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.public.user_space import UserSpace + +logger = DBLogger(__name__) + + +class UserSpaceDao(DbModelDaoABC[UserSpace]): + def __init__(self): + DbModelDaoABC.__init__(self, __name__, UserSpace, "public.user_spaces") + self.attribute(UserSpace.name, str) + self.attribute(UserSpace.owner_id, int) + + async def get_by_name(self, name: str) -> UserSpace: + result = await self._db.select_map( + f"SELECT * FROM {self._table_name} WHERE Name = '{name}'" + ) + return self.to_object(result[0]) + + async def get_users(self, user_space_id: int) -> list: + result = await self._db.select_map( + f""" + SELECT u.* + FROM administration.users u + JOIN public.user_spaces_users usu ON u.id = usu.userId + WHERE usu.userSpaceId = {user_space_id} + AND usu.deleted = FALSE + """ + ) + + from data.schemas.administration.user_dao import userDao + + return [userDao.to_object(x) for x in result] + + async def get_assigned_by_user_id(self, user_id: int): + result = await self._db.select_map( + f""" + SELECT DISTINCT us.* + FROM public.user_spaces us + LEFT JOIN public.user_spaces_users usu ON us.id = usu.userSpaceId + WHERE (usu.userId = {user_id} OR us.ownerId = {user_id}) + AND us.deleted = FALSE; + """ + ) + + return [self.to_object(x) for x in result] + + +userSpaceDao = UserSpaceDao() diff --git a/api/src/data/schemas/public/user_space_user.py b/api/src/data/schemas/public/user_space_user.py new file mode 100644 index 0000000..6c093e6 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_join_model_abc import DbJoinModelABC +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSpaceUser(DbJoinModelABC): + def __init__( + self, + id: SerialId, + user_space_id: SerialId, + user_id: SerialId, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbJoinModelABC.__init__( + self, id, user_space_id, user_id, deleted, editor_id, created, updated + ) + self._user_space_id = user_space_id + self._user_id = user_id + + @property + def user_space_id(self) -> SerialId: + return self._user_space_id + + @async_property + async def user_space(self): + from data.schemas.public.user_space_dao import userSpaceDao + + return await userSpaceDao.get_by_id(self._user_space_id) + + @property + def user_id(self) -> SerialId: + return self._user_id + + @async_property + async def user(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._user_id) diff --git a/api/src/data/schemas/public/user_space_user_dao.py b/api/src/data/schemas/public/user_space_user_dao.py new file mode 100644 index 0000000..8ed77bc --- /dev/null +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -0,0 +1,35 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.public.user_space_user import UserSpaceUser + +logger = DBLogger(__name__) + + +class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): + def __init__(self): + DbModelDaoABC.__init__( + self, __name__, UserSpaceUser, "public.user_spaces_users" + ) + self.attribute(UserSpaceUser.user_space_id, str) + self.attribute(UserSpaceUser.user_id, str) + + async def get_by_user_space_id( + self, user_space_id: str, with_deleted=False + ) -> list[UserSpaceUser]: + f = [{UserSpaceUser.user_space_id: user_space_id}] + if not with_deleted: + f.append({UserSpaceUser.deleted: False}) + + return await self.find_by(f) + + async def get_by_user_id( + self, user_id: str, with_deleted=False + ) -> list[UserSpaceUser]: + f = [{UserSpaceUser.user_id: user_id}] + if not with_deleted: + f.append({UserSpaceUser.deleted: False}) + + return await self.find_by(f) + + +userSpaceUserDao = UserSpaceUserDao() diff --git a/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql new file mode 100644 index 0000000..fcea5a1 --- /dev/null +++ b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS public.user_spaces +( + Id SERIAL PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + OwnerId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_history +( + LIKE public.user_spaces +); + +CREATE TRIGGER user_spaces_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users +( + Id SERIAL PRIMARY KEY, + UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id), + UserId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users_history +( + LIKE public.user_spaces_users +); + +CREATE TRIGGER user_spaces_users_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces_users + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +-- add user spaces +INSERT INTO public.user_spaces (Name, OwnerId) +VALUES ('Default', (SELECT id FROM administration.users ORDER BY id LIMIT 1)); + +INSERT INTO public.user_spaces_users (UserSpaceId, UserId) +SELECT 1 AS UserSpaceId, u.id AS UserId +FROM administration.users u +WHERE u.deleted = FALSE; + +-- change other tables +ALTER TABLE public.groups + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.groups_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups_history + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls_history + DROP COLUMN IF EXISTS UserId; \ No newline at end of file diff --git a/api/src/service/permission/permissions_enum.py b/api/src/service/permission/permissions_enum.py index 267a9bc..f054ff0 100644 --- a/api/src/service/permission/permissions_enum.py +++ b/api/src/service/permission/permissions_enum.py @@ -26,6 +26,18 @@ class Permissions(Enum): settings = "settings" settings_update = "settings.update" + # domains + domains = "domains" + domains_create = "domains.create" + domains_update = "domains.update" + domains_delete = "domains.delete" + + # user spaces + user_spaces = "user_spaces" + user_spaces_create = "user_spaces.create" + user_spaces_update = "user_spaces.update" + user_spaces_delete = "user_spaces.delete" + """ Permissions """ @@ -38,12 +50,6 @@ class Permissions(Enum): """ Public """ - # domains - domains = "domains" - domains_create = "domains.create" - domains_update = "domains.update" - domains_delete = "domains.delete" - # groups groups = "groups" groups_create = "groups.create" diff --git a/web/src/app/components/home/home.component.html b/web/src/app/components/home/home.component.html index 5f2c53f..e69de29 100644 --- a/web/src/app/components/home/home.component.html +++ b/web/src/app/components/home/home.component.html @@ -1 +0,0 @@ -

home works!

diff --git a/web/src/app/core/base/form-page-base.ts b/web/src/app/core/base/form-page-base.ts index e05c78b..ad2e32f 100644 --- a/web/src/app/core/base/form-page-base.ts +++ b/web/src/app/core/base/form-page-base.ts @@ -26,8 +26,12 @@ export abstract class FormPageBase< protected filterService = inject(FilterService); protected dataService = inject(PageDataService) as S; - protected constructor(idKey: string = 'id') { + private backRoute: string = '..'; + + protected constructor(idKey: string = 'id', backRoute: string = '..') { const id = this.route.snapshot.params[idKey]; + this.backRoute = backRoute; + this.validateRoute(id); this.buildForm(); @@ -46,7 +50,7 @@ export abstract class FormPageBase< } close() { - const backRoute = this.nodeId ? '../..' : '..'; + const backRoute = this.nodeId ? `${this.backRoute}/..` : this.backRoute; this.router.navigate([backRoute], { relativeTo: this.route }).then(() => { this.filterService.onLoad.emit(); diff --git a/web/src/app/core/guard/permission.guard.ts b/web/src/app/core/guard/permission.guard.ts index ca1a076..fc07bcf 100644 --- a/web/src/app/core/guard/permission.guard.ts +++ b/web/src/app/core/guard/permission.guard.ts @@ -4,7 +4,6 @@ import { Logger } from 'src/app/service/logger.service'; import { ToastService } from 'src/app/service/toast.service'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; const log = new Logger('PermissionGuard'); @@ -15,18 +14,11 @@ export class PermissionGuard { constructor( private router: Router, private toast: ToastService, - private auth: AuthService, - private features: FeatureFlagService + private auth: AuthService ) {} async canActivate(route: ActivatedRouteSnapshot): Promise { const permissions = route.data['permissions'] as PermissionsEnum[]; - const checkByPerUserSetup = route.data['checkByPerUserSetup'] as boolean; - - const isPerUserSetup = await this.features.get('PerUserSetup'); - if (checkByPerUserSetup && isPerUserSetup) { - return true; - } if (!permissions || permissions.length === 0) { return true; diff --git a/web/src/app/model/entities/user-space.ts b/web/src/app/model/entities/user-space.ts new file mode 100644 index 0000000..4b82c61 --- /dev/null +++ b/web/src/app/model/entities/user-space.ts @@ -0,0 +1,19 @@ +import { User } from 'src/app/model/auth/user'; +import { DbModelWithHistory } from 'src/app/model/entities/db-model'; + +export interface UserSpace extends DbModelWithHistory { + id: number; + name: string; + users?: User[]; +} + +export interface UserSpaceCreateInput { + name?: string; + users?: number[]; +} + +export interface UserSpaceUpdateInput { + id: number; + name?: string; + users?: number[]; +} diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index f6be88c..5bbc76b 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -7,13 +7,9 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; const routes: Routes = [ { - path: 'domains', - loadChildren: () => - import('src/app/modules/admin/domains/domains.module').then( - m => m.DomainsModule - ), - canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.domains] }, + path: '', + pathMatch: 'full', + redirectTo: 'urls', }, { path: 'groups', @@ -22,7 +18,7 @@ const routes: Routes = [ m => m.GroupsModule ), canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true }, + data: { permissions: [PermissionsEnum.groups] }, }, { path: 'urls', @@ -36,9 +32,15 @@ const routes: Routes = [ PermissionsEnum.shortUrls, PermissionsEnum.shortUrlsByAssignment, ], - checkByPerUserSetup: true, }, }, + { + path: 'rooms', + loadChildren: () => + import('src/app/modules/admin/user-spaces/user-spaces.module').then( + m => m.UserSpacesModule + ), + }, { path: 'administration', loadChildren: () => diff --git a/web/src/app/modules/admin/administration/administration.module.ts b/web/src/app/modules/admin/administration/administration.module.ts index a010f0f..d967bf1 100644 --- a/web/src/app/modules/admin/administration/administration.module.ts +++ b/web/src/app/modules/admin/administration/administration.module.ts @@ -11,6 +11,15 @@ const routes: Routes = [ pathMatch: 'full', redirectTo: 'users', }, + { + path: 'domains', + loadChildren: () => + import( + 'src/app/modules/admin/administration/domains/domains.module' + ).then(m => m.DomainsModule), + canActivate: [PermissionGuard], + data: { permissions: [PermissionsEnum.domains] }, + }, { path: 'users', title: 'Users | Maxlan', diff --git a/web/src/app/modules/admin/domains/domains.columns.ts b/web/src/app/modules/admin/administration/domains/domains.columns.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.columns.ts rename to web/src/app/modules/admin/administration/domains/domains.columns.ts diff --git a/web/src/app/modules/admin/domains/domains.data.service.ts b/web/src/app/modules/admin/administration/domains/domains.data.service.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.data.service.ts rename to web/src/app/modules/admin/administration/domains/domains.data.service.ts diff --git a/web/src/app/modules/admin/domains/domains.module.ts b/web/src/app/modules/admin/administration/domains/domains.module.ts similarity index 72% rename from web/src/app/modules/admin/domains/domains.module.ts rename to web/src/app/modules/admin/administration/domains/domains.module.ts index fe3365a..d4fc7ff 100644 --- a/web/src/app/modules/admin/domains/domains.module.ts +++ b/web/src/app/modules/admin/administration/domains/domains.module.ts @@ -4,11 +4,11 @@ import { SharedModule } from 'src/app/modules/shared/shared.module'; import { RouterModule, Routes } from '@angular/router'; import { PermissionGuard } from 'src/app/core/guard/permission.guard'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { DomainsPage } from 'src/app/modules/admin/domains/domains.page'; -import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; -import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; -import { HistoryComponent } from 'src/app/modules/admin/domains/history/history.component'; +import { DomainsPage } from 'src/app/modules/admin/administration/domains/domains.page'; +import { DomainFormPageComponent } from 'src/app/modules/admin/administration/domains/form-page/domain-form-page.component'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; +import { DomainsColumns } from 'src/app/modules/admin/administration/domains/domains.columns'; +import { HistoryComponent } from 'src/app/modules/admin/administration/domains/history/history.component'; const routes: Routes = [ { diff --git a/web/src/app/modules/admin/domains/domains.page.html b/web/src/app/modules/admin/administration/domains/domains.page.html similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.html rename to web/src/app/modules/admin/administration/domains/domains.page.html diff --git a/web/src/app/modules/admin/domains/domains.page.scss b/web/src/app/modules/admin/administration/domains/domains.page.scss similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.scss rename to web/src/app/modules/admin/administration/domains/domains.page.scss diff --git a/web/src/app/modules/admin/domains/domains.page.spec.ts b/web/src/app/modules/admin/administration/domains/domains.page.spec.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.spec.ts rename to web/src/app/modules/admin/administration/domains/domains.page.spec.ts diff --git a/web/src/app/modules/admin/domains/domains.page.ts b/web/src/app/modules/admin/administration/domains/domains.page.ts similarity index 91% rename from web/src/app/modules/admin/domains/domains.page.ts rename to web/src/app/modules/admin/administration/domains/domains.page.ts index 337b4e3..72c8059 100644 --- a/web/src/app/modules/admin/domains/domains.page.ts +++ b/web/src/app/modules/admin/administration/domains/domains.page.ts @@ -3,9 +3,9 @@ import { PageBase } from 'src/app/core/base/page-base'; import { ToastService } from 'src/app/service/toast.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; -import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; import { Domain } from 'src/app/model/entities/domain'; +import { DomainsColumns } from 'src/app/modules/admin/administration/domains/domains.columns'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; @Component({ selector: 'app-domains', diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.html b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.html similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.html rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.html diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.scss similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.scss diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.spec.ts similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.spec.ts diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts similarity index 95% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts index f5994bb..0cb4874 100644 --- a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts +++ b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts @@ -7,7 +7,7 @@ import { DomainCreateInput, DomainUpdateInput, } from 'src/app/model/entities/domain'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; @Component({ selector: 'app-domain-form-page', diff --git a/web/src/app/modules/admin/domains/history/history.component.html b/web/src/app/modules/admin/administration/domains/history/history.component.html similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.html rename to web/src/app/modules/admin/administration/domains/history/history.component.html diff --git a/web/src/app/modules/admin/domains/history/history.component.scss b/web/src/app/modules/admin/administration/domains/history/history.component.scss similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.scss rename to web/src/app/modules/admin/administration/domains/history/history.component.scss diff --git a/web/src/app/modules/admin/domains/history/history.component.spec.ts b/web/src/app/modules/admin/administration/domains/history/history.component.spec.ts similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.spec.ts rename to web/src/app/modules/admin/administration/domains/history/history.component.spec.ts diff --git a/web/src/app/modules/admin/domains/history/history.component.ts b/web/src/app/modules/admin/administration/domains/history/history.component.ts similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.ts rename to web/src/app/modules/admin/administration/domains/history/history.component.ts diff --git a/web/src/app/modules/admin/administration/users/users.page.ts b/web/src/app/modules/admin/administration/users/users.page.ts index bbdfbf7..dfa459c 100644 --- a/web/src/app/modules/admin/administration/users/users.page.ts +++ b/web/src/app/modules/admin/administration/users/users.page.ts @@ -26,6 +26,8 @@ export class UsersPage extends PageBase { }); } + async onInit(): Promise {} + load(silent?: boolean): void { if (!silent) this.loading = true; this.dataService diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html index 8f0373c..ea771ac 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html @@ -27,14 +27,13 @@ type="text" formControlName="name"/> -
+
+ [showClear]="true"/> diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts index f80f7d8..efe1f6b 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts @@ -17,17 +17,13 @@ import { FeatureFlagService } from 'src/app/service/feature-flag.service'; templateUrl: './group-form-page.component.html', styleUrl: './group-form-page.component.scss', }) -export class GroupFormPageComponent - extends FormPageBase< - Group, - GroupCreateInput, - GroupUpdateInput, - GroupsDataService - > - implements OnInit -{ +export class GroupFormPageComponent extends FormPageBase< + Group, + GroupCreateInput, + GroupUpdateInput, + GroupsDataService +> { roles: Role[] = []; - isPerUserSetup = true; constructor( private features: FeatureFlagService, @@ -35,15 +31,9 @@ export class GroupFormPageComponent private cds: CommonDataService ) { super(); - } - - async ngOnInit() { - this.isPerUserSetup = await this.features.get('PerUserSetup'); - if (!this.isPerUserSetup) { - this.cds.getAllRoles().subscribe(roles => { - this.roles = roles; - }); - } + this.cds.getAllRoles().subscribe(roles => { + this.roles = roles; + }); if (!this.nodeId) { this.node = this.new(); diff --git a/web/src/app/modules/admin/groups/groups.data.service.ts b/web/src/app/modules/admin/groups/groups.data.service.ts index c40ce19..9687874 100644 --- a/web/src/app/modules/admin/groups/groups.data.service.ts +++ b/web/src/app/modules/admin/groups/groups.data.service.ts @@ -23,6 +23,7 @@ import { GroupUpdateInput, } from 'src/app/model/entities/group'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Injectable() export class GroupsDataService @@ -35,7 +36,8 @@ export class GroupsDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo + private apollo: Apollo, + private sidebarService: SidebarService ) { super(); } @@ -74,7 +76,14 @@ export class GroupsDataService ${DB_MODEL_FRAGMENT} `, variables: { - filter: filter, + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + ...(filter ?? []), + ], sort: sort, skip: skip, take: take, @@ -186,6 +195,7 @@ export class GroupsDataService `, variables: { input: { + userSpaceId: this.sidebarService.selectedUserSpace$.value?.id, name: object.name, roles: object.roles?.map(x => x.id), }, diff --git a/web/src/app/modules/admin/groups/groups.module.ts b/web/src/app/modules/admin/groups/groups.module.ts index f8762ad..fb786ad 100644 --- a/web/src/app/modules/admin/groups/groups.module.ts +++ b/web/src/app/modules/admin/groups/groups.module.ts @@ -22,7 +22,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsCreate], - checkByPerUserSetup: true, }, }, { @@ -31,7 +30,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsUpdate], - checkByPerUserSetup: true, }, }, { @@ -40,7 +38,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groups], - checkByPerUserSetup: true, }, }, ], diff --git a/web/src/app/modules/admin/groups/groups.page.ts b/web/src/app/modules/admin/groups/groups.page.ts index b080d40..dfddf35 100644 --- a/web/src/app/modules/admin/groups/groups.page.ts +++ b/web/src/app/modules/admin/groups/groups.page.ts @@ -1,68 +1,41 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { PageBase } from 'src/app/core/base/page-base'; import { ToastService } from 'src/app/service/toast.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; -import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { Group } from 'src/app/model/entities/group'; import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service'; import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns'; -import { AuthService } from 'src/app/service/auth.service'; -import { ConfigService } from 'src/app/service/config.service'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; +import { takeUntil } from 'rxjs/operators'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Component({ selector: 'app-groups', templateUrl: './groups.page.html', styleUrl: './groups.page.scss', }) -export class GroupsPage - extends PageBase - implements OnInit -{ +export class GroupsPage extends PageBase< + Group, + GroupsDataService, + GroupsColumns +> { constructor( private toast: ToastService, private confirmation: ConfirmationDialogService, - private auth: AuthService, - private config: ConfigService, - private features: FeatureFlagService + private sidebar: SidebarService ) { - super(true); - } + super(true, { + read: [], + create: [], + update: [], + delete: [], + restore: [], + }); - async ngOnInit() { - this.requiredPermissions = { - read: (await this.features.get('PerUserSetup')) - ? [] - : [PermissionsEnum.groups], - create: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.create ?? [] - )) - ? (this.requiredPermissions.create ?? []) - : [], - update: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.update ?? [] - )) - ? (this.requiredPermissions.update ?? []) - : [], - delete: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.delete ?? [] - )) - ? (this.requiredPermissions.delete ?? []) - : [], - restore: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.restore ?? [] - )) - ? (this.requiredPermissions.restore ?? []) - : [], - }; + this.sidebar.selectedUserSpace$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(true); + }); } load(silent?: boolean): void { diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html index e4bddcb..8240320 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html @@ -65,7 +65,7 @@ > -
+

{{ 'common.domain' | translate }}

diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts index 48ebe48..207682f 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ToastService } from 'src/app/service/toast.service'; import { FormPageBase } from 'src/app/core/base/form-page-base'; @@ -10,45 +10,29 @@ import { import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { Group } from 'src/app/model/entities/group'; import { Domain } from 'src/app/model/entities/domain'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; @Component({ selector: 'app-short-url-form-page', templateUrl: './short-url-form-page.component.html', styleUrl: './short-url-form-page.component.scss', }) -export class ShortUrlFormPageComponent - extends FormPageBase< - ShortUrl, - ShortUrlCreateInput, - ShortUrlUpdateInput, - ShortUrlsDataService - > - implements OnInit -{ +export class ShortUrlFormPageComponent extends FormPageBase< + ShortUrl, + ShortUrlCreateInput, + ShortUrlUpdateInput, + ShortUrlsDataService +> { groups: Group[] = []; domains: Domain[] = []; - isPerUserSetup = true; - - constructor( - private features: FeatureFlagService, - private toast: ToastService - ) { + constructor(private toast: ToastService) { super(); - this.dataService.getAllGroups().subscribe(groups => { + this.dataService.getAllAvailableGroups().subscribe(groups => { this.groups = groups; }); - } - - async ngOnInit() { - this.isPerUserSetup = await this.features.get('PerUserSetup'); - - if (!this.isPerUserSetup) { - this.dataService.getAllDomains().subscribe(domains => { - this.domains = domains; - }); - } + this.dataService.getAllDomains().subscribe(domains => { + this.domains = domains; + }); if (!this.nodeId) { this.node = this.new(); diff --git a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts index ddfe661..7b9abdf 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts @@ -25,6 +25,7 @@ import { import { Group } from 'src/app/model/entities/group'; import { Domain } from 'src/app/model/entities/domain'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Injectable() export class ShortUrlsDataService @@ -37,7 +38,8 @@ export class ShortUrlsDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo + private apollo: Apollo, + private sidebarService: SidebarService ) { super(); } @@ -76,7 +78,22 @@ export class ShortUrlsDataService ${DB_MODEL_FRAGMENT} `, variables: { - filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])], + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { group: { deleted: { equal: false } } }, + { + group: { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + }, + ...(filter ?? []), + ], sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], skip, take, @@ -111,7 +128,15 @@ export class ShortUrlsDataService ${DB_MODEL_FRAGMENT} `, variables: { - filter: [{ group: { isNull: true } }, ...(filter ?? [])], + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { group: { isNull: true } }, + ...(filter ?? []), + ], sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], skip, take, @@ -263,6 +288,7 @@ export class ShortUrlsDataService `, variables: { input: { + userSpaceId: this.sidebarService.selectedUserSpace$.value?.id, shortUrl: object.shortUrl, targetUrl: object.targetUrl, description: object.description, @@ -365,12 +391,12 @@ export class ShortUrlsDataService .pipe(map(result => result.data?.shortUrl.restore ?? false)); } - getAllGroups() { + getAllAvailableGroups() { return this.apollo .query<{ groups: QueryResult }>({ query: gql` - query getGroups { - groups { + query getGroups($filter: [GroupFilter]) { + groups(filter: $filter) { nodes { id name @@ -378,6 +404,16 @@ export class ShortUrlsDataService } } `, + variables: { + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { deleted: { equal: false } }, + ], + }, }) .pipe( catchError(err => { diff --git a/web/src/app/modules/admin/short-urls/short-urls.module.ts b/web/src/app/modules/admin/short-urls/short-urls.module.ts index a144ca7..a9553e8 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.module.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.module.ts @@ -22,7 +22,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsCreate], - checkByPerUserSetup: true, }, }, { @@ -31,7 +30,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsUpdate], - checkByPerUserSetup: true, }, }, { @@ -40,7 +38,6 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrls], - checkByPerUserSetup: true, }, }, ], diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.html b/web/src/app/modules/admin/short-urls/short-urls.page.html index e0c3333..740c37c 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.page.html +++ b/web/src/app/modules/admin/short-urls/short-urls.page.html @@ -36,7 +36,6 @@ (onClick)="open(url.shortUrl)">

{{ 'short_url.short_url' | translate }}

- implements OnInit -{ +export class ShortUrlsPage extends PageBase< + ShortUrl, + ShortUrlsDataService, + ShortUrlsColumns +> { @ViewChild('imageUpload') imageUpload!: FileUpload; shortUrlsWithoutGroup: ShortUrl[] = []; @@ -56,53 +57,38 @@ export class ShortUrlsPage ); } - protected hasPermissions = { - read: false, - create: false, - update: false, - delete: false, - restore: false, - }; - constructor( private toast: ToastService, private confirmation: ConfirmationDialogService, - private auth: AuthService, private config: ConfigService, - private features: FeatureFlagService + private sidebar: SidebarService ) { super(true, { - read: [PermissionsEnum.shortUrls], - create: [PermissionsEnum.shortUrlsCreate], - update: [PermissionsEnum.shortUrlsUpdate], - delete: [PermissionsEnum.shortUrlsDelete], - restore: [PermissionsEnum.shortUrlsDelete], + read: [], + create: [], + update: [], + delete: [], + restore: [], }); + + this.sidebar.selectedUserSpace$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(true); + }); } - async ngOnInit() { - this.hasPermissions = { - read: - (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.read ?? [] - )) || (await this.features.get('PerUserSetup')), - create: - (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.create ?? [] - )) || (await this.features.get('PerUserSetup')), - update: - (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.update ?? [] - )) || (await this.features.get('PerUserSetup')), - delete: - (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.delete ?? [] - )) || (await this.features.get('PerUserSetup')), - restore: - (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.restore ?? [] - )) || (await this.features.get('PerUserSetup')), - }; + load(silent?: boolean): void { + if (!silent) this.loading = true; + this.dataService + .load(this.filter, this.sort, this.skip, this.take) + .subscribe(result => { + this.result = result; + this.shortUrlsWithoutGroup = this.getShortUrlsWithoutGroup(); + this.groupedShortUrls = this.getShortUrlsWithGroup(); + this.resolveColumns(); + this.loading = false; + }); } resolveColumns() { @@ -134,19 +120,6 @@ export class ShortUrlsPage }); } - load(silent?: boolean): void { - if (!silent) this.loading = true; - this.dataService - .load(this.filter, this.sort, this.skip, this.take) - .subscribe(result => { - this.result = result; - this.shortUrlsWithoutGroup = this.getShortUrlsWithoutGroup(); - this.groupedShortUrls = this.getShortUrlsWithGroup(); - this.resolveColumns(); - this.loading = false; - }); - } - delete(group: ShortUrl): void { this.confirmation.confirmDialog({ header: 'dialog.delete.header', diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html new file mode 100644 index 0000000..d9bc903 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.html @@ -0,0 +1,31 @@ + + +

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

+
+ + +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.name' | translate }}

+ +
+
+
diff --git a/api/src/api_graphql/filter/collection/__init__.py b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.scss similarity index 100% rename from api/src/api_graphql/filter/collection/__init__.py rename to web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.scss diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts new file mode 100644 index 0000000..d7bfe12 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; +import { SharedModule } from "src/app/modules/shared/shared.module"; +import { TranslateModule } from "@ngx-translate/core"; +import { AuthService } from "src/app/service/auth.service"; +import { ErrorHandlingService } from "src/app/service/error-handling.service"; +import { ToastService } from "src/app/service/toast.service"; +import { ConfirmationService, MessageService } from "primeng/api"; +import { ActivatedRoute } from "@angular/router"; +import { of } from "rxjs"; +import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +describe("ApiKeyFormpageComponent", () => { + let component: RoleFormPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RoleFormPageComponent], + imports: [ + BrowserAnimationsModule, + SharedModule, + TranslateModule.forRoot(), + ], + providers: [ + AuthService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ApiKeysDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RoleFormPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts new file mode 100644 index 0000000..7a5fdf7 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastService } from 'src/app/service/toast.service'; +import { FormPageBase } from 'src/app/core/base/form-page-base'; +import { + UserSpace, + UserSpaceCreateInput, + UserSpaceUpdateInput, +} from 'src/app/model/entities/user-space'; +import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; + +@Component({ + selector: 'app-user-space-form-page', + templateUrl: './user-space-form-page.component.html', + styleUrl: './user-space-form-page.component.scss', +}) +export class UserSpaceFormPageComponent extends FormPageBase< + UserSpace, + UserSpaceCreateInput, + UserSpaceUpdateInput, + UserSpacesDataService +> { + constructor(private toast: ToastService) { + super(undefined, '../..'); + if (!this.nodeId) { + this.node = this.new(); + this.setForm(this.node); + + return; + } + + this.dataService.loadById(this.nodeId).subscribe(node => { + this.node = node; + this.setForm(this.node); + }); + } + + new(): UserSpace { + return {} as UserSpace; + } + + buildForm() { + this.form = new FormGroup({ + id: new FormControl(undefined), + name: new FormControl(undefined, [ + Validators.required, + ]), + }); + this.form.controls['id'].disable(); + } + + setForm(node?: UserSpace) { + this.form.controls['id'].setValue(node?.id); + this.form.controls['name'].setValue(node?.name); + + this.spinner.hide(); + } + + getCreateInput(): UserSpaceCreateInput { + return { + name: this.form.controls['name'].value ?? undefined, + }; + } + + getUpdateInput(): UserSpaceUpdateInput { + if (!this.node?.id) { + throw new Error('Node id is missing'); + } + + return { + id: this.form.controls['id'].value, + name: this.form.controls['name'].value ?? undefined, + }; + } + + create(object: UserSpaceCreateInput): void { + this.dataService.create(object).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } + + update(object: UserSpaceUpdateInput): void { + this.dataService.update(object).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } +} diff --git a/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts new file mode 100644 index 0000000..d78ce6e --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts @@ -0,0 +1,292 @@ +import { Injectable, Provider } from '@angular/core'; +import { merge, Observable } from 'rxjs'; +import { + Create, + Delete, + PageDataService, + Restore, + Update, +} from 'src/app/core/base/page.data.service'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort, SortOrder } 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_HISTORY_MODEL_FRAGMENT, + 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 { + UserSpace, + UserSpaceCreateInput, + UserSpaceUpdateInput, +} from 'src/app/model/entities/user-space'; +import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; + +@Injectable() +export class UserSpacesDataService + extends PageDataService + implements + Create, + Update, + Delete, + Restore +{ + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) { + super(); + } + + load( + filter?: Filter[] | undefined, + sort?: Sort[] | undefined, + skip?: number | undefined, + take?: number | undefined + ): Observable> { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpaces( + $filter: [UserSpaceFilter] + $sort: [UserSpaceSort] + ) { + userSpaces(filter: $filter, sort: $sort) { + nodes { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + filter: [filter], + sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], + skip, + take, + }, + }) + .pipe(map(result => result.data.userSpaces)); + } + + loadById(id: number): Observable { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpace($id: Int) { + userSpaces(filter: { id: { equal: $id } }) { + nodes { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + id: id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.userSpaces.nodes[0])); + } + + loadHistory(id: number) { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpaceHistory($id: Int) { + userSpaces(filter: { id: { equal: $id } }) { + count + totalCount + nodes { + history { + id + name + + ...DB_HISTORY_MODEL + } + } + } + } + + ${DB_HISTORY_MODEL_FRAGMENT} + `, + variables: { + id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpaces?.nodes?.[0]?.history ?? [])); + } + + onChange(): Observable { + return merge( + this.apollo + .subscribe<{ userSpaceChange: void }>({ + query: gql` + subscription onUserSpaceChange { + userSpaceChange + } + `, + }) + .pipe(map(result => result.data?.userSpaceChange)), + + this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onGroupChange { + groupChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)) + ).pipe(map(() => {})); + } + + create(object: UserSpaceCreateInput): Observable { + return this.apollo + .mutate<{ userSpace: { create: UserSpace } }>({ + mutation: gql` + mutation createUserSpace($input: UserSpaceCreateInput!) { + userSpace { + create(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + name: object.name, + users: object.users, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.create)); + } + + update(object: UserSpaceUpdateInput): Observable { + return this.apollo + .mutate<{ userSpace: { update: UserSpace } }>({ + mutation: gql` + mutation updateUserSpace($input: UserSpaceUpdateInput!) { + userSpace { + update(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + id: object.id, + name: object.name, + users: object.users, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.update)); + } + + delete(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { delete: boolean } }>({ + mutation: gql` + mutation deleteUserSpace($id: Int!) { + userSpace { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.delete ?? false)); + } + + restore(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { restore: boolean } }>({ + mutation: gql` + mutation restoreUserSpace($id: Int!) { + userSpace { + restore(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.restore ?? false)); + } + + static provide(): Provider[] { + return [ + { + provide: PageDataService, + useClass: UserSpacesDataService, + }, + { + provide: PageWithHistoryDataService, + useClass: UserSpacesDataService, + }, + UserSpacesDataService, + ]; + } +} diff --git a/web/src/app/modules/admin/user-spaces/user-spaces.module.ts b/web/src/app/modules/admin/user-spaces/user-spaces.module.ts new file mode 100644 index 0000000..909f1a8 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/user-spaces.module.ts @@ -0,0 +1,24 @@ +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'; +import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; +import { UserSpaceFormPageComponent } from 'src/app/modules/admin/user-spaces/form-page/user-space-form-page.component'; + +const routes: Routes = [ + { + path: 'create', + component: UserSpaceFormPageComponent, + }, + { + path: 'edit/:id', + component: UserSpaceFormPageComponent, + }, +]; + +@NgModule({ + declarations: [UserSpaceFormPageComponent], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [UserSpacesDataService.provide()], +}) +export class UserSpacesModule {} diff --git a/web/src/app/modules/shared/components/side-menu/side-menu.component.html b/web/src/app/modules/shared/components/side-menu/side-menu.component.html index d257a08..7df2b8b 100644 --- a/web/src/app/modules/shared/components/side-menu/side-menu.component.html +++ b/web/src/app/modules/shared/components/side-menu/side-menu.component.html @@ -1,49 +1,51 @@ - -
-
- {{ element.label | translate }} -
-
- - -
-
+ +
+
+ {{ element.label | translate }} +
+
+ + +
+
- -
- + + <{{ element.label }} - {{ element.visible }} | {{ element.command }}> +
+ -
-
- -
- +
+ + <{{ item.label }} - {{ item.visible }} | {{ item.command }}> +
+ +
+
+
-
-
-
-
+
diff --git a/web/src/app/modules/shared/components/side-menu/side-menu.component.ts b/web/src/app/modules/shared/components/side-menu/side-menu.component.ts index edd0dec..9b9d3d5 100644 --- a/web/src/app/modules/shared/components/side-menu/side-menu.component.ts +++ b/web/src/app/modules/shared/components/side-menu/side-menu.component.ts @@ -1,14 +1,32 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnDestroy } from '@angular/core'; import { MenuElement } from 'src/app/model/view/menu-element'; +import { GuiService } from 'src/app/service/gui.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-side-menu', templateUrl: './side-menu.component.html', styleUrl: './side-menu.component.scss', }) -export class SideMenuComponent { +export class SideMenuComponent implements OnDestroy { @Input() elements: MenuElement[] = []; + debug = false; + + unsubscribe$ = new Subject(); + + constructor(private gui: GuiService) { + this.gui.debug$.pipe(takeUntil(this.unsubscribe$)).subscribe(debug => { + this.debug = debug; + }); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + expand(element: MenuElement): void { element.expanded = true; } diff --git a/web/src/app/service/gui.service.ts b/web/src/app/service/gui.service.ts index 708d2a7..1fd6a0b 100644 --- a/web/src/app/service/gui.service.ts +++ b/web/src/app/service/gui.service.ts @@ -20,6 +20,8 @@ export class GuiService { hideGui$ = new BehaviorSubject(false); theme$ = new BehaviorSubject(this.config.settings.themes[0].name); + debug$ = new BehaviorSubject(false); + constructor( private router: Router, private apollo: Apollo, diff --git a/web/src/app/service/sidebar.data.service.ts b/web/src/app/service/sidebar.data.service.ts new file mode 100644 index 0000000..be3dd89 --- /dev/null +++ b/web/src/app/service/sidebar.data.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import { Apollo, gql } from 'apollo-angular'; +import { Observable } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { UserSpace } from 'src/app/model/entities/user-space'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { SpinnerService } from 'src/app/service/spinner.service'; + +@Injectable({ + providedIn: 'root', +}) +export class SidebarDataService { + constructor( + private apollo: Apollo, + private spinner: SpinnerService + ) {} + + getAssignedUserSpaces() { + return this.apollo + .query<{ assignedUserSpaces: QueryResult }>({ + query: gql` + query getAssignedUserSpaces { + assignedUserSpaces { + nodes { + id + name + } + } + } + `, + }) + .pipe(map(result => result.data?.assignedUserSpaces.nodes)); + } + + onChange(): Observable { + return this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onUserSpaceChange { + userSpaceChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)); + } + + delete(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { delete: boolean } }>({ + mutation: gql` + mutation deleteUserSpace($id: Int!) { + userSpace { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.delete ?? false)); + } +} diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts index 49bff52..78d73dc 100644 --- a/web/src/app/service/sidebar.service.ts +++ b/web/src/app/service/sidebar.service.ts @@ -3,7 +3,11 @@ import { BehaviorSubject } from 'rxjs'; import { MenuElement } from 'src/app/model/view/menu-element'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; +import { SidebarDataService } from 'src/app/service/sidebar.data.service'; +import { UserSpace } from 'src/app/model/entities/user-space'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { Router } from '@angular/router'; @Injectable({ providedIn: 'root', @@ -12,13 +16,50 @@ export class SidebarService { visible$ = new BehaviorSubject(true); elements$ = new BehaviorSubject([]); + selectedUserSpace$ = new BehaviorSubject( + undefined + ); + userSpaces: UserSpace[] = []; + constructor( private auth: AuthService, - private featureFlags: FeatureFlagService + private data: SidebarDataService, + private spinner: SpinnerService, + private router: Router, + private confirmation: ConfirmationDialogService ) { + this.loadUserSpaces(); this.auth.user$.subscribe(async () => { await this.setElements(); }); + + this.data.onChange().subscribe(() => { + this.loadUserSpaces(); + }); + + this.selectedUserSpace$.subscribe(async value => { + // skip initial value + if (value === undefined) { + return; + } + + this.setSelectedUserSpaceIdToLS(value ? value.id : 0); + await this.setElements(); + }); + } + + loadUserSpaces() { + this.data.getAssignedUserSpaces().subscribe(async userSpaces => { + this.userSpaces = userSpaces; + + const storedId = this.getSelectedUserSpaceIdFromLS(); + + const selectedUserSpace = userSpaces.find(x => x.id === storedId); + if (selectedUserSpace) { + this.selectedUserSpace$.next(selectedUserSpace); + } + await this.setElements(); + }); } show() { @@ -33,33 +74,93 @@ export class SidebarService { async setElements() { const elements: MenuElement[] = [ { - label: 'common.domains', - icon: 'pi pi-sitemap', - routerLink: ['/admin/domains'], - visible: await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.domains, - ]), + label: 'sidebar.user_spaces', + icon: 'pi pi-list', + expanded: true, + items: [ + { + label: 'sidebar.user_space_add', + icon: 'pi pi-plus', + routerLink: ['/admin/rooms/create'], + }, + ...(this.userSpaces + ?.filter(x => x.name != this.selectedUserSpace$.value?.name) + .map(x => { + return { + label: x.name, + icon: 'pi pi-hashtag', + command: async () => { + this.spinner.show(); + this.selectedUserSpace$.next(x); + await this.setElements(); + await this.router.navigate(['/admin/urls']); + }, + } as MenuElement; + }) ?? []), + ], }, { - label: 'common.groups', - icon: 'pi pi-tags', - routerLink: ['/admin/groups'], - visible: - (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) || - (await this.featureFlags.get('PerUserSetup')), - }, - { - label: 'common.urls', - icon: 'pi pi-tag', - routerLink: ['/admin/urls'], - visible: - (await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.shortUrls, - PermissionsEnum.shortUrlsByAssignment, - ])) || (await this.featureFlags.get('PerUserSetup')), + label: this.selectedUserSpace$.value?.name || '', + icon: 'pi pi-hashtag', + visible: !!this.selectedUserSpace$.value, + expanded: true, + items: [ + { + label: 'sidebar.user_space_edit', + icon: 'pi pi-pencil', + routerLink: [ + `/admin/rooms/edit/${this.selectedUserSpace$.value?.id}`, + ], + }, + { + label: 'common.groups', + icon: 'pi pi-tags', + routerLink: ['/admin/groups'], + visible: + this.selectedUserSpace$.value !== null && + (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])), + }, + { + label: 'common.urls', + icon: 'pi pi-tag', + routerLink: ['/admin/urls'], + visible: + this.selectedUserSpace$.value !== null && + (await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.shortUrls, + PermissionsEnum.shortUrlsByAssignment, + ])), + }, + { + label: 'sidebar.user_space_delete', + icon: 'pi pi-trash', + command: () => { + this.confirmation.confirmDialog({ + header: 'dialog.delete.header', + message: 'dialog.delete.message', + accept: () => { + if (!this.selectedUserSpace$.value) { + return; + } + + this.spinner.show(); + this.data + .delete(this.selectedUserSpace$.value) + .subscribe(() => { + this.selectedUserSpace$.next(null); + this.spinner.hide(); + }); + }, + messageParams: { entity: this.selectedUserSpace$.value?.name }, + }); + }, + }, + ], }, + await this.sectionAdmin(), ]; + this.elements$.next(elements); } @@ -71,6 +172,14 @@ export class SidebarService { expanded: true, isSection: true, items: [ + { + label: 'common.domains', + icon: 'pi pi-sitemap', + routerLink: ['/admin/administration/domains'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.domains, + ]), + }, { label: 'sidebar.users', icon: 'pi pi-user', @@ -114,4 +223,13 @@ export class SidebarService { ], }; } + + protected getSelectedUserSpaceIdFromLS() { + const storedId = localStorage.getItem('sidebar_SelectedUserSpaceId'); + return storedId ? +storedId : null; + } + + protected setSelectedUserSpaceIdToLS(id: number) { + localStorage.setItem('sidebar_SelectedUserSpaceId', id.toString()); + } } diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index aa96be0..e76cf8b 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -42,6 +42,7 @@ "updated": "Bearbeitet", "upload": "Hochladen", "urls": "URLs", + "user_space": "Team", "warning": "Warnung" }, "dialog": { @@ -239,6 +240,10 @@ }, "roles": "Rollen", "settings": "Einstellungen", + "user_space_add": "Team erstellen", + "user_space_delete": "Team löschen", + "user_space_edit": "Team bearbeiten", + "user_spaces": "Teams", "users": "Benutzer" }, "table": { diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 4f95c94..3bbe59b 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -42,6 +42,7 @@ "updated": "Updated", "upload": "Upload", "urls": "URLs", + "user_space": "Team", "warning": "Warning" }, "dialog": { @@ -239,6 +240,10 @@ }, "roles": "Roles", "settings": "Settings", + "user_space_add": "Add Team", + "user_space_delete": "Delete Team", + "user_space_edit": "Edit Team", + "user_spaces": "Teams", "users": "Users" }, "table": {