Reworked user spaces #15
Some checks failed
Test API before pr merge / test-lint (pull_request) Successful in 12s
Test before pr merge / test-translation-lint (pull_request) Successful in 43s
Test before pr merge / test-lint (pull_request) Failing after 46s
Test before pr merge / test-before-merge (pull_request) Failing after 1m39s

This commit is contained in:
Sven Heidemann 2025-05-01 13:32:16 +02:00
parent bf0f5aa54d
commit bcd79b18ab
96 changed files with 1820 additions and 526 deletions

View File

@ -51,6 +51,10 @@ class RouteUserExtension:
if request is None: if request is None:
return 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) user_id = cls._get_user_id_from_token(request)
if not user_id: if not user_id:
return None return None
@ -63,6 +67,15 @@ class RouteUserExtension:
if request is None: if request is None:
return 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)) return await userDao.find_by_keycloak_id(cls.get_token(request))
@classmethod @classmethod

View File

@ -174,9 +174,9 @@ class QueryABC(ObjectType):
kwargs["sort"] = None kwargs["sort"] = None
if iscoroutinefunction(field.resolver): if iscoroutinefunction(field.resolver):
collection = await field.collection_resolver(*args) collection = await field.resolver(*args)
else: else:
collection = field.collection_resolver(*args) collection = field.resolver(*args)
return self._resolve_collection( return self._resolve_collection(
collection, collection,

View File

@ -15,7 +15,7 @@ class CollectionField(FieldABC):
require_any_permission: list[Permissions] = None, require_any_permission: list[Permissions] = None,
require_any: TRequireAny = None, require_any: TRequireAny = None,
public: bool = False, public: bool = False,
collection_resolver: Callable = None, resolver: Callable = None,
filter_type: Type[CollectionFilterABC] = None, filter_type: Type[CollectionFilterABC] = None,
sort_type: Type[T] = None, sort_type: Type[T] = None,
): ):
@ -23,13 +23,13 @@ class CollectionField(FieldABC):
self._name = name self._name = name
self._public = public self._public = public
self._collection_resolver = collection_resolver self._resolver = resolver
self._filter_type = filter_type self._filter_type = filter_type
self._sort_type = sort_type self._sort_type = sort_type
@property @property
def collection_resolver(self) -> Optional[Callable]: def resolver(self) -> Optional[Callable]:
return self._collection_resolver return self._resolver
@property @property
def filter_type( def filter_type(

View File

@ -11,13 +11,13 @@ class CollectionFieldBuilder(FieldBuilderABC):
def __init__(self, name: str): def __init__(self, name: str):
FieldBuilderABC.__init__(self, name) FieldBuilderABC.__init__(self, name)
self._collection_resolver = None self._resolver = None
self._filter_type = None self._filter_type = None
self._sort_type = None self._sort_type = None
def with_collection_resolver(self, collection_resolver: Callable) -> Self: def with_resolver(self, resolver: Callable) -> Self:
assert collection_resolver is not None, "collection_resolver cannot be None" assert resolver is not None, "resolver cannot be None"
self._collection_resolver = collection_resolver self._resolver = resolver
return self return self
def with_filter(self, filter_type: Type[CollectionFilterABC]) -> Self: def with_filter(self, filter_type: Type[CollectionFilterABC]) -> Self:
@ -31,16 +31,14 @@ class CollectionFieldBuilder(FieldBuilderABC):
return self return self
def build(self) -> CollectionField: def build(self) -> CollectionField:
assert ( assert self._resolver is not None, "resolver cannot be None"
self._collection_resolver is not None
), "collection_resolver cannot be None"
return CollectionField( return CollectionField(
self._name, self._name,
self._require_any_permission, self._require_any_permission,
self._require_any, self._require_any,
self._public, self._public,
self._collection_resolver, self._resolver,
self._filter_type, self._filter_type,
self._sort_type, self._sort_type,
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.db_model_filter_abc import DbModelFilterABC
from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.abc.filter.string_filter import StringFilter
from api_graphql.filter.user_space_filter import UserSpaceFilter
class GroupFilter(DbModelFilterABC): class GroupFilter(DbModelFilterABC):
@ -11,6 +12,7 @@ class GroupFilter(DbModelFilterABC):
self.add_field("name", StringFilter) self.add_field("name", StringFilter)
self.add_field("description", StringFilter) self.add_field("description", StringFilter)
self.add_field("userSpace", UserSpaceFilter, "userspace")
self.add_field("isNull", bool) self.add_field("isNull", bool)
self.add_field("isNotNull", bool) self.add_field("isNotNull", bool)

View File

@ -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.abc.filter.string_filter import StringFilter
from api_graphql.filter.domain_filter import DomainFilter from api_graphql.filter.domain_filter import DomainFilter
from api_graphql.filter.group_filter import GroupFilter from api_graphql.filter.group_filter import GroupFilter
from api_graphql.filter.user_space_filter import UserSpaceFilter
class ShortUrlFilter(DbModelFilterABC): class ShortUrlFilter(DbModelFilterABC):
@ -17,3 +18,5 @@ class ShortUrlFilter(DbModelFilterABC):
self.add_field("group", GroupFilter) self.add_field("group", GroupFilter)
self.add_field("domain", DomainFilter) self.add_field("domain", DomainFilter)
self.add_field("userSpace", UserSpaceFilter, "userspace")

View File

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

View File

@ -8,6 +8,8 @@ type GroupHistory implements DbHistoryModel {
id: Int id: Int
name: String name: String
userSpace: UserSpace
shortUrls: [ShortUrl] shortUrls: [ShortUrl]
roles: [Role] roles: [Role]
@ -21,6 +23,8 @@ type Group implements DbModel {
id: Int id: Int
name: String name: String
userSpace: UserSpace
shortUrls: [ShortUrl] shortUrls: [ShortUrl]
roles: [Role] roles: [Role]
@ -55,6 +59,8 @@ input GroupFilter {
id: IntFilter id: IntFilter
name: StringFilter name: StringFilter
userSpace: UserSpaceFilter
fuzzy: GroupFuzzy fuzzy: GroupFuzzy
deleted: BooleanFilter deleted: BooleanFilter
@ -74,6 +80,7 @@ type GroupMutation {
} }
input GroupCreateInput { input GroupCreateInput {
userSpaceId: Int!
name: String! name: String!
roles: [Int] roles: [Int]
} }

View File

@ -4,8 +4,10 @@ type Mutation {
user: UserMutation user: UserMutation
role: RoleMutation role: RoleMutation
group: GroupMutation
domain: DomainMutation domain: DomainMutation
userSpace: UserSpaceMutation
group: GroupMutation
shortUrl: ShortUrlMutation shortUrl: ShortUrlMutation
setting: SettingMutation setting: SettingMutation

View File

@ -12,6 +12,9 @@ type Query {
notExistingUsersFromKeycloak: [KeycloakUser] notExistingUsersFromKeycloak: [KeycloakUser]
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult 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 groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult

View File

@ -14,6 +14,7 @@ type ShortUrlHistory implements DbHistoryModel {
group: Group group: Group
domain: Domain domain: Domain
userSpace: UserSpace
deleted: Boolean deleted: Boolean
editor: String editor: String
@ -27,9 +28,11 @@ type ShortUrl implements DbModel {
targetUrl: String targetUrl: String
description: String description: String
visits: Int visits: Int
loadingScreen: Boolean
group: Group group: Group
domain: Domain domain: Domain
loadingScreen: Boolean userSpace: UserSpace
deleted: Boolean deleted: Boolean
editor: User editor: User
@ -68,9 +71,12 @@ input ShortUrlFilter {
targetUrl: StringFilter targetUrl: StringFilter
description: StringFilter description: StringFilter
loadingScreen: BooleanFilter loadingScreen: BooleanFilter
group: GroupFilter group: GroupFilter
domain: DomainFilter domain: DomainFilter
userSpace: UserSpaceFilter
fuzzy: ShortUrlFuzzy fuzzy: ShortUrlFuzzy
deleted: BooleanFilter deleted: BooleanFilter
@ -88,6 +94,7 @@ type ShortUrlMutation {
} }
input ShortUrlCreateInput { input ShortUrlCreateInput {
userSpaceId: Int!
shortUrl: String! shortUrl: String!
targetUrl: String! targetUrl: String!
description: String description: String

View File

@ -12,6 +12,7 @@ type Subscription {
userLogout: SubscriptionChange userLogout: SubscriptionChange
domainChange: SubscriptionChange domainChange: SubscriptionChange
userSpaceChange: SubscriptionChange
groupChange: SubscriptionChange groupChange: SubscriptionChange
shortUrlChange: SubscriptionChange shortUrlChange: SubscriptionChange
} }

View File

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

View File

@ -6,9 +6,14 @@ class GroupCreateInput(InputABC):
def __init__(self, src: dict): def __init__(self, src: dict):
InputABC.__init__(self, src) InputABC.__init__(self, src)
self._user_space_id = self.option("userSpaceId", int, required=True)
self._name = self.option("name", str, required=True) self._name = self.option("name", str, required=True)
self._roles = self.option("roles", list[int]) self._roles = self.option("roles", list[int])
@property
def user_space_id(self) -> int:
return self._user_space_id
@property @property
def name(self) -> str: def name(self) -> str:
return self._name return self._name

View File

@ -8,6 +8,7 @@ class ShortUrlCreateInput(InputABC):
def __init__(self, src: dict): def __init__(self, src: dict):
InputABC.__init__(self, src) InputABC.__init__(self, src)
self._user_space_id = self.option("userSpaceId", int, required=True)
self._short_url = self.option("shortUrl", str, required=True) self._short_url = self.option("shortUrl", str, required=True)
self._target_url = self.option("targetUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True)
self._description = self.option("description", str) self._description = self.option("description", str)
@ -15,6 +16,10 @@ class ShortUrlCreateInput(InputABC):
self._domain_id = self.option("domainId", int) self._domain_id = self.option("domainId", int)
self._loading_screen = self.option("loadingScreen", bool) self._loading_screen = self.option("loadingScreen", bool)
@property
def user_space_id(self) -> int:
return self._user_space_id
@property @property
def short_url(self) -> str: def short_url(self) -> str:
return self._short_url return self._short_url

View File

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

View File

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

View File

@ -1,5 +1,4 @@
from api_graphql.abc.mutation_abc import MutationABC 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 from service.permission.permissions_enum import Permissions
@ -44,28 +43,34 @@ class Mutation(MutationABC):
], ],
) )
self.add_mutation_type( self.add_mutation_type(
"group", "userSpace",
"Group", "UserSpace",
require_any=( require_any=(
[ [
Permissions.user_spaces_create,
Permissions.user_spaces_update,
Permissions.user_spaces_delete,
],
[self._test],
),
)
self.add_mutation_type(
"group",
"Group",
require_any_permission=[
Permissions.groups_create, Permissions.groups_create,
Permissions.groups_update, Permissions.groups_update,
Permissions.groups_delete, Permissions.groups_delete,
], ],
[by_user_setup_mutation],
),
) )
self.add_mutation_type( self.add_mutation_type(
"shortUrl", "shortUrl",
"ShortUrl", "ShortUrl",
require_any=( require_any_permission=[
[
Permissions.short_urls_create, Permissions.short_urls_create,
Permissions.short_urls_update, Permissions.short_urls_update,
Permissions.short_urls_delete, Permissions.short_urls_delete,
], ],
[by_user_setup_mutation],
),
) )
self.add_mutation_type( self.add_mutation_type(
@ -90,3 +95,7 @@ class Mutation(MutationABC):
"privacy", "privacy",
"Privacy", "Privacy",
) )
@staticmethod
async def _test(*args, **kwargs):
return True

View File

@ -1,14 +1,11 @@
from typing import Optional from typing import Optional
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput 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.logger import APILogger
from core.string import first_to_lower
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment import GroupRoleAssignment from data.schemas.public.group_role_assignment import GroupRoleAssignment
@ -26,26 +23,38 @@ class GroupMutation(MutationABC):
MutationFieldBuilder("create") MutationFieldBuilder("create")
.with_resolver(self.resolve_create) .with_resolver(self.resolve_create)
.with_input(GroupCreateInput) .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( self.field(
MutationFieldBuilder("update") MutationFieldBuilder("update")
.with_resolver(self.resolve_update) .with_resolver(self.resolve_update)
.with_input(GroupUpdateInput) .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( self.field(
MutationFieldBuilder("delete") MutationFieldBuilder("delete")
.with_resolver(self.resolve_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( self.field(
MutationFieldBuilder("restore") MutationFieldBuilder("restore")
.with_resolver(self.resolve_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 @staticmethod
@ -87,12 +96,9 @@ class GroupMutation(MutationABC):
group = Group( group = Group(
0, 0,
obj.name, obj.name,
( obj.user_space_id,
(await Route.get_user()).id
if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)
else None
),
) )
gid = await groupDao.create(group) gid = await groupDao.create(group)
await cls._handle_group_role_assignments(gid, obj.roles) await cls._handle_group_role_assignments(gid, obj.roles)

View File

@ -3,10 +3,8 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder 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_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput 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.logger import APILogger
from core.string import first_to_lower
from data.schemas.public.domain_dao import domainDao from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
@ -26,31 +24,46 @@ class ShortUrlMutation(MutationABC):
MutationFieldBuilder("create") MutationFieldBuilder("create")
.with_resolver(self.resolve_create) .with_resolver(self.resolve_create)
.with_input(ShortUrlCreateInput) .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( self.field(
MutationFieldBuilder("update") MutationFieldBuilder("update")
.with_resolver(self.resolve_update) .with_resolver(self.resolve_update)
.with_input(ShortUrlUpdateInput) .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( self.field(
MutationFieldBuilder("delete") MutationFieldBuilder("delete")
.with_resolver(self.resolve_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( self.field(
MutationFieldBuilder("restore") MutationFieldBuilder("restore")
.with_resolver(self.resolve_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( self.field(
MutationFieldBuilder("trackVisit") MutationFieldBuilder("trackVisit")
.with_resolver(self.resolve_track_visit) .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]) .with_require_any_permission([Permissions.short_urls_update])
) )
@ -70,11 +83,7 @@ class ShortUrlMutation(MutationABC):
obj.group_id, obj.group_id,
obj.domain_id, obj.domain_id,
obj.loading_screen, obj.loading_screen,
( obj.user_space_id,
(await Route.get_user()).id
if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)
else None
),
) )
nid = await shortUrlDao.create(short_url) nid = await shortUrlDao.create(short_url)
return await shortUrlDao.get_by_id(nid) return await shortUrlDao.get_by_id(nid)

View File

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

View File

@ -1,6 +1,6 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder 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 import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
@ -14,6 +14,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC):
DbHistoryModelQueryABC.__init__(self, "Group") DbHistoryModelQueryABC.__init__(self, "Group")
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("userSpace", lambda x, *_: x.user_space)
self.field( self.field(
ResolverFieldBuilder("shortUrls") ResolverFieldBuilder("shortUrls")
.with_resolver(self._get_urls) .with_resolver(self._get_urls)
@ -21,7 +22,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC):
[ [
Permissions.groups, Permissions.groups,
], ],
[by_assignment_resolver], [by_group_assignment_resolver],
) )
) )
self.set_field( self.set_field(

View File

@ -1,6 +1,7 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder 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 import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao 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) DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("userSpace", lambda x, *_: x.user_space)
self.field( self.field(
ResolverFieldBuilder("shortUrls") ResolverFieldBuilder("shortUrls")
.with_resolver(self._get_urls) .with_resolver(self._get_urls)
@ -21,7 +23,7 @@ class GroupQuery(DbModelQueryABC):
[ [
Permissions.groups, Permissions.groups,
], ],
[by_assignment_resolver], [by_group_assignment_resolver],
) )
) )
self.set_field("roles", self._get_roles) self.set_field("roles", self._get_roles)

View File

@ -8,7 +8,10 @@ class ShortUrlQuery(DbHistoryModelQueryABC):
self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description) 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("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen) 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)

View File

@ -9,7 +9,10 @@ class ShortUrlQuery(DbModelQueryABC):
self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description) 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("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen) 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)

View File

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

View File

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

View File

@ -2,6 +2,7 @@ from api.auth.keycloak_client import Keycloak
from api.route import Route from api.route import Route
from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.query_abc import QueryABC
from api_graphql.abc.sort_abc import Sort 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.dao_field_builder import DaoFieldBuilder
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.filter.api_key_filter import ApiKeyFilter 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.role_filter import RoleFilter
from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.short_url_filter import ShortUrlFilter
from api_graphql.filter.user_filter import UserFilter from api_graphql.filter.user_filter import UserFilter
from api_graphql.filter.user_space_filter import UserSpaceFilter
from api_graphql.require_any_resolvers import ( from api_graphql.require_any_resolvers import (
by_assignment_resolver, by_group_assignment_resolver,
by_user_setup_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 import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.administration.user import User 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.short_url_dao import shortUrlDao
from data.schemas.public.user_setting import UserSetting from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingDao 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.feature_flag_dao import featureFlagDao
from data.schemas.system.setting_dao import settingDao from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
@ -109,13 +110,31 @@ class Query(QueryABC):
.with_require_any_permission( .with_require_any_permission(
[ [
Permissions.domains, Permissions.domains,
Permissions.short_urls_create, Permissions.domains_create,
Permissions.short_urls_update, 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") DaoFieldBuilder("groups")
.with_dao(groupDao) .with_dao(groupDao)
.with_filter(GroupFilter) .with_filter(GroupFilter)
@ -126,35 +145,21 @@ class Query(QueryABC):
Permissions.short_urls_create, Permissions.short_urls_create,
Permissions.short_urls_update, Permissions.short_urls_update,
], ],
[by_assignment_resolver, by_user_setup_resolver], [by_group_assignment_resolver],
) )
) )
if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): self.field(
group_field = group_field.with_default_filter(
self._resolve_default_user_filter
)
self.field(group_field)
short_url_field = (
DaoFieldBuilder("shortUrls") DaoFieldBuilder("shortUrls")
.with_dao(shortUrlDao) .with_dao(shortUrlDao)
.with_filter(ShortUrlFilter) .with_filter(ShortUrlFilter)
.with_sort(Sort[ShortUrl]) .with_sort(Sort[ShortUrl])
.with_require_any( .with_require_any(
[Permissions.short_urls], [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( self.field(
ResolverFieldBuilder("settings") ResolverFieldBuilder("settings")
.with_resolver(self._resolve_settings) .with_resolver(self._resolve_settings)
@ -230,3 +235,11 @@ class Query(QueryABC):
@staticmethod @staticmethod
async def _resolve_default_user_filter(*args, **kwargs) -> dict: async def _resolve_default_user_filter(*args, **kwargs) -> dict:
return {"user": {"id": {"equal": (await Route.get_user()).id}}} 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)

View File

@ -1,12 +1,32 @@
from api_graphql.service.collection_result import CollectionResult from api_graphql.service.collection_result import CollectionResult
from api_graphql.service.query_context import QueryContext 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.group_dao import groupDao
from data.schemas.public.user_space_user_dao import userSpaceUserDao
from service.permission.permissions_enum import Permissions 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): if not isinstance(ctx.data, CollectionResult):
return False return False
@ -27,17 +47,3 @@ async def by_assignment_resolver(ctx: QueryContext) -> bool:
) )
return False 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)

View File

@ -60,6 +60,11 @@ class Subscription(SubscriptionABC):
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)
.with_require_any_permission([Permissions.domains]) .with_require_any_permission([Permissions.domains])
) )
self.subscribe(
SubscriptionFieldBuilder("userSpaceChange").with_resolver(
lambda message, *_: message.message
)
)
self.subscribe( self.subscribe(
SubscriptionFieldBuilder("groupChange") SubscriptionFieldBuilder("groupChange")
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)

View File

@ -9,14 +9,9 @@ class FeatureFlags:
_flags = { _flags = {
FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025 FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025
FeatureFlagsEnum.technical_demo_banner.value: False, # 18.04.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 = [ _overwrite_flags = []
FeatureFlagsEnum.per_user_setup.value,
]
@staticmethod @staticmethod
def overwrite_flag(key: str): def overwrite_flag(key: str):

View File

@ -4,4 +4,3 @@ from enum import Enum
class FeatureFlagsEnum(Enum): class FeatureFlagsEnum(Enum):
version_endpoint = "VersionEndpoint" version_endpoint = "VersionEndpoint"
technical_demo_banner = "TechnicalDemoBanner" technical_demo_banner = "TechnicalDemoBanner"
per_user_setup = "PerUserSetup"

View File

@ -37,6 +37,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
self.__db_names: dict[str, str] = {} self.__db_names: dict[str, str] = {}
self.__foreign_tables: dict[str, tuple[str, str]] = {} self.__foreign_tables: dict[str, tuple[str, str]] = {}
self.__foreign_table_keys: dict[str, str] = {} self.__foreign_table_keys: dict[str, str] = {}
self.__foreign_dao: dict[str, "DataAccessObjectABC"] = {}
self.__date_attributes: set[str] = set() self.__date_attributes: set[str] = set()
self.__ignored_attributes: set[str] = set() self.__ignored_attributes: set[str] = set()
@ -108,6 +109,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
primary_attr: Attribute, primary_attr: Attribute,
foreign_attr: Attribute, foreign_attr: Attribute,
table_name: str, table_name: str,
reference_dao: "DataAccessObjectABC" = None,
): ):
""" """
Add a reference to another table for the given attribute 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 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 foreign_attr: Name of the foreign key in the object
:param str table_name: Name of the table to reference :param str table_name: Name of the table to reference
:param DataAccessObjectABC reference_dao: The data access object for the referenced table
:return: :return:
""" """
if isinstance(attr, property): if isinstance(attr, property):
@ -131,6 +134,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
foreign_attr = foreign_attr.lower().replace("_", "") foreign_attr = foreign_attr.lower().replace("_", "")
self.__foreign_table_keys[attr] = foreign_attr 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: if table_name == self._table_name:
return return
@ -622,7 +628,27 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
external_table_deps.append(external_field) external_table_deps.append(external_field)
parse_node(value, f"{external_field}.{key}") parse_node(value, f"{external_field}.{key}")
elif parent_key in self.__foreign_table_keys: elif parent_key in self.__foreign_table_keys:
if key in operators:
parse_node({key: value}, self.__foreign_table_keys[parent_key]) 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: elif key in operators:
operator = operators[key] operator = operators[key]
if key == "contains" or key == "notContains": if key == "contains" or key == "notContains":
@ -633,6 +659,23 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
value = f"{value}%" value = f"{value}%"
elif key == "endsWith": elif key == "endsWith":
value = f"%{value}" 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: elif (key == "equal" or key == "notEqual") and value is None:
operator = operators["isNull"] operator = operators["isNull"]
@ -641,6 +684,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
elif isinstance(value, dict): elif isinstance(value, dict):
if key in self.__foreign_table_keys: if key in self.__foreign_table_keys:
parse_node(value, key) 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: elif key in self.__db_names:
parse_node(value, self.__db_names[key]) parse_node(value, self.__db_names[key])
else: else:

View File

@ -3,11 +3,12 @@ from typing import Optional
from async_property import async_property from async_property import async_property
from core.database.abc.db_join_model_abc import DbJoinModelABC
from core.typing import SerialId from core.typing import SerialId
from core.database.abc.db_model_abc import DbModelABC from core.database.abc.db_model_abc import DbModelABC
class RoleUser(DbModelABC): class RoleUser(DbJoinModelABC):
def __init__( def __init__(
self, self,
id: SerialId, id: SerialId,
@ -18,7 +19,9 @@ class RoleUser(DbModelABC):
created: Optional[datetime] = None, created: Optional[datetime] = None,
updated: 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._role_id = role_id
self._user_id = user_id self._user_id = user_id

View File

@ -12,7 +12,7 @@ class Group(DbModelABC):
self, self,
id: SerialId, id: SerialId,
name: str, name: str,
user_id: Optional[SerialId] = None, user_space_id: Optional[SerialId] = None,
deleted: bool = False, deleted: bool = False,
editor_id: Optional[SerialId] = None, editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None, created: Optional[datetime] = None,
@ -20,7 +20,7 @@ class Group(DbModelABC):
): ):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated) DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
self._name = name self._name = name
self._user_id = user_id self._user_space_id = user_space_id
@property @property
def name(self) -> str: def name(self) -> str:
@ -31,15 +31,14 @@ class Group(DbModelABC):
self._name = value self._name = value
@property @property
def user_id(self) -> Optional[SerialId]: def user_space_id(self) -> Optional[SerialId]:
return self._user_id return self._user_space_id
@async_property @async_property
async def user(self): async def user_space(self):
if self._user_id is None: if self._user_space_id is None:
return 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 await userSpaceDao.get_by_id(self._user_space_id)
return user

View File

@ -11,8 +11,8 @@ class GroupDao(DbModelDaoABC[Group]):
DbModelDaoABC.__init__(self, __name__, Group, "public.groups") DbModelDaoABC.__init__(self, __name__, Group, "public.groups")
self.attribute(Group.name, str) self.attribute(Group.name, str)
self.attribute(Group.user_id, int) self.attribute(Group.user_space_id, int)
self.reference("user", "id", Group.user_id, "administration.users") self.reference("userspace", "id", Group.user_space_id, "public.user_spaces")
async def get_by_name(self, name: str) -> Group: async def get_by_name(self, name: str) -> Group:
result = await self._db.select_map( result = await self._db.select_map(

View File

@ -18,7 +18,7 @@ class ShortUrl(DbModelABC):
group_id: Optional[SerialId], group_id: Optional[SerialId],
domain_id: Optional[SerialId], domain_id: Optional[SerialId],
loading_screen: Optional[str] = None, loading_screen: Optional[str] = None,
user_id: Optional[SerialId] = None, user_space_id: Optional[SerialId] = None,
deleted: bool = False, deleted: bool = False,
editor_id: Optional[SerialId] = None, editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None, created: Optional[datetime] = None,
@ -35,7 +35,7 @@ class ShortUrl(DbModelABC):
loading_screen = False loading_screen = False
self._loading_screen = loading_screen self._loading_screen = loading_screen
self._user_id = user_id self._user_space_id = user_space_id
@property @property
def short_url(self) -> str: def short_url(self) -> str:
@ -110,18 +110,17 @@ class ShortUrl(DbModelABC):
self._loading_screen = value self._loading_screen = value
@property @property
def user_id(self) -> Optional[SerialId]: def user_space_id(self) -> Optional[SerialId]:
return self._user_id return self._user_space_id
@async_property @async_property
async def user(self): async def user_space(self):
if self._user_id is None: if self._user_space_id is None:
return 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 await userSpaceDao.get_by_id(self._user_space_id)
return user
def to_dto(self) -> dict: def to_dto(self) -> dict:
return { return {

View File

@ -13,13 +13,17 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]):
self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.target_url, str)
self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.description, str)
self.attribute(ShortUrl.group_id, int) 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.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.loading_screen, bool)
self.attribute(ShortUrl.user_id, int) self.attribute(ShortUrl.user_space_id, int)
self.reference("user", "id", ShortUrl.user_id, "administration.users") self.reference("userspace", "id", ShortUrl.user_space_id, "public.user_spaces")
shortUrlDao = ShortUrlDao() shortUrlDao = ShortUrlDao()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,6 +26,18 @@ class Permissions(Enum):
settings = "settings" settings = "settings"
settings_update = "settings.update" 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 Permissions
""" """
@ -38,12 +50,6 @@ class Permissions(Enum):
""" """
Public Public
""" """
# domains
domains = "domains"
domains_create = "domains.create"
domains_update = "domains.update"
domains_delete = "domains.delete"
# groups # groups
groups = "groups" groups = "groups"
groups_create = "groups.create" groups_create = "groups.create"

View File

@ -1 +0,0 @@
<p>home works!</p>

View File

@ -26,8 +26,12 @@ export abstract class FormPageBase<
protected filterService = inject(FilterService); protected filterService = inject(FilterService);
protected dataService = inject(PageDataService) as S; 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]; const id = this.route.snapshot.params[idKey];
this.backRoute = backRoute;
this.validateRoute(id); this.validateRoute(id);
this.buildForm(); this.buildForm();
@ -46,7 +50,7 @@ export abstract class FormPageBase<
} }
close() { close() {
const backRoute = this.nodeId ? '../..' : '..'; const backRoute = this.nodeId ? `${this.backRoute}/..` : this.backRoute;
this.router.navigate([backRoute], { relativeTo: this.route }).then(() => { this.router.navigate([backRoute], { relativeTo: this.route }).then(() => {
this.filterService.onLoad.emit(); this.filterService.onLoad.emit();

View File

@ -4,7 +4,6 @@ import { Logger } from 'src/app/service/logger.service';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from 'src/app/service/auth.service'; import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
const log = new Logger('PermissionGuard'); const log = new Logger('PermissionGuard');
@ -15,18 +14,11 @@ export class PermissionGuard {
constructor( constructor(
private router: Router, private router: Router,
private toast: ToastService, private toast: ToastService,
private auth: AuthService, private auth: AuthService
private features: FeatureFlagService
) {} ) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> { async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data['permissions'] as PermissionsEnum[]; 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) { if (!permissions || permissions.length === 0) {
return true; return true;

View File

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

View File

@ -7,13 +7,9 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
const routes: Routes = [ const routes: Routes = [
{ {
path: 'domains', path: '',
loadChildren: () => pathMatch: 'full',
import('src/app/modules/admin/domains/domains.module').then( redirectTo: 'urls',
m => m.DomainsModule
),
canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.domains] },
}, },
{ {
path: 'groups', path: 'groups',
@ -22,7 +18,7 @@ const routes: Routes = [
m => m.GroupsModule m => m.GroupsModule
), ),
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true }, data: { permissions: [PermissionsEnum.groups] },
}, },
{ {
path: 'urls', path: 'urls',
@ -36,9 +32,15 @@ const routes: Routes = [
PermissionsEnum.shortUrls, PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment, PermissionsEnum.shortUrlsByAssignment,
], ],
checkByPerUserSetup: true,
}, },
}, },
{
path: 'rooms',
loadChildren: () =>
import('src/app/modules/admin/user-spaces/user-spaces.module').then(
m => m.UserSpacesModule
),
},
{ {
path: 'administration', path: 'administration',
loadChildren: () => loadChildren: () =>

View File

@ -11,6 +11,15 @@ const routes: Routes = [
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'users', 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', path: 'users',
title: 'Users | Maxlan', title: 'Users | Maxlan',

View File

@ -4,11 +4,11 @@ import { SharedModule } from 'src/app/modules/shared/shared.module';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { PermissionGuard } from 'src/app/core/guard/permission.guard'; import { PermissionGuard } from 'src/app/core/guard/permission.guard';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { DomainsPage } from 'src/app/modules/admin/domains/domains.page'; import { DomainsPage } from 'src/app/modules/admin/administration/domains/domains.page';
import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component'; import { DomainFormPageComponent } from 'src/app/modules/admin/administration/domains/form-page/domain-form-page.component';
import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service';
import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; import { DomainsColumns } from 'src/app/modules/admin/administration/domains/domains.columns';
import { HistoryComponent } from 'src/app/modules/admin/domains/history/history.component'; import { HistoryComponent } from 'src/app/modules/admin/administration/domains/history/history.component';
const routes: Routes = [ const routes: Routes = [
{ {

View File

@ -3,9 +3,9 @@ import { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; 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 { 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({ @Component({
selector: 'app-domains', selector: 'app-domains',

View File

@ -7,7 +7,7 @@ import {
DomainCreateInput, DomainCreateInput,
DomainUpdateInput, DomainUpdateInput,
} from 'src/app/model/entities/domain'; } 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({ @Component({
selector: 'app-domain-form-page', selector: 'app-domain-form-page',

View File

@ -26,6 +26,8 @@ export class UsersPage extends PageBase<User, UsersDataService, UsersColumns> {
}); });
} }
async onInit(): Promise<void> {}
load(silent?: boolean): void { load(silent?: boolean): void {
if (!silent) this.loading = true; if (!silent) this.loading = true;
this.dataService this.dataService

View File

@ -27,14 +27,13 @@
type="text" type="text"
formControlName="name"/> formControlName="name"/>
</div> </div>
<div *ngIf="!isPerUserSetup" class="divider"></div> <div class="divider"></div>
<p-multiSelect <p-multiSelect
*ngIf="!isPerUserSetup"
[options]="roles" [options]="roles"
formControlName="roles" formControlName="roles"
optionLabel="name" optionLabel="name"
placeholder="{{ 'user.assign_roles' | translate }}" placeholder="{{ 'user.assign_roles' | translate }}"
display="chip" display="chip"
[showClear]="true" /> [showClear]="true"/>
</ng-template> </ng-template>
</app-form-page> </app-form-page>

View File

@ -17,17 +17,13 @@ import { FeatureFlagService } from 'src/app/service/feature-flag.service';
templateUrl: './group-form-page.component.html', templateUrl: './group-form-page.component.html',
styleUrl: './group-form-page.component.scss', styleUrl: './group-form-page.component.scss',
}) })
export class GroupFormPageComponent export class GroupFormPageComponent extends FormPageBase<
extends FormPageBase<
Group, Group,
GroupCreateInput, GroupCreateInput,
GroupUpdateInput, GroupUpdateInput,
GroupsDataService GroupsDataService
> > {
implements OnInit
{
roles: Role[] = []; roles: Role[] = [];
isPerUserSetup = true;
constructor( constructor(
private features: FeatureFlagService, private features: FeatureFlagService,
@ -35,15 +31,9 @@ export class GroupFormPageComponent
private cds: CommonDataService private cds: CommonDataService
) { ) {
super(); super();
}
async ngOnInit() {
this.isPerUserSetup = await this.features.get('PerUserSetup');
if (!this.isPerUserSetup) {
this.cds.getAllRoles().subscribe(roles => { this.cds.getAllRoles().subscribe(roles => {
this.roles = roles; this.roles = roles;
}); });
}
if (!this.nodeId) { if (!this.nodeId) {
this.node = this.new(); this.node = this.new();

View File

@ -23,6 +23,7 @@ import {
GroupUpdateInput, GroupUpdateInput,
} from 'src/app/model/entities/group'; } from 'src/app/model/entities/group';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { SidebarService } from 'src/app/service/sidebar.service';
@Injectable() @Injectable()
export class GroupsDataService export class GroupsDataService
@ -35,7 +36,8 @@ export class GroupsDataService
{ {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private apollo: Apollo private apollo: Apollo,
private sidebarService: SidebarService
) { ) {
super(); super();
} }
@ -74,7 +76,14 @@ export class GroupsDataService
${DB_MODEL_FRAGMENT} ${DB_MODEL_FRAGMENT}
`, `,
variables: { variables: {
filter: filter, filter: [
{
userSpace: {
id: { equal: this.sidebarService.selectedUserSpace$.value?.id },
},
},
...(filter ?? []),
],
sort: sort, sort: sort,
skip: skip, skip: skip,
take: take, take: take,
@ -186,6 +195,7 @@ export class GroupsDataService
`, `,
variables: { variables: {
input: { input: {
userSpaceId: this.sidebarService.selectedUserSpace$.value?.id,
name: object.name, name: object.name,
roles: object.roles?.map(x => x.id), roles: object.roles?.map(x => x.id),
}, },

View File

@ -22,7 +22,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.groupsCreate], permissions: [PermissionsEnum.groupsCreate],
checkByPerUserSetup: true,
}, },
}, },
{ {
@ -31,7 +30,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.groupsUpdate], permissions: [PermissionsEnum.groupsUpdate],
checkByPerUserSetup: true,
}, },
}, },
{ {
@ -40,7 +38,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.groups], permissions: [PermissionsEnum.groups],
checkByPerUserSetup: true,
}, },
}, },
], ],

View File

@ -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 { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.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 { Group } from 'src/app/model/entities/group';
import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service'; import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns'; import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns';
import { AuthService } from 'src/app/service/auth.service'; import { takeUntil } from 'rxjs/operators';
import { ConfigService } from 'src/app/service/config.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({ @Component({
selector: 'app-groups', selector: 'app-groups',
templateUrl: './groups.page.html', templateUrl: './groups.page.html',
styleUrl: './groups.page.scss', styleUrl: './groups.page.scss',
}) })
export class GroupsPage export class GroupsPage extends PageBase<
extends PageBase<Group, GroupsDataService, GroupsColumns> Group,
implements OnInit GroupsDataService,
{ GroupsColumns
> {
constructor( constructor(
private toast: ToastService, private toast: ToastService,
private confirmation: ConfirmationDialogService, private confirmation: ConfirmationDialogService,
private auth: AuthService, private sidebar: SidebarService
private config: ConfigService,
private features: FeatureFlagService
) { ) {
super(true); super(true, {
} read: [],
create: [],
update: [],
delete: [],
restore: [],
});
async ngOnInit() { this.sidebar.selectedUserSpace$
this.requiredPermissions = { .pipe(takeUntil(this.unsubscribe$))
read: (await this.features.get('PerUserSetup')) .subscribe(() => {
? [] this.load(true);
: [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 ?? [])
: [],
};
} }
load(silent?: boolean): void { load(silent?: boolean): void {

View File

@ -65,7 +65,7 @@
></p-dropdown> ></p-dropdown>
</div> </div>
</div> </div>
<div class="form-page-input" *ngIf="!isPerUserSetup"> <div class="form-page-input">
<p class="label">{{ 'common.domain' | translate }}</p> <p class="label">{{ 'common.domain' | translate }}</p>
<div <div
class="value"> class="value">

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { FormPageBase } from 'src/app/core/base/form-page-base'; 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 { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain'; import { Domain } from 'src/app/model/entities/domain';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({ @Component({
selector: 'app-short-url-form-page', selector: 'app-short-url-form-page',
templateUrl: './short-url-form-page.component.html', templateUrl: './short-url-form-page.component.html',
styleUrl: './short-url-form-page.component.scss', styleUrl: './short-url-form-page.component.scss',
}) })
export class ShortUrlFormPageComponent export class ShortUrlFormPageComponent extends FormPageBase<
extends FormPageBase<
ShortUrl, ShortUrl,
ShortUrlCreateInput, ShortUrlCreateInput,
ShortUrlUpdateInput, ShortUrlUpdateInput,
ShortUrlsDataService ShortUrlsDataService
> > {
implements OnInit
{
groups: Group[] = []; groups: Group[] = [];
domains: Domain[] = []; domains: Domain[] = [];
isPerUserSetup = true; constructor(private toast: ToastService) {
constructor(
private features: FeatureFlagService,
private toast: ToastService
) {
super(); super();
this.dataService.getAllGroups().subscribe(groups => { this.dataService.getAllAvailableGroups().subscribe(groups => {
this.groups = groups; this.groups = groups;
}); });
}
async ngOnInit() {
this.isPerUserSetup = await this.features.get('PerUserSetup');
if (!this.isPerUserSetup) {
this.dataService.getAllDomains().subscribe(domains => { this.dataService.getAllDomains().subscribe(domains => {
this.domains = domains; this.domains = domains;
}); });
}
if (!this.nodeId) { if (!this.nodeId) {
this.node = this.new(); this.node = this.new();

View File

@ -25,6 +25,7 @@ import {
import { Group } from 'src/app/model/entities/group'; import { Group } from 'src/app/model/entities/group';
import { Domain } from 'src/app/model/entities/domain'; import { Domain } from 'src/app/model/entities/domain';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { SidebarService } from 'src/app/service/sidebar.service';
@Injectable() @Injectable()
export class ShortUrlsDataService export class ShortUrlsDataService
@ -37,7 +38,8 @@ export class ShortUrlsDataService
{ {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private apollo: Apollo private apollo: Apollo,
private sidebarService: SidebarService
) { ) {
super(); super();
} }
@ -76,7 +78,22 @@ export class ShortUrlsDataService
${DB_MODEL_FRAGMENT} ${DB_MODEL_FRAGMENT}
`, `,
variables: { 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 ?? [])], sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
skip, skip,
take, take,
@ -111,7 +128,15 @@ export class ShortUrlsDataService
${DB_MODEL_FRAGMENT} ${DB_MODEL_FRAGMENT}
`, `,
variables: { variables: {
filter: [{ group: { isNull: true } }, ...(filter ?? [])], filter: [
{
userSpace: {
id: { equal: this.sidebarService.selectedUserSpace$.value?.id },
},
},
{ group: { isNull: true } },
...(filter ?? []),
],
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
skip, skip,
take, take,
@ -263,6 +288,7 @@ export class ShortUrlsDataService
`, `,
variables: { variables: {
input: { input: {
userSpaceId: this.sidebarService.selectedUserSpace$.value?.id,
shortUrl: object.shortUrl, shortUrl: object.shortUrl,
targetUrl: object.targetUrl, targetUrl: object.targetUrl,
description: object.description, description: object.description,
@ -365,12 +391,12 @@ export class ShortUrlsDataService
.pipe(map(result => result.data?.shortUrl.restore ?? false)); .pipe(map(result => result.data?.shortUrl.restore ?? false));
} }
getAllGroups() { getAllAvailableGroups() {
return this.apollo return this.apollo
.query<{ groups: QueryResult<Group> }>({ .query<{ groups: QueryResult<Group> }>({
query: gql` query: gql`
query getGroups { query getGroups($filter: [GroupFilter]) {
groups { groups(filter: $filter) {
nodes { nodes {
id id
name name
@ -378,6 +404,16 @@ export class ShortUrlsDataService
} }
} }
`, `,
variables: {
filter: [
{
userSpace: {
id: { equal: this.sidebarService.selectedUserSpace$.value?.id },
},
},
{ deleted: { equal: false } },
],
},
}) })
.pipe( .pipe(
catchError(err => { catchError(err => {

View File

@ -22,7 +22,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.shortUrlsCreate], permissions: [PermissionsEnum.shortUrlsCreate],
checkByPerUserSetup: true,
}, },
}, },
{ {
@ -31,7 +30,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.shortUrlsUpdate], permissions: [PermissionsEnum.shortUrlsUpdate],
checkByPerUserSetup: true,
}, },
}, },
{ {
@ -40,7 +38,6 @@ const routes: Routes = [
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { data: {
permissions: [PermissionsEnum.shortUrls], permissions: [PermissionsEnum.shortUrls],
checkByPerUserSetup: true,
}, },
}, },
], ],

View File

@ -36,7 +36,6 @@
(onClick)="open(url.shortUrl)"></p-button> (onClick)="open(url.shortUrl)"></p-button>
<p-button <p-button
*ngIf="hasPermissions.update"
class="icon-btn btn" class="icon-btn btn"
icon="pi pi-pencil" icon="pi pi-pencil"
tooltipPosition="left" tooltipPosition="left"
@ -44,7 +43,6 @@
[disabled]="url.deleted" [disabled]="url.deleted"
routerLink="edit/{{ url.id }}"></p-button> routerLink="edit/{{ url.id }}"></p-button>
<p-button <p-button
*ngIf="hasPermissions.delete"
class="icon-btn btn danger-icon-btn" class="icon-btn btn danger-icon-btn"
icon="pi pi-trash" icon="pi pi-trash"
tooltipPosition="left" tooltipPosition="left"
@ -52,7 +50,7 @@
[disabled]="url.deleted" [disabled]="url.deleted"
(click)="delete(url)"></p-button> (click)="delete(url)"></p-button>
<p-button <p-button
*ngIf="url.deleted && hasPermissions.restore" *ngIf="url.deleted"
class="icon-btn btn" class="icon-btn btn"
icon="pi pi-undo" icon="pi pi-undo"
tooltipPosition="left" tooltipPosition="left"
@ -74,7 +72,6 @@
<div><h1>{{ 'short_url.short_url' | translate }}</h1></div> <div><h1>{{ 'short_url.short_url' | translate }}</h1></div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<p-button <p-button
*ngIf="hasPermissions.create"
class="icon-btn btn" class="icon-btn btn"
icon="pi pi-plus" icon="pi pi-plus"
tooltipPosition="left" tooltipPosition="left"

View File

@ -2,7 +2,6 @@ import { Component, OnInit, ViewChild } from '@angular/core';
import { PageBase } from 'src/app/core/base/page-base'; import { PageBase } from 'src/app/core/base/page-base';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { ShortUrl } from 'src/app/model/entities/short-url'; import { ShortUrl } from 'src/app/model/entities/short-url';
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
import { ShortUrlsColumns } from 'src/app/modules/admin/short-urls/short-urls.columns'; import { ShortUrlsColumns } from 'src/app/modules/admin/short-urls/short-urls.columns';
@ -12,17 +11,19 @@ import QrCodeWithLogo from 'qrcode-with-logos';
import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload'; import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model'; import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model';
import { FeatureFlagService } from 'src/app/service/feature-flag.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-short-urls', selector: 'app-short-urls',
templateUrl: './short-urls.page.html', templateUrl: './short-urls.page.html',
styleUrl: './short-urls.page.scss', styleUrl: './short-urls.page.scss',
}) })
export class ShortUrlsPage export class ShortUrlsPage extends PageBase<
extends PageBase<ShortUrl, ShortUrlsDataService, ShortUrlsColumns> ShortUrl,
implements OnInit ShortUrlsDataService,
{ ShortUrlsColumns
> {
@ViewChild('imageUpload') imageUpload!: FileUpload; @ViewChild('imageUpload') imageUpload!: FileUpload;
shortUrlsWithoutGroup: ShortUrl[] = []; shortUrlsWithoutGroup: ShortUrl[] = [];
@ -56,53 +57,38 @@ export class ShortUrlsPage
); );
} }
protected hasPermissions = {
read: false,
create: false,
update: false,
delete: false,
restore: false,
};
constructor( constructor(
private toast: ToastService, private toast: ToastService,
private confirmation: ConfirmationDialogService, private confirmation: ConfirmationDialogService,
private auth: AuthService,
private config: ConfigService, private config: ConfigService,
private features: FeatureFlagService private sidebar: SidebarService
) { ) {
super(true, { super(true, {
read: [PermissionsEnum.shortUrls], read: [],
create: [PermissionsEnum.shortUrlsCreate], create: [],
update: [PermissionsEnum.shortUrlsUpdate], update: [],
delete: [PermissionsEnum.shortUrlsDelete], delete: [],
restore: [PermissionsEnum.shortUrlsDelete], restore: [],
});
this.sidebar.selectedUserSpace$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(() => {
this.load(true);
}); });
} }
async ngOnInit() { load(silent?: boolean): void {
this.hasPermissions = { if (!silent) this.loading = true;
read: this.dataService
(await this.auth.hasAnyPermissionLazy( .load(this.filter, this.sort, this.skip, this.take)
this.requiredPermissions.read ?? [] .subscribe(result => {
)) || (await this.features.get('PerUserSetup')), this.result = result;
create: this.shortUrlsWithoutGroup = this.getShortUrlsWithoutGroup();
(await this.auth.hasAnyPermissionLazy( this.groupedShortUrls = this.getShortUrlsWithGroup();
this.requiredPermissions.create ?? [] this.resolveColumns();
)) || (await this.features.get('PerUserSetup')), this.loading = false;
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')),
};
} }
resolveColumns() { 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 { delete(group: ShortUrl): void {
this.confirmation.confirmDialog({ this.confirmation.confirmDialog({
header: 'dialog.delete.header', header: 'dialog.delete.header',

View File

@ -0,0 +1,31 @@
<app-form-page
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.user_space' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.name' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="name"/>
</div>
</ng-template>
</app-form-page>

View File

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

View File

@ -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<number | undefined>(undefined),
name: new FormControl<string | undefined>(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();
});
}
}

View File

@ -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<UserSpace>
implements
Create<UserSpace, UserSpaceCreateInput>,
Update<UserSpace, UserSpaceUpdateInput>,
Delete<UserSpace>,
Restore<UserSpace>
{
constructor(
private spinner: SpinnerService,
private apollo: Apollo
) {
super();
}
load(
filter?: Filter[] | undefined,
sort?: Sort[] | undefined,
skip?: number | undefined,
take?: number | undefined
): Observable<QueryResult<UserSpace>> {
return this.apollo
.query<{ userSpaces: QueryResult<UserSpace> }>({
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<UserSpace> {
return this.apollo
.query<{ userSpaces: QueryResult<UserSpace> }>({
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<UserSpace> }>({
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<void> {
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<UserSpace | undefined> {
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<UserSpace | undefined> {
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<boolean> {
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<boolean> {
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,
];
}
}

View File

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

View File

@ -1,7 +1,7 @@
<ng-template #menuBtn let-element> <ng-template #menuBtn let-element>
<a <a
class="flex w-full gap-5 items-center justify-center p-2 rounded-xl hover:bg hover:cursor-pointer" class="flex w-full gap-5 items-center justify-center p-2 rounded-xl hover:bg hover:cursor-pointer"
(click)="(element.command)" (click)="element.command ? element.command() : null"
[routerLink]="element.routerLink"> [routerLink]="element.routerLink">
<div class=""><span [class]="element.icon"></span></div> <div class=""><span [class]="element.icon"></span></div>
<div class="flex flex-1 font-bold justify-start"> <div class="flex flex-1 font-bold justify-start">
@ -21,6 +21,7 @@
<div <div
class="flex flex-col gap-2 justify-between w-full p-1.5 overflow-hidden max-w-56"> class="flex flex-col gap-2 justify-between w-full p-1.5 overflow-hidden max-w-56">
<ng-container *ngFor="let element of elements"> <ng-container *ngFor="let element of elements">
<span *ngIf="debug"><{{ element.label }} - {{ element.visible }} | {{ element.command }}></span>
<div *ngIf="element.visible !== false"> <div *ngIf="element.visible !== false">
<ng-template <ng-template
*ngTemplateOutlet=" *ngTemplateOutlet="
@ -34,6 +35,7 @@
<div <div
class="flex flex-col gap-2 justify-between w-full p-1.5 overflow-hidden max-w-56"> class="flex flex-col gap-2 justify-between w-full p-1.5 overflow-hidden max-w-56">
<ng-container *ngFor="let item of element.items"> <ng-container *ngFor="let item of element.items">
<span *ngIf="debug"><{{ item.label }} - {{ item.visible }} | {{ item.command }}></span>
<div *ngIf="item.visible !== false"> <div *ngIf="item.visible !== false">
<ng-template <ng-template
*ngTemplateOutlet=" *ngTemplateOutlet="

View File

@ -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 { 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({ @Component({
selector: 'app-side-menu', selector: 'app-side-menu',
templateUrl: './side-menu.component.html', templateUrl: './side-menu.component.html',
styleUrl: './side-menu.component.scss', styleUrl: './side-menu.component.scss',
}) })
export class SideMenuComponent { export class SideMenuComponent implements OnDestroy {
@Input() elements: MenuElement[] = []; @Input() elements: MenuElement[] = [];
debug = false;
unsubscribe$ = new Subject<void>();
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 { expand(element: MenuElement): void {
element.expanded = true; element.expanded = true;
} }

View File

@ -20,6 +20,8 @@ export class GuiService {
hideGui$ = new BehaviorSubject<boolean>(false); hideGui$ = new BehaviorSubject<boolean>(false);
theme$ = new BehaviorSubject<string>(this.config.settings.themes[0].name); theme$ = new BehaviorSubject<string>(this.config.settings.themes[0].name);
debug$ = new BehaviorSubject<boolean>(false);
constructor( constructor(
private router: Router, private router: Router,
private apollo: Apollo, private apollo: Apollo,

View File

@ -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<UserSpace> }>({
query: gql`
query getAssignedUserSpaces {
assignedUserSpaces {
nodes {
id
name
}
}
}
`,
})
.pipe(map(result => result.data?.assignedUserSpaces.nodes));
}
onChange(): Observable<void> {
return this.apollo
.subscribe<{ groupChange: void }>({
query: gql`
subscription onUserSpaceChange {
userSpaceChange
}
`,
})
.pipe(map(result => result.data?.groupChange));
}
delete(object: UserSpace): Observable<boolean> {
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));
}
}

View File

@ -3,7 +3,11 @@ import { BehaviorSubject } from 'rxjs';
import { MenuElement } from 'src/app/model/view/menu-element'; import { MenuElement } from 'src/app/model/view/menu-element';
import { AuthService } from 'src/app/service/auth.service'; import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -12,13 +16,50 @@ export class SidebarService {
visible$ = new BehaviorSubject<boolean>(true); visible$ = new BehaviorSubject<boolean>(true);
elements$ = new BehaviorSubject<MenuElement[]>([]); elements$ = new BehaviorSubject<MenuElement[]>([]);
selectedUserSpace$ = new BehaviorSubject<UserSpace | undefined | null>(
undefined
);
userSpaces: UserSpace[] = [];
constructor( constructor(
private auth: AuthService, 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 () => { this.auth.user$.subscribe(async () => {
await this.setElements(); 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() { show() {
@ -33,33 +74,93 @@ export class SidebarService {
async setElements() { async setElements() {
const elements: MenuElement[] = [ const elements: MenuElement[] = [
{ {
label: 'common.domains', label: 'sidebar.user_spaces',
icon: 'pi pi-sitemap', icon: 'pi pi-list',
routerLink: ['/admin/domains'], expanded: true,
visible: await this.auth.hasAnyPermissionLazy([ items: [
PermissionsEnum.domains, {
]), 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: 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', label: 'common.groups',
icon: 'pi pi-tags', icon: 'pi pi-tags',
routerLink: ['/admin/groups'], routerLink: ['/admin/groups'],
visible: visible:
(await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) || this.selectedUserSpace$.value !== null &&
(await this.featureFlags.get('PerUserSetup')), (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])),
}, },
{ {
label: 'common.urls', label: 'common.urls',
icon: 'pi pi-tag', icon: 'pi pi-tag',
routerLink: ['/admin/urls'], routerLink: ['/admin/urls'],
visible: visible:
this.selectedUserSpace$.value !== null &&
(await this.auth.hasAnyPermissionLazy([ (await this.auth.hasAnyPermissionLazy([
PermissionsEnum.shortUrls, PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment, PermissionsEnum.shortUrlsByAssignment,
])) || (await this.featureFlags.get('PerUserSetup')), ])),
}, },
{
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(), await this.sectionAdmin(),
]; ];
this.elements$.next(elements); this.elements$.next(elements);
} }
@ -71,6 +172,14 @@ export class SidebarService {
expanded: true, expanded: true,
isSection: true, isSection: true,
items: [ items: [
{
label: 'common.domains',
icon: 'pi pi-sitemap',
routerLink: ['/admin/administration/domains'],
visible: await this.auth.hasAnyPermissionLazy([
PermissionsEnum.domains,
]),
},
{ {
label: 'sidebar.users', label: 'sidebar.users',
icon: 'pi pi-user', 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());
}
} }

View File

@ -42,6 +42,7 @@
"updated": "Bearbeitet", "updated": "Bearbeitet",
"upload": "Hochladen", "upload": "Hochladen",
"urls": "URLs", "urls": "URLs",
"user_space": "Team",
"warning": "Warnung" "warning": "Warnung"
}, },
"dialog": { "dialog": {
@ -239,6 +240,10 @@
}, },
"roles": "Rollen", "roles": "Rollen",
"settings": "Einstellungen", "settings": "Einstellungen",
"user_space_add": "Team erstellen",
"user_space_delete": "Team löschen",
"user_space_edit": "Team bearbeiten",
"user_spaces": "Teams",
"users": "Benutzer" "users": "Benutzer"
}, },
"table": { "table": {

View File

@ -42,6 +42,7 @@
"updated": "Updated", "updated": "Updated",
"upload": "Upload", "upload": "Upload",
"urls": "URLs", "urls": "URLs",
"user_space": "Team",
"warning": "Warning" "warning": "Warning"
}, },
"dialog": { "dialog": {
@ -239,6 +240,10 @@
}, },
"roles": "Roles", "roles": "Roles",
"settings": "Settings", "settings": "Settings",
"user_space_add": "Add Team",
"user_space_delete": "Delete Team",
"user_space_edit": "Edit Team",
"user_spaces": "Teams",
"users": "Users" "users": "Users"
}, },
"table": { "table": {