diff --git a/api/requirements.txt b/api/requirements.txt index 88df780..51ce70d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -11,3 +11,4 @@ Jinja2==3.1.5 python-keycloak==5.3.1 python-multipart==0.0.20 websockets==15.0 +sqlparse==0.5.3 diff --git a/api/src/api/auth/keycloak_client.py b/api/src/api/auth/keycloak_client.py index 79ee820..466bd2b 100644 --- a/api/src/api/auth/keycloak_client.py +++ b/api/src/api/auth/keycloak_client.py @@ -9,18 +9,28 @@ class Keycloak: client: Optional[KeycloakOpenID] = None admin: Optional[KeycloakAdmin] = None + url: Optional[str] = None + client_id: Optional[str] = None + realm: Optional[str] = None + __client_secret: Optional[str] = None + @classmethod def init(cls): + cls.url = Environment.get("KEYCLOAK_URL", str) + cls.client_id = Environment.get("KEYCLOAK_CLIENT_ID", str) + cls.realm = Environment.get("KEYCLOAK_REALM", str) + cls.__client_secret = Environment.get("KEYCLOAK_CLIENT_SECRET", str) + cls.client = KeycloakOpenID( - server_url=Environment.get("KEYCLOAK_URL", str), - client_id=Environment.get("KEYCLOAK_CLIENT_ID", str), - realm_name=Environment.get("KEYCLOAK_REALM", str), - client_secret_key=Environment.get("KEYCLOAK_CLIENT_SECRET", str), + server_url=cls.url, + client_id=cls.client_id, + realm_name=cls.realm, + client_secret_key=cls.__client_secret, ) connection = KeycloakOpenIDConnection( - server_url=Environment.get("KEYCLOAK_URL", str), - client_id=Environment.get("KEYCLOAK_CLIENT_ID", str), - realm_name=Environment.get("KEYCLOAK_REALM", str), - client_secret_key=Environment.get("KEYCLOAK_CLIENT_SECRET", str), + server_url=cls.url, + client_id=cls.client_id, + realm_name=cls.realm, + client_secret_key=cls.__client_secret, ) cls.admin = KeycloakAdmin(connection=connection) diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index 585390d..ec2cebd 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -51,6 +51,10 @@ class RouteUserExtension: if request is None: return None + dev_user = await cls.get_dev_user() + if dev_user is not None: + return dev_user + user_id = cls._get_user_id_from_token(request) if not user_id: return None @@ -63,6 +67,15 @@ class RouteUserExtension: if request is None: return None + if "Authorization" not in request.headers: + return None + + if len(request.headers.get("Authorization").split()) < 2: + return None + + if request.headers.get("Authorization").split()[0] != "DEV-User": + return None + return await userDao.find_by_keycloak_id(cls.get_token(request)) @classmethod diff --git a/api/src/api/routes/invitation.py b/api/src/api/routes/invitation.py new file mode 100644 index 0000000..dc063a4 --- /dev/null +++ b/api/src/api/routes/invitation.py @@ -0,0 +1,72 @@ +from starlette.requests import Request +from starlette.responses import RedirectResponse, JSONResponse + +from api.auth.keycloak_client import Keycloak +from api.route import Route +from core.environment import Environment +from core.logger import Logger +from data.schemas.administration.user import User +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_invitation import UserInvitation +from data.schemas.public.user_invitation_dao import userInvitationDao +from data.schemas.public.user_space_user import UserSpaceUser +from data.schemas.public.user_space_user_dao import userSpaceUserDao + +BasePath = f"/api/invitation" +logger = Logger(__name__) + + +def _redirect_to_client(): + client_urls = Environment.get("CLIENT_URLS", str) + if client_urls is None: + return JSONResponse({"message": "Invitation accepted."}) + + return RedirectResponse(client_urls.split(",")[0], status_code=303) + + +@Route.get(f"{BasePath}/accept/{{invitation_id:path}}") +async def accept_invitation(request: Request): + invitation_id = request.path_params["invitation_id"] + + user_invitation = await userInvitationDao.find_single_by( + [{UserInvitation.invitation: invitation_id}] + ) + if user_invitation is None: + return JSONResponse({"error": "Invitation not found."}, 404) + + if user_invitation.deleted: + return _redirect_to_client() + + keycloak_user = Keycloak.admin.get_users({"email": user_invitation.email}) + if len(keycloak_user) == 0: + redirect_uri = f"{str(request.base_url)}api/invitation/accept/{invitation_id}" + registration_url = f"{Keycloak.url}/realms/{Keycloak.realm}/protocol/openid-connect/auth?prompt=create&client_id={Keycloak.client_id}&response_type=code&scope=openid&redirect_uri={redirect_uri}" + return RedirectResponse(registration_url, status_code=303) + + if user_invitation.email not in [ + x.email for x in await userDao.find_by([{User.deleted: False}]) + ]: + user_id = await userDao.create(User(0, keycloak_user[0]["id"])) + await userSpaceUserDao.create( + UserSpaceUser(0, user_invitation.user_space_id, user_id) + ) + + await userInvitationDao.delete(user_invitation) + + return _redirect_to_client() + + +@Route.get(f"{BasePath}/user-space/accept/{{invitation_id:path}}") +async def accept_user_space_invitation(request: Request): + invitation_id = request.path_params["invitation_id"] + + user_space_user = await userSpaceUserDao.find_single_by( + [{UserSpaceUser.invitation: invitation_id}, {UserSpaceUser.deleted: False}] + ) + if user_space_user is None: + return JSONResponse({"error": "Invitation not found or already accepted."}, 404) + + user_space_user.invitation = None + await userSpaceUserDao.update(user_space_user) + + return _redirect_to_client() diff --git a/api/src/api_graphql/abc/mutation_abc.py b/api/src/api_graphql/abc/mutation_abc.py index 95e61f4..b305bfe 100644 --- a/api/src/api_graphql/abc/mutation_abc.py +++ b/api/src/api_graphql/abc/mutation_abc.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Type, Union +from typing import Type, Union, Any from api_graphql.abc.query_abc import QueryABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index c3d2ca6..e2564b5 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -174,9 +174,9 @@ class QueryABC(ObjectType): kwargs["sort"] = None if iscoroutinefunction(field.resolver): - collection = await field.collection_resolver(*args) + collection = await field.resolver(*args) else: - collection = field.collection_resolver(*args) + collection = field.resolver(*args) return self._resolve_collection( collection, @@ -240,6 +240,18 @@ class QueryABC(ObjectType): ): await self._require_any_permission(field.require_any_permission) + if isinstance(field, MutationField): + if field.require_any is not None: + await self._require_any( + None, + *field.require_any, + *args, + **kwargs, + ) + + result = await resolver(*args, **kwargs) + return result + result = await resolver(*args, **kwargs) if field.require_any is not None: diff --git a/api/src/api_graphql/field/collection_field.py b/api/src/api_graphql/field/collection_field.py index e41d81e..0c586a9 100644 --- a/api/src/api_graphql/field/collection_field.py +++ b/api/src/api_graphql/field/collection_field.py @@ -15,7 +15,7 @@ class CollectionField(FieldABC): require_any_permission: list[Permissions] = None, require_any: TRequireAny = None, public: bool = False, - collection_resolver: Callable = None, + resolver: Callable = None, filter_type: Type[CollectionFilterABC] = None, sort_type: Type[T] = None, ): @@ -23,13 +23,13 @@ class CollectionField(FieldABC): self._name = name self._public = public - self._collection_resolver = collection_resolver + self._resolver = resolver self._filter_type = filter_type self._sort_type = sort_type @property - def collection_resolver(self) -> Optional[Callable]: - return self._collection_resolver + def resolver(self) -> Optional[Callable]: + return self._resolver @property def filter_type( diff --git a/api/src/api_graphql/field/collection_field_builder.py b/api/src/api_graphql/field/collection_field_builder.py index 8a52b9c..355fda7 100644 --- a/api/src/api_graphql/field/collection_field_builder.py +++ b/api/src/api_graphql/field/collection_field_builder.py @@ -11,13 +11,13 @@ class CollectionFieldBuilder(FieldBuilderABC): def __init__(self, name: str): FieldBuilderABC.__init__(self, name) - self._collection_resolver = None + self._resolver = None self._filter_type = None self._sort_type = None - def with_collection_resolver(self, collection_resolver: Callable) -> Self: - assert collection_resolver is not None, "collection_resolver cannot be None" - self._collection_resolver = collection_resolver + def with_resolver(self, resolver: Callable) -> Self: + assert resolver is not None, "resolver cannot be None" + self._resolver = resolver return self def with_filter(self, filter_type: Type[CollectionFilterABC]) -> Self: @@ -31,16 +31,14 @@ class CollectionFieldBuilder(FieldBuilderABC): return self def build(self) -> CollectionField: - assert ( - self._collection_resolver is not None - ), "collection_resolver cannot be None" + assert self._resolver is not None, "resolver cannot be None" return CollectionField( self._name, self._require_any_permission, self._require_any, self._public, - self._collection_resolver, + self._resolver, self._filter_type, self._sort_type, ) diff --git a/api/src/api_graphql/field/mutation_field_builder.py b/api/src/api_graphql/field/mutation_field_builder.py index 245045f..8ee5330 100644 --- a/api/src/api_graphql/field/mutation_field_builder.py +++ b/api/src/api_graphql/field/mutation_field_builder.py @@ -60,7 +60,7 @@ class MutationFieldBuilder(FieldBuilderABC): def with_input( self, - input_type: Type[Union[InputABC, str, int, bool]], + input_type: Type[Union[InputABC, str, int, bool, list]], input_key: str = "input", ) -> Self: self._input_type = input_type diff --git a/api/src/api_graphql/filter/collection/api_key_collection_filter.py b/api/src/api_graphql/filter/collection/api_key_collection_filter.py deleted file mode 100644 index 3bd1f7c..0000000 --- a/api/src/api_graphql/filter/collection/api_key_collection_filter.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class ApiKeyCollectionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("identifier", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py b/api/src/api_graphql/filter/collection/ip_list_collection_filter.py deleted file mode 100644 index 21c040e..0000000 --- a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class IpListFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("ip", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) - self.add_field("mac", StringCollectionFilter) - self.add_field("dns", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/news_collection_filter.py b/api/src/api_graphql/filter/collection/news_collection_filter.py deleted file mode 100644 index 78be285..0000000 --- a/api/src/api_graphql/filter/collection/news_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.bool_filter import BoolCollectionFilter -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class NewsFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("title", StringCollectionFilter) - self.add_field("published", BoolCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/permission_collection_filter.py b/api/src/api_graphql/filter/collection/permission_collection_filter.py deleted file mode 100644 index 03132c0..0000000 --- a/api/src/api_graphql/filter/collection/permission_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class PermissionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/role_collection_filter.py b/api/src/api_graphql/filter/collection/role_collection_filter.py deleted file mode 100644 index 6c01b12..0000000 --- a/api/src/api_graphql/filter/collection/role_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class RoleFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/user_collection_filter.py b/api/src/api_graphql/filter/collection/user_collection_filter.py deleted file mode 100644 index 8e64940..0000000 --- a/api/src/api_graphql/filter/collection/user_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class UserFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("keycloakId", StringCollectionFilter) - self.add_field("username", StringCollectionFilter) - self.add_field("email", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/group_filter.py b/api/src/api_graphql/filter/group_filter.py index f71148d..3b053db 100644 --- a/api/src/api_graphql/filter/group_filter.py +++ b/api/src/api_graphql/filter/group_filter.py @@ -1,5 +1,6 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class GroupFilter(DbModelFilterABC): @@ -11,6 +12,7 @@ class GroupFilter(DbModelFilterABC): self.add_field("name", StringFilter) self.add_field("description", StringFilter) + self.add_field("userSpace", UserSpaceFilter, "userspace") self.add_field("isNull", bool) self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/filter/short_url_filter.py b/api/src/api_graphql/filter/short_url_filter.py index 02a5097..c925eef 100644 --- a/api/src/api_graphql/filter/short_url_filter.py +++ b/api/src/api_graphql/filter/short_url_filter.py @@ -2,6 +2,7 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.filter.domain_filter import DomainFilter from api_graphql.filter.group_filter import GroupFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class ShortUrlFilter(DbModelFilterABC): @@ -17,3 +18,5 @@ class ShortUrlFilter(DbModelFilterABC): self.add_field("group", GroupFilter) self.add_field("domain", DomainFilter) + + self.add_field("userSpace", UserSpaceFilter, "userspace") diff --git a/api/src/api_graphql/filter/user_space_filter.py b/api/src/api_graphql/filter/user_space_filter.py new file mode 100644 index 0000000..17052a2 --- /dev/null +++ b/api/src/api_graphql/filter/user_space_filter.py @@ -0,0 +1,17 @@ +from api_graphql.abc.db_model_filter_abc import DbModelFilterABC +from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_filter import UserFilter + + +class UserSpaceFilter(DbModelFilterABC): + def __init__( + self, + obj: dict, + ): + DbModelFilterABC.__init__(self, obj) + + self.add_field("name", StringFilter) + self.add_field("owner", UserFilter) + + self.add_field("isNull", bool) + self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index f7f9315..9282d72 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -8,6 +8,8 @@ type GroupHistory implements DbHistoryModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -21,6 +23,8 @@ type Group implements DbModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -55,6 +59,8 @@ input GroupFilter { id: IntFilter name: StringFilter + userSpace: UserSpaceFilter + fuzzy: GroupFuzzy deleted: BooleanFilter @@ -74,6 +80,7 @@ type GroupMutation { } input GroupCreateInput { + userSpaceId: Int! name: String! roles: [Int] } diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index 6dff6f7..a49d9a6 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -4,8 +4,10 @@ type Mutation { user: UserMutation role: RoleMutation - group: GroupMutation domain: DomainMutation + + userSpace: UserSpaceMutation + group: GroupMutation shortUrl: ShortUrlMutation setting: SettingMutation diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 6b6e454..85acf01 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -12,6 +12,9 @@ type Query { notExistingUsersFromKeycloak: [KeycloakUser] domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult + + assignedUserSpaces: UserSpaceResult + userSpaces(filter: [UserSpaceFilter], sort: [UserSpaceSort], skip: Int, take: Int): UserSpaceResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index c766a66..154328a 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -14,6 +14,7 @@ type ShortUrlHistory implements DbHistoryModel { group: Group domain: Domain + userSpace: UserSpace deleted: Boolean editor: String @@ -27,9 +28,11 @@ type ShortUrl implements DbModel { targetUrl: String description: String visits: Int + loadingScreen: Boolean + group: Group domain: Domain - loadingScreen: Boolean + userSpace: UserSpace deleted: Boolean editor: User @@ -68,9 +71,12 @@ input ShortUrlFilter { targetUrl: StringFilter description: StringFilter loadingScreen: BooleanFilter + group: GroupFilter domain: DomainFilter + userSpace: UserSpaceFilter + fuzzy: ShortUrlFuzzy deleted: BooleanFilter @@ -88,6 +94,7 @@ type ShortUrlMutation { } input ShortUrlCreateInput { + userSpaceId: Int! shortUrl: String! targetUrl: String! description: String diff --git a/api/src/api_graphql/graphql/subscription.gql b/api/src/api_graphql/graphql/subscription.gql index 8ee85eb..e228c04 100644 --- a/api/src/api_graphql/graphql/subscription.gql +++ b/api/src/api_graphql/graphql/subscription.gql @@ -12,6 +12,7 @@ type Subscription { userLogout: SubscriptionChange domainChange: SubscriptionChange + userSpaceChange: SubscriptionChange groupChange: SubscriptionChange shortUrlChange: SubscriptionChange } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql new file mode 100644 index 0000000..dafada4 --- /dev/null +++ b/api/src/api_graphql/graphql/user_space.gql @@ -0,0 +1,90 @@ +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 + + inviteUsers(id: Int!, emails: [String!]!): Boolean +} + +input UserSpaceCreateInput { + name: String! + users: [Int] +} + +input UserSpaceUpdateInput { + id: Int! + name: String + users: [Int] +} \ No newline at end of file diff --git a/api/src/api_graphql/input/group_create_input.py b/api/src/api_graphql/input/group_create_input.py index 994224e..29a0d71 100644 --- a/api/src/api_graphql/input/group_create_input.py +++ b/api/src/api_graphql/input/group_create_input.py @@ -6,9 +6,14 @@ class GroupCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._name = self.option("name", str, required=True) self._roles = self.option("roles", list[int]) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def name(self) -> str: return self._name diff --git a/api/src/api_graphql/input/short_url_create_input.py b/api/src/api_graphql/input/short_url_create_input.py index 94c4b9e..19a8847 100644 --- a/api/src/api_graphql/input/short_url_create_input.py +++ b/api/src/api_graphql/input/short_url_create_input.py @@ -8,6 +8,7 @@ class ShortUrlCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._short_url = self.option("shortUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True) self._description = self.option("description", str) @@ -15,6 +16,10 @@ class ShortUrlCreateInput(InputABC): self._domain_id = self.option("domainId", int) self._loading_screen = self.option("loadingScreen", bool) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def short_url(self) -> str: return self._short_url diff --git a/api/src/api_graphql/input/user_space_create_input.py b/api/src/api_graphql/input/user_space_create_input.py new file mode 100644 index 0000000..e12fadc --- /dev/null +++ b/api/src/api_graphql/input/user_space_create_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._name = self.option("name", str, required=True) + self._users = self.option("users", list[int]) + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/input/user_space_update_input.py b/api/src/api_graphql/input/user_space_update_input.py new file mode 100644 index 0000000..9d40397 --- /dev/null +++ b/api/src/api_graphql/input/user_space_update_input.py @@ -0,0 +1,23 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._name = self.option("name", str) + self._users = self.option("users", list[int]) + + @property + def id(self) -> int: + return self._id + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index f996321..dac85fb 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -1,5 +1,6 @@ from api_graphql.abc.mutation_abc import MutationABC -from api_graphql.require_any_resolvers import by_user_setup_mutation +from api_graphql.require_any_resolvers import has_assigned_user_spaces +from api_graphql.service.query_context import QueryContext from service.permission.permissions_enum import Permissions @@ -43,6 +44,18 @@ class Mutation(MutationABC): Permissions.domains_delete, ], ) + self.add_mutation_type( + "userSpace", + "UserSpace", + require_any=( + [ + Permissions.user_spaces_create, + Permissions.user_spaces_update, + Permissions.user_spaces_delete, + ], + [lambda ctx: True], + ), + ) self.add_mutation_type( "group", "Group", @@ -52,7 +65,7 @@ class Mutation(MutationABC): Permissions.groups_update, Permissions.groups_delete, ], - [by_user_setup_mutation], + [has_assigned_user_spaces], ), ) self.add_mutation_type( @@ -64,7 +77,7 @@ class Mutation(MutationABC): Permissions.short_urls_update, Permissions.short_urls_delete, ], - [by_user_setup_mutation], + [has_assigned_user_spaces], ), ) diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index 16addbd..e430e11 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -1,14 +1,12 @@ from typing import Optional -from api.route import Route from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_update_input import GroupUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum +from api_graphql.require_any_resolvers import has_assigned_user_spaces from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment import GroupRoleAssignment @@ -26,26 +24,38 @@ class GroupMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(GroupCreateInput) - .with_require_any([Permissions.groups_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any([Permissions.groups_create], [has_assigned_user_spaces]) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(GroupUpdateInput) - .with_require_any([Permissions.groups_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any([Permissions.groups_update], [has_assigned_user_spaces]) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any([Permissions.groups_delete], [has_assigned_user_spaces]) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any([Permissions.groups_delete], [has_assigned_user_spaces]) ) @staticmethod @@ -87,12 +97,9 @@ class GroupMutation(MutationABC): group = Group( 0, obj.name, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) + gid = await groupDao.create(group) await cls._handle_group_role_assignments(gid, obj.roles) @@ -107,9 +114,7 @@ class GroupMutation(MutationABC): raise ValueError(f"Group with id {obj.id} not found") if obj.name is not None: - already_exists = await groupDao.find_by( - {Group.name: obj.name, Group.id: {"ne": obj.id}} - ) + already_exists = await groupDao.find_by({Group.name: obj.name}) if len(already_exists) > 0: raise ValueError(f"Group {obj.name} already exists") diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py index b471fba..0c537eb 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -3,10 +3,9 @@ from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum +from api_graphql.require_any_resolvers import has_assigned_user_spaces from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.domain_dao import domainDao from data.schemas.public.group_dao import groupDao from data.schemas.public.short_url import ShortUrl @@ -26,31 +25,54 @@ class ShortUrlMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(ShortUrlCreateInput) - .with_require_any([Permissions.short_urls_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.short_urls_create], [has_assigned_user_spaces] + ) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(ShortUrlUpdateInput) - .with_require_any([Permissions.short_urls_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.short_urls_update], [has_assigned_user_spaces] + ) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) self.field( MutationFieldBuilder("trackVisit") .with_resolver(self.resolve_track_visit) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) .with_require_any_permission([Permissions.short_urls_update]) ) @@ -70,11 +92,7 @@ class ShortUrlMutation(MutationABC): obj.group_id, obj.domain_id, obj.loading_screen, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) nid = await shortUrlDao.create(short_url) return await shortUrlDao.get_by_id(nid) diff --git a/api/src/api_graphql/mutations/user_setting_mutation.py b/api/src/api_graphql/mutations/user_setting_mutation.py index ff1be20..b06f9f4 100644 --- a/api/src/api_graphql/mutations/user_setting_mutation.py +++ b/api/src/api_graphql/mutations/user_setting_mutation.py @@ -22,12 +22,12 @@ class UserSettingMutation(MutationABC): f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) .with_input(UserSettingInput, "input") - .with_require_any([], [self._x]) + .with_require_any([], [self._check_user]) ) @staticmethod - async def _x(ctx): - return ctx.data.user_id == (await Route.get_user()).id + async def _check_user(ctx): + return ctx.user.id == (await Route.get_user()).id @staticmethod async def resolve_change(obj: UserSettingInput, *_): diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py new file mode 100644 index 0000000..0f95436 --- /dev/null +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -0,0 +1,221 @@ +from uuid import uuid4 + +from api.middleware.request import get_request +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 api_graphql.service.query_context import QueryContext +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_invitation import UserInvitation +from data.schemas.public.user_invitation_dao import userInvitationDao +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.mail_service import mailService +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" + ) + ) + + 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( + [Permissions.user_spaces_update], + [self._resolve_input_user_space_assigned], + ) + ) + + self.field( + MutationFieldBuilder("delete") + .with_resolver(self.resolve_delete) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.user_spaces_delete], + [self._resolve_input_user_space_assigned], + ) + ) + + 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]) + ) + + self.field( + MutationFieldBuilder("inviteUsers") + .with_resolver(self.resolve_invite_users) + .with_input(list, "emails") + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.user_spaces_update], + [self._resolve_input_user_space_assigned], + ) + ) + + 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}) + 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 + + @staticmethod + async def resolve_invite_users(emails: list[str], *_, id: str): + logger.debug(f"invite users to user_space: {id} with emails: {emails}") + + user_space = await userSpaceDao.get_by_id(id) + user_space_user_emails = [ + x.email for x in await userSpaceDao.get_users(user_space.id) + ] + + email_invitations = {} + + base_url = str(get_request().base_url) + for email in emails: + if email in user_space_user_emails: + continue + user = await userDao.find_single_by({"email": {"equal": email}}) + + invitation = uuid4() + if user is None: + user_invitation = await userInvitationDao.find_by( + [ + {UserInvitation.email: email}, + {UserInvitation.user_space_id: user_space.id}, + ] + ) + if len(user_invitation) > 0: + continue + + await userInvitationDao.create( + UserInvitation( + 0, + email, + str(invitation), + user_space.id, + ) + ) + email_invitations[email] = ( + f"{base_url}api/invitation/accept/{invitation}" + ) + continue + + email_invitations[email] = ( + f"{base_url}api/invitation/user-space/accept/{invitation}" + ) + await userSpaceUserDao.create( + UserSpaceUser(0, user_space.id, user.id, str(invitation)) + ) + + for email, invitation in email_invitations.items(): + logger.debug(f"Invitation for {email}: {invitation}") + mailService.send( + email, + "You're invited to join Open-Redirect", + f"Hi,\n\nYou’ve been invited to join Open-Redirect, a secure collaboration platform for your team. " + f"To complete your registration, please use the following invitation code:\n\n" + f"{invitation}\n\n" + "If you weren’t expecting this invitation, you can safely ignore this message.\n\n" + "Thanks,\n" + "The Open-Redirect Team", + ) + + return True + + @staticmethod + async def _resolve_input_user_space_assigned(ctx: QueryContext): + check_dict = ctx.kwargs + if "input" in ctx.kwargs: + check_dict = ctx.kwargs["input"] + + return "id" in check_dict and check_dict["id"] in [ + x.id for x in await userSpaceDao.get_assigned_by_user_id(ctx.user.id) + ] diff --git a/api/src/api_graphql/queries/group_history_query.py b/api/src/api_graphql/queries/group_history_query.py index dbfe220..28695ef 100644 --- a/api/src/api_graphql/queries/group_history_query.py +++ b/api/src/api_graphql/queries/group_history_query.py @@ -1,6 +1,6 @@ from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC from api_graphql.field.resolver_field_builder import ResolverFieldBuilder -from api_graphql.require_any_resolvers import by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_assignment_resolver from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao @@ -14,6 +14,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): DbHistoryModelQueryABC.__init__(self, "Group") self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +22,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field( diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index 8d5b559..be7334c 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -1,6 +1,7 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.field.resolver_field_builder import ResolverFieldBuilder -from api_graphql.require_any_resolvers import by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_assignment_resolver + from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao @@ -14,6 +15,7 @@ class GroupQuery(DbModelQueryABC): DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True) self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +23,7 @@ class GroupQuery(DbModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field("roles", self._get_roles) diff --git a/api/src/api_graphql/queries/short_url_history_query.py b/api/src/api_graphql/queries/short_url_history_query.py index 2d56696..facfaed 100644 --- a/api/src/api_graphql/queries/short_url_history_query.py +++ b/api/src/api_graphql/queries/short_url_history_query.py @@ -8,7 +8,10 @@ class ShortUrlQuery(DbHistoryModelQueryABC): self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("description", lambda x, *_: x.description) - self.set_field("group", lambda x, *_: x.group) - self.set_field("domain", lambda x, *_: x.domain) self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("loadingScreen", lambda x, *_: x.loading_screen) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/short_url_query.py b/api/src/api_graphql/queries/short_url_query.py index 1a103d6..c16d672 100644 --- a/api/src/api_graphql/queries/short_url_query.py +++ b/api/src/api_graphql/queries/short_url_query.py @@ -9,7 +9,10 @@ class ShortUrlQuery(DbModelQueryABC): self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("description", lambda x, *_: x.description) - self.set_field("group", lambda x, *_: x.group) - self.set_field("domain", lambda x, *_: x.domain) self.set_field("visits", lambda x, *_: x.visit_count) self.set_field("loadingScreen", lambda x, *_: x.loading_screen) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/user_space_history_query.py b/api/src/api_graphql/queries/user_space_history_query.py new file mode 100644 index 0000000..a74483f --- /dev/null +++ b/api/src/api_graphql/queries/user_space_history_query.py @@ -0,0 +1,22 @@ +from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao + + +class UserSpaceHistoryQuery(DbHistoryModelQueryABC): + def __init__(self): + DbHistoryModelQueryABC.__init__(self, "UserSpace") + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field( + "users", + lambda x, *_: self._resolve_foreign_history( + x.updated, + x.id, + userSpaceUserDao, + userDao, + lambda y: y.user_id, + obj_key="userspaceid", + ), + ) diff --git a/api/src/api_graphql/queries/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py new file mode 100644 index 0000000..e022232 --- /dev/null +++ b/api/src/api_graphql/queries/user_space_query.py @@ -0,0 +1,21 @@ +from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from data.schemas.public.group_dao import groupDao +from data.schemas.public.short_url_dao import shortUrlDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao + + +class UserSpaceQuery(DbModelQueryABC): + def __init__(self): + DbModelQueryABC.__init__(self, "UserSpace", userSpaceDao, with_history=True) + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field("users", self._get_users) + + self.set_history_reference_dao(groupDao, "userspaceid") + self.set_history_reference_dao(shortUrlDao, "userspaceid") + + @staticmethod + async def _get_users(space: UserSpace, *_): + return await userSpaceDao.get_users(space.id, with_invitations=False) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 2e80df3..8e3f3be 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -2,6 +2,7 @@ from api.auth.keycloak_client import Keycloak from api.route import Route from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.sort_abc import Sort +from api_graphql.field.collection_field_builder import CollectionFieldBuilder from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.filter.api_key_filter import ApiKeyFilter @@ -11,12 +12,12 @@ from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.user_filter import UserFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter from api_graphql.require_any_resolvers import ( - by_assignment_resolver, - by_user_setup_resolver, + by_group_assignment_resolver, + by_user_space_assignment_resolver, + has_assigned_user_spaces, ) -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.user import User @@ -33,6 +34,8 @@ from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.user_setting import UserSetting from data.schemas.public.user_setting_dao import userSettingDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao from data.schemas.system.feature_flag_dao import featureFlagDao from data.schemas.system.setting_dao import settingDao from service.permission.permissions_enum import Permissions @@ -56,19 +59,21 @@ class Query(QueryABC): .with_filter(PermissionFilter) .with_sort(Sort[Permission]) ) + self.field( DaoFieldBuilder("roles") .with_dao(roleDao) .with_filter(RoleFilter) .with_sort(Sort[Role]) - .with_require_any_permission( + .with_require_any( [ Permissions.roles, Permissions.users_create, Permissions.users_update, Permissions.groups_create, Permissions.groups_update, - ] + ], + [has_assigned_user_spaces], ) ) @@ -106,16 +111,36 @@ class Query(QueryABC): .with_dao(domainDao) .with_filter(DomainFilter) .with_sort(Sort[Domain]) - .with_require_any_permission( + .with_require_any( [ Permissions.domains, - Permissions.short_urls_create, - Permissions.short_urls_update, - ] + Permissions.domains_create, + Permissions.domains_update, + ], + [has_assigned_user_spaces], ) ) - 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( + [ + Permissions.user_spaces, + ], + [lambda ctx: all(x.owner_id == ctx.user.id for x in ctx.data.nodes)], + ) + ) + + self.field( DaoFieldBuilder("groups") .with_dao(groupDao) .with_filter(GroupFilter) @@ -126,35 +151,21 @@ class Query(QueryABC): Permissions.short_urls_create, Permissions.short_urls_update, ], - [by_assignment_resolver, by_user_setup_resolver], + [by_user_space_assignment_resolver, by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - group_field = group_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(group_field) - - short_url_field = ( + self.field( DaoFieldBuilder("shortUrls") .with_dao(shortUrlDao) .with_filter(ShortUrlFilter) .with_sort(Sort[ShortUrl]) .with_require_any( [Permissions.short_urls], - [by_assignment_resolver, by_user_setup_resolver], + [by_user_space_assignment_resolver, by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - short_url_field = short_url_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(short_url_field) - self.field( ResolverFieldBuilder("settings") .with_resolver(self._resolve_settings) @@ -230,3 +241,11 @@ class Query(QueryABC): @staticmethod async def _resolve_default_user_filter(*args, **kwargs) -> dict: return {"user": {"id": {"equal": (await Route.get_user()).id}}} + + @staticmethod + async def _resolve_assigned_user_spaces(*args, **kwargs): + user = await Route.get_user() + if user is None: + return [] + + return await userSpaceDao.get_assigned_by_user_id(user.id) diff --git a/api/src/api_graphql/require_any_resolvers.py b/api/src/api_graphql/require_any_resolvers.py index 8eea17f..cff57fe 100644 --- a/api/src/api_graphql/require_any_resolvers.py +++ b/api/src/api_graphql/require_any_resolvers.py @@ -1,12 +1,36 @@ from api_graphql.service.collection_result import CollectionResult from api_graphql.service.query_context import QueryContext -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.public.group_dao import groupDao +from data.schemas.public.user_space_dao import userSpaceDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao from service.permission.permissions_enum import Permissions -async def by_assignment_resolver(ctx: QueryContext) -> bool: +async def by_user_space_assignment_resolver(ctx: QueryContext) -> bool: + if not isinstance(ctx.data, CollectionResult): + return False + + if len(ctx.data.nodes) == 0: + return True + + user = ctx.user + + assigned_user_space_ids = { + us.user_space_id for us in await userSpaceUserDao.get_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 len(ctx.data.nodes) == 0 + + +async def by_group_assignment_resolver(ctx: QueryContext) -> bool: if not isinstance(ctx.data, CollectionResult): return False @@ -29,15 +53,6 @@ async def by_assignment_resolver(ctx: QueryContext) -> bool: return False -async def by_user_setup_resolver(ctx: QueryContext) -> bool: - if not isinstance(ctx.data, CollectionResult): - return False - - if not FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup): - return False - - return all(x.user_id == ctx.user.id for x in ctx.data.nodes) - - -async def by_user_setup_mutation(ctx: QueryContext) -> bool: - return await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) +async def has_assigned_user_spaces(ctx: QueryContext): + user_spaces = await userSpaceDao.get_assigned_by_user_id(ctx.user.id) + return len(user_spaces) > 0 diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index 02f0e57..ff499d7 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -1,5 +1,10 @@ +from typing import AsyncGenerator + +from api.broadcast import broadcast +from api.route import Route from api_graphql.abc.subscription_abc import SubscriptionABC from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder +from api_graphql.require_any_resolvers import has_assigned_user_spaces from service.permission.permissions_enum import Permissions @@ -49,9 +54,16 @@ class Subscription(SubscriptionABC): .with_public(True) ) + async def _user_logout_generator(*args, **kwargs) -> AsyncGenerator[str, None]: + async with broadcast.subscribe(channel="userLogout") as subscriber: + async for message in subscriber: + if message.message == (await Route.get_user()).id: + yield message + self.subscribe( SubscriptionFieldBuilder("userLogout") .with_resolver(lambda message, *_: message.message) + .with_generator(_user_logout_generator) .with_public(True) ) @@ -60,13 +72,18 @@ class Subscription(SubscriptionABC): .with_resolver(lambda message, *_: message.message) .with_require_any_permission([Permissions.domains]) ) + self.subscribe( + SubscriptionFieldBuilder("userSpaceChange") + .with_resolver(lambda message, *_: message.message) + .with_public(True) + ) self.subscribe( SubscriptionFieldBuilder("groupChange") .with_resolver(lambda message, *_: message.message) - .with_require_any_permission([Permissions.groups]) + .with_require_any([Permissions.groups], [has_assigned_user_spaces]) ) self.subscribe( SubscriptionFieldBuilder("shortUrlChange") .with_resolver(lambda message, *_: message.message) - .with_require_any_permission([Permissions.short_urls]) + .with_require_any([Permissions.short_urls], [has_assigned_user_spaces]) ) diff --git a/api/src/core/configuration/feature_flags.py b/api/src/core/configuration/feature_flags.py index 8eaa333..4bccd0f 100644 --- a/api/src/core/configuration/feature_flags.py +++ b/api/src/core/configuration/feature_flags.py @@ -9,14 +9,9 @@ class FeatureFlags: _flags = { FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025 FeatureFlagsEnum.technical_demo_banner.value: False, # 18.04.2025 - FeatureFlagsEnum.per_user_setup.value: Environment.get( - "PER_USER_SETUP", bool, False - ), # 18.04.2025 } - _overwrite_flags = [ - FeatureFlagsEnum.per_user_setup.value, - ] + _overwrite_flags = [] @staticmethod def overwrite_flag(key: str): diff --git a/api/src/core/configuration/feature_flags_enum.py b/api/src/core/configuration/feature_flags_enum.py index c5af428..2c6ac5d 100644 --- a/api/src/core/configuration/feature_flags_enum.py +++ b/api/src/core/configuration/feature_flags_enum.py @@ -4,4 +4,3 @@ from enum import Enum class FeatureFlagsEnum(Enum): version_endpoint = "VersionEndpoint" technical_demo_banner = "TechnicalDemoBanner" - per_user_setup = "PerUserSetup" diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py index a3003db..64518f7 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -37,6 +37,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self.__db_names: dict[str, str] = {} self.__foreign_tables: dict[str, tuple[str, str]] = {} self.__foreign_table_keys: dict[str, str] = {} + self.__foreign_dao: dict[str, "DataAccessObjectABC"] = {} self.__date_attributes: set[str] = set() self.__ignored_attributes: set[str] = set() @@ -108,6 +109,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): primary_attr: Attribute, foreign_attr: Attribute, table_name: str, + reference_dao: "DataAccessObjectABC" = None, ): """ Add a reference to another table for the given attribute @@ -115,6 +117,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :param str primary_attr: Name of the primary key in the foreign object :param str foreign_attr: Name of the foreign key in the object :param str table_name: Name of the table to reference + :param DataAccessObjectABC reference_dao: The data access object for the referenced table :return: """ if isinstance(attr, property): @@ -131,6 +134,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): foreign_attr = foreign_attr.lower().replace("_", "") self.__foreign_table_keys[attr] = foreign_attr + if reference_dao is not None: + self.__foreign_dao[attr] = reference_dao + if table_name == self._table_name: return @@ -622,7 +628,27 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): external_table_deps.append(external_field) parse_node(value, f"{external_field}.{key}") elif parent_key in self.__foreign_table_keys: - parse_node({key: value}, self.__foreign_table_keys[parent_key]) + if key in operators: + parse_node({key: value}, self.__foreign_table_keys[parent_key]) + continue + + if parent_key in self.__foreign_dao: + foreign_dao = self.__foreign_dao[parent_key] + if key in foreign_dao.__foreign_tables: + x = f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}" + parse_node( + value, + f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}", + ) + continue + + if parent_key in self.__foreign_tables: + parse_node( + value, f"{self.__foreign_tables[parent_key][0]}.{key}" + ) + continue + + parse_node({parent_key: value}) elif key in operators: operator = operators[key] if key == "contains" or key == "notContains": @@ -633,6 +659,23 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): value = f"{value}%" elif key == "endsWith": value = f"%{value}" + elif key == "isNull" or key == "isNotNull": + is_null_value = ( + value.get("equal", None) + if isinstance(value, dict) + else value + ) + + if is_null_value is None: + operator = operators[key] + elif (key == "isNull" and is_null_value) or ( + key == "isNotNull" and not is_null_value + ): + operator = "IS NULL" + else: + operator = "IS NOT NULL" + + conditions.append((parent_key, operator, None)) elif (key == "equal" or key == "notEqual") and value is None: operator = operators["isNull"] @@ -641,6 +684,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): elif isinstance(value, dict): if key in self.__foreign_table_keys: parse_node(value, key) + elif key in self.__db_names and parent_key is not None: + parse_node({f"{parent_key}": value}) elif key in self.__db_names: parse_node(value, self.__db_names[key]) else: diff --git a/api/src/core/database/postgres_pool.py b/api/src/core/database/postgres_pool.py index 6115c40..87d8b1c 100644 --- a/api/src/core/database/postgres_pool.py +++ b/api/src/core/database/postgres_pool.py @@ -1,5 +1,6 @@ from typing import Optional, Any +import sqlparse from psycopg import sql from psycopg_pool import AsyncConnectionPool, PoolTimeout @@ -44,7 +45,9 @@ class PostgresPool: @staticmethod async def _exec_sql(cursor: Any, query: str, args=None, multi=True): if multi: - queries = query.split(";") + queries = [ + str(stmt).strip() for stmt in sqlparse.parse(query) if str(stmt).strip() + ] for q in queries: if q.strip() == "": continue diff --git a/api/src/data/schemas/permission/role_user.py b/api/src/data/schemas/permission/role_user.py index 6af5bfc..f210aed 100644 --- a/api/src/data/schemas/permission/role_user.py +++ b/api/src/data/schemas/permission/role_user.py @@ -3,11 +3,12 @@ from typing import Optional from async_property import async_property +from core.database.abc.db_join_model_abc import DbJoinModelABC from core.typing import SerialId from core.database.abc.db_model_abc import DbModelABC -class RoleUser(DbModelABC): +class RoleUser(DbJoinModelABC): def __init__( self, id: SerialId, @@ -18,7 +19,9 @@ class RoleUser(DbModelABC): created: Optional[datetime] = None, updated: Optional[datetime] = None, ): - DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + DbJoinModelABC.__init__( + self, id, role_id, user_id, deleted, editor_id, created, updated + ) self._role_id = role_id self._user_id = user_id diff --git a/api/src/data/schemas/public/group.py b/api/src/data/schemas/public/group.py index da11936..a67398d 100644 --- a/api/src/data/schemas/public/group.py +++ b/api/src/data/schemas/public/group.py @@ -12,7 +12,7 @@ class Group(DbModelABC): self, id: SerialId, name: str, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -20,7 +20,7 @@ class Group(DbModelABC): ): DbModelABC.__init__(self, id, deleted, editor_id, created, updated) self._name = name - self._user_id = user_id + self._user_space_id = user_space_id @property def name(self) -> str: @@ -31,15 +31,14 @@ class Group(DbModelABC): self._name = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) diff --git a/api/src/data/schemas/public/group_dao.py b/api/src/data/schemas/public/group_dao.py index e856f04..2fc8a70 100644 --- a/api/src/data/schemas/public/group_dao.py +++ b/api/src/data/schemas/public/group_dao.py @@ -11,8 +11,8 @@ class GroupDao(DbModelDaoABC[Group]): DbModelDaoABC.__init__(self, __name__, Group, "public.groups") self.attribute(Group.name, str) - self.attribute(Group.user_id, int) - self.reference("user", "id", Group.user_id, "administration.users") + self.attribute(Group.user_space_id, int) + self.reference("userspace", "id", Group.user_space_id, "public.user_spaces") async def get_by_name(self, name: str) -> Group: result = await self._db.select_map( diff --git a/api/src/data/schemas/public/short_url.py b/api/src/data/schemas/public/short_url.py index 8d23653..b36ac9b 100644 --- a/api/src/data/schemas/public/short_url.py +++ b/api/src/data/schemas/public/short_url.py @@ -18,7 +18,7 @@ class ShortUrl(DbModelABC): group_id: Optional[SerialId], domain_id: Optional[SerialId], loading_screen: Optional[str] = None, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -35,7 +35,7 @@ class ShortUrl(DbModelABC): loading_screen = False self._loading_screen = loading_screen - self._user_id = user_id + self._user_space_id = user_space_id @property def short_url(self) -> str: @@ -110,18 +110,17 @@ class ShortUrl(DbModelABC): self._loading_screen = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) def to_dto(self) -> dict: return { diff --git a/api/src/data/schemas/public/short_url_dao.py b/api/src/data/schemas/public/short_url_dao.py index 66e0003..ced0e19 100644 --- a/api/src/data/schemas/public/short_url_dao.py +++ b/api/src/data/schemas/public/short_url_dao.py @@ -13,13 +13,17 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]): self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.group_id, int) - self.reference("group", "id", ShortUrl.group_id, "public.groups") + from data.schemas.public.group_dao import groupDao + + self.reference("group", "id", ShortUrl.group_id, "public.groups", groupDao) self.attribute(ShortUrl.domain_id, int) - self.reference("domain", "id", ShortUrl.domain_id, "public.domains") + from data.schemas.public.domain_dao import domainDao + + self.reference("domain", "id", ShortUrl.domain_id, "public.domains", domainDao) self.attribute(ShortUrl.loading_screen, bool) - self.attribute(ShortUrl.user_id, int) - self.reference("user", "id", ShortUrl.user_id, "administration.users") + self.attribute(ShortUrl.user_space_id, int) + self.reference("userspace", "id", ShortUrl.user_space_id, "public.user_spaces") shortUrlDao = ShortUrlDao() diff --git a/api/src/data/schemas/public/short_url_visit_dao.py b/api/src/data/schemas/public/short_url_visit_dao.py index 99f72a5..959c25a 100644 --- a/api/src/data/schemas/public/short_url_visit_dao.py +++ b/api/src/data/schemas/public/short_url_visit_dao.py @@ -15,7 +15,9 @@ class ShortUrlVisitDao(DbModelDaoABC[ShortUrlVisit]): async def count_by_id(self, fid: int) -> int: result = await self._db.select_map( - f"SELECT COUNT(*) FROM {self._table_name} WHERE shortUrlId = {fid}" + f"""SELECT COUNT(*) FROM {self._table_name} + WHERE shortUrlId = {fid} + AND deleted = FALSE""" ) if result is None or len(result) == 0: return 0 diff --git a/api/src/data/schemas/public/user_invitation.py b/api/src/data/schemas/public/user_invitation.py new file mode 100644 index 0000000..182446e --- /dev/null +++ b/api/src/data/schemas/public/user_invitation.py @@ -0,0 +1,51 @@ +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 UserInvitation(DbModelABC): + def __init__( + self, + id: SerialId, + email: str, + invitation: str, + user_space_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._email = email + self._user_space_id = user_space_id + self._invitation = invitation + + @property + def email(self) -> str: + return self._email + + @email.setter + def email(self, value: str): + self._email = value + + @property + def invitation(self) -> Optional[str]: + return self._invitation + + @invitation.setter + def invitation(self, value: Optional[str]): + self._invitation = value + + @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) diff --git a/api/src/data/schemas/public/user_invitation_dao.py b/api/src/data/schemas/public/user_invitation_dao.py new file mode 100644 index 0000000..0aae2e9 --- /dev/null +++ b/api/src/data/schemas/public/user_invitation_dao.py @@ -0,0 +1,16 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from data.schemas.public.user_invitation import UserInvitation + + +class UserInvitationDao(DbModelDaoABC[UserInvitation]): + + def __init__(self): + DbModelDaoABC.__init__( + self, __name__, UserInvitation, "public.user_invitations" + ) + self.attribute(UserInvitation.email, str) + self.attribute(UserInvitation.invitation, str) + self.attribute(UserInvitation.user_space_id, int) + + +userInvitationDao = UserInvitationDao() diff --git a/api/src/data/schemas/public/user_space.py b/api/src/data/schemas/public/user_space.py new file mode 100644 index 0000000..ac451ee --- /dev/null +++ b/api/src/data/schemas/public/user_space.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSpace(DbModelABC): + def __init__( + self, + id: SerialId, + name: str, + owner_id: SerialId, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + self._name = name + self._owner_id = owner_id + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + @property + def owner_id(self) -> SerialId: + return self._owner_id + + @owner_id.setter + def owner_id(self, value: SerialId): + self._owner_id = value + + @async_property + async def owner(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._owner_id) diff --git a/api/src/data/schemas/public/user_space_dao.py b/api/src/data/schemas/public/user_space_dao.py new file mode 100644 index 0000000..fd8c818 --- /dev/null +++ b/api/src/data/schemas/public/user_space_dao.py @@ -0,0 +1,50 @@ +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, with_invitations=True) -> 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 + {'' if with_invitations else 'AND usu.invitation IS NULL'} + """ + ) + + from data.schemas.administration.user_dao import userDao + + return [userDao.to_object(x) for x in result] + + async def get_assigned_by_user_id(self, user_id: int): + result = await self._db.select_map( + f""" + SELECT DISTINCT us.* + FROM public.user_spaces us + LEFT JOIN public.user_spaces_users usu ON us.id = usu.userSpaceId + WHERE (usu.userId = {user_id} OR us.ownerId = {user_id}) + AND us.deleted = FALSE; + """ + ) + + return [self.to_object(x) for x in result] + + +userSpaceDao = UserSpaceDao() diff --git a/api/src/data/schemas/public/user_space_user.py b/api/src/data/schemas/public/user_space_user.py new file mode 100644 index 0000000..5c9fa39 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user.py @@ -0,0 +1,56 @@ +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, + invitation: Optional[str] = None, + 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 + self._invitation = invitation + + @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) + + @property + def invitation(self) -> Optional[str]: + return self._invitation + + @invitation.setter + def invitation(self, value: Optional[str]): + self._invitation = value diff --git a/api/src/data/schemas/public/user_space_user_dao.py b/api/src/data/schemas/public/user_space_user_dao.py new file mode 100644 index 0000000..280b8b6 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -0,0 +1,39 @@ +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, int) + self.attribute(UserSpaceUser.user_id, int) + self.attribute(UserSpaceUser.invitation, 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}, + {UserSpaceUser.invitation: None}, + ] + 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}, {UserSpaceUser.invitation: None}] + if not with_deleted: + f.append({UserSpaceUser.deleted: False}) + + return await self.find_by(f) + + +userSpaceUserDao = UserSpaceUserDao() diff --git a/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql new file mode 100644 index 0000000..fcea5a1 --- /dev/null +++ b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS public.user_spaces +( + Id SERIAL PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + OwnerId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_history +( + LIKE public.user_spaces +); + +CREATE TRIGGER user_spaces_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users +( + Id SERIAL PRIMARY KEY, + UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id), + UserId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users_history +( + LIKE public.user_spaces_users +); + +CREATE TRIGGER user_spaces_users_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces_users + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +-- add user spaces +INSERT INTO public.user_spaces (Name, OwnerId) +VALUES ('Default', (SELECT id FROM administration.users ORDER BY id LIMIT 1)); + +INSERT INTO public.user_spaces_users (UserSpaceId, UserId) +SELECT 1 AS UserSpaceId, u.id AS UserId +FROM administration.users u +WHERE u.deleted = FALSE; + +-- change other tables +ALTER TABLE public.groups + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.groups_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups_history + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls_history + DROP COLUMN IF EXISTS UserId; \ No newline at end of file diff --git a/api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql new file mode 100644 index 0000000..e432dff --- /dev/null +++ b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.user_spaces_users + ADD COLUMN invitation UUID DEFAULT NULL; + +ALTER TABLE public.user_spaces_users_history + ADD COLUMN invitation UUID DEFAULT NULL; diff --git a/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql new file mode 100644 index 0000000..66fc27f --- /dev/null +++ b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.user_invitations +( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + invitation UUID NOT NULL, + userSpaceId INT NOT NULL REFERENCES public.user_spaces (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 public.user_invitations_history +( + LIKE public.user_invitations +); + +CREATE TRIGGER user_invitations_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_invitations + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); \ No newline at end of file diff --git a/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql b/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql new file mode 100644 index 0000000..b967bef --- /dev/null +++ b/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql @@ -0,0 +1,10 @@ +CREATE UNIQUE INDEX IF NOT EXISTS user_invitations_unique_email + ON public.user_invitations (email) + WHERE email != 'ANONYMOUS'; + +ALTER TABLE public.user_invitations + ADD CONSTRAINT user_invitations_unique_invitation UNIQUE (invitation); + +ALTER TABLE public.user_spaces_users + ADD CONSTRAINT user_spaces_users_unique_invitation UNIQUE (invitation), + ADD CONSTRAINT user_spaces_users_unique_entry UNIQUE (userspaceid, userid); \ No newline at end of file diff --git a/api/src/redirector.py b/api/src/redirector.py index 28ff9b8..a47cee5 100644 --- a/api/src/redirector.py +++ b/api/src/redirector.py @@ -83,7 +83,26 @@ def _find_short_url_by_path(path: str) -> Optional[dict]: json={ "query": f""" query getShortUrlByPath($path: String!) {{ - shortUrls(filter: {{ shortUrl: {{ equal: $path }}, deleted: {{ equal: false }}, group: {{ deleted: {{ equal: false }} }} }}) {{ + shortUrls(filter: [{{ shortUrl: {{ equal: $path }} }}, {{ deleted: {{ equal: false }} }}, {{ group: {{ deleted: {{ equal: false }} }} }}]) {{ + nodes {{ + id + shortUrl + targetUrl + description + group {{ + id + name + }} + domain {{ + id + name + }} + loadingScreen + deleted + }} + }} + + shortUrlsWithoutGroup: shortUrls(filter: [{{ shortUrl: {{ equal: $path }} }}, {{ deleted: {{ equal: false }} }}, {{ group: {{ isNull: true }} }}]) {{ nodes {{ id shortUrl @@ -115,14 +134,18 @@ def _find_short_url_by_path(path: str) -> Optional[dict]: "data" not in data or "shortUrls" not in data["data"] or "nodes" not in data["data"]["shortUrls"] + or "nodes" not in data["data"]["shortUrlsWithoutGroup"] ): return None - data = data["data"]["shortUrls"]["nodes"] - if len(data) == 0: + nodes = [ + *data["data"]["shortUrls"]["nodes"], + *data["data"]["shortUrlsWithoutGroup"]["nodes"], + ] + if len(nodes) == 0: return None - return data[0] + return nodes[0] async def _handle_short_url(request: Request, short_url: dict): @@ -145,7 +168,7 @@ async def _track_visit(r: Request, short_url: dict): f"{api_url}/graphql", json={ "query": f""" - mutation trackShortUrlVisit($id: ID!, $agent: String) {{ + mutation trackShortUrlVisit($id: Int!, $agent: String) {{ shortUrl {{ trackVisit(id: $id, agent: $agent) }} diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py index e0d6d82..044df4c 100644 --- a/api/src/service/data_privacy_service.py +++ b/api/src/service/data_privacy_service.py @@ -9,6 +9,7 @@ from core.database.abc.db_model_dao_abc import DbModelDaoABC from core.logger import Logger from core.string import first_to_lower from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_invitation_dao import userInvitationDao logger = Logger("DataPrivacy") @@ -77,7 +78,16 @@ class DataPrivacyService: keycloak_id = user.keycloak_id # Anonymize internal data + user_invitations = await userInvitationDao.find_by( + [{"email": {"equal": user.email}}] + ) + for user_invitation in user_invitations: + user_invitation.email = "ANONYMOUS" + user_invitation.deleted = True + await userInvitationDao.update(user_invitation) + await user.anonymize() + await userDao.delete(user) # Anonymize external data try: diff --git a/api/src/service/mail_service.py b/api/src/service/mail_service.py new file mode 100644 index 0000000..884ce14 --- /dev/null +++ b/api/src/service/mail_service.py @@ -0,0 +1,46 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from core.environment import Environment +from core.logger import Logger + +logger = Logger("MailService") + + +class MailService: + + def __init__(self): + self._server = Environment.get("MAIL_SERVER", str) + self._port = Environment.get("MAIL_PORT", int) + self._user = Environment.get("MAIL_USER", str) + self._password = Environment.get("MAIL_PASSWORD", str) + self._from = Environment.get("MAIL_FROM", str) + + if not all([self._server, self._port, self._user, self._password, self._from]): + raise ValueError("Mail server configuration is incomplete.") + + def send(self, to: str, subject: str, body: str): + logger.debug(f"Preparing to send email to: {to}, subject: {subject}") + + msg = MIMEMultipart() + msg["From"] = self._from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(body, "plain")) + + try: + logger.info(f"Connecting to mail server: {self._server}:{self._port}") + with smtplib.SMTP(self._server, self._port) as server: + server.starttls() + logger.debug("Starting TLS encryption") + server.login(self._user, self._password) + logger.info(f"Logged in as: {self._user}") + server.sendmail(self._from, to, msg.as_string()) + logger.info(f"Email successfully sent to: {to}") + except Exception as e: + logger.error(f"Failed to send email to {to}", e) + + +mailService = MailService() diff --git a/api/src/service/permission/permissions_enum.py b/api/src/service/permission/permissions_enum.py index 267a9bc..f054ff0 100644 --- a/api/src/service/permission/permissions_enum.py +++ b/api/src/service/permission/permissions_enum.py @@ -26,6 +26,18 @@ class Permissions(Enum): settings = "settings" settings_update = "settings.update" + # domains + domains = "domains" + domains_create = "domains.create" + domains_update = "domains.update" + domains_delete = "domains.delete" + + # user spaces + user_spaces = "user_spaces" + user_spaces_create = "user_spaces.create" + user_spaces_update = "user_spaces.update" + user_spaces_delete = "user_spaces.delete" + """ Permissions """ @@ -38,12 +50,6 @@ class Permissions(Enum): """ Public """ - # domains - domains = "domains" - domains_create = "domains.create" - domains_update = "domains.update" - domains_delete = "domains.delete" - # groups groups = "groups" groups_create = "groups.create" diff --git a/web/src/app/components/home/home.component.html b/web/src/app/components/home/home.component.html index 5f2c53f..e69de29 100644 --- a/web/src/app/components/home/home.component.html +++ b/web/src/app/components/home/home.component.html @@ -1 +0,0 @@ -

home works!

diff --git a/web/src/app/core/base/form-page-base.ts b/web/src/app/core/base/form-page-base.ts index e05c78b..ad2e32f 100644 --- a/web/src/app/core/base/form-page-base.ts +++ b/web/src/app/core/base/form-page-base.ts @@ -26,8 +26,12 @@ export abstract class FormPageBase< protected filterService = inject(FilterService); protected dataService = inject(PageDataService) as S; - protected constructor(idKey: string = 'id') { + private backRoute: string = '..'; + + protected constructor(idKey: string = 'id', backRoute: string = '..') { const id = this.route.snapshot.params[idKey]; + this.backRoute = backRoute; + this.validateRoute(id); this.buildForm(); @@ -46,7 +50,7 @@ export abstract class FormPageBase< } close() { - const backRoute = this.nodeId ? '../..' : '..'; + const backRoute = this.nodeId ? `${this.backRoute}/..` : this.backRoute; this.router.navigate([backRoute], { relativeTo: this.route }).then(() => { this.filterService.onLoad.emit(); diff --git a/web/src/app/core/guard/permission.guard.ts b/web/src/app/core/guard/permission.guard.ts index ca1a076..684ee2c 100644 --- a/web/src/app/core/guard/permission.guard.ts +++ b/web/src/app/core/guard/permission.guard.ts @@ -4,7 +4,7 @@ import { Logger } from 'src/app/service/logger.service'; import { ToastService } from 'src/app/service/toast.service'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; +import { SidebarService } from 'src/app/service/sidebar.service'; const log = new Logger('PermissionGuard'); @@ -16,16 +16,18 @@ export class PermissionGuard { private router: Router, private toast: ToastService, private auth: AuthService, - private features: FeatureFlagService + private sidebar: SidebarService ) {} async canActivate(route: ActivatedRouteSnapshot): Promise { const permissions = route.data['permissions'] as PermissionsEnum[]; - const checkByPerUserSetup = route.data['checkByPerUserSetup'] as boolean; + const isInUserSpace = route.data['isInUserSpace'] as boolean; - const isPerUserSetup = await this.features.get('PerUserSetup'); - if (checkByPerUserSetup && isPerUserSetup) { - return true; + if (isInUserSpace) { + return ( + this.sidebar.selectedUserSpace$.value !== undefined && + this.sidebar.selectedUserSpace$.value !== null + ); } if (!permissions || permissions.length === 0) { diff --git a/web/src/app/core/init-keycloak.ts b/web/src/app/core/init-keycloak.ts index 07232b9..a7472fc 100644 --- a/web/src/app/core/init-keycloak.ts +++ b/web/src/app/core/init-keycloak.ts @@ -13,6 +13,7 @@ export function initializeKeycloak( }, initOptions: { onLoad: 'check-sso', + checkLoginIframe: false, }, enableBearerInterceptor: false, }); diff --git a/web/src/app/core/token.interceptor.ts b/web/src/app/core/token.interceptor.ts index 776d13e..443d5d6 100644 --- a/web/src/app/core/token.interceptor.ts +++ b/web/src/app/core/token.interceptor.ts @@ -20,6 +20,7 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => { return next(req); } + const auth = inject(AuthService); return from(keycloak.getToken()).pipe( switchMap(token => { if (!token) { @@ -46,7 +47,6 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => { ); }), catchError(() => { - const auth = inject(AuthService); auth.logout().then(); return next(req); }) diff --git a/web/src/app/model/entities/user-space.ts b/web/src/app/model/entities/user-space.ts new file mode 100644 index 0000000..365e1f0 --- /dev/null +++ b/web/src/app/model/entities/user-space.ts @@ -0,0 +1,20 @@ +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; + owner?: User; + users?: User[]; +} + +export interface UserSpaceCreateInput { + name?: string; + users?: number[]; +} + +export interface UserSpaceUpdateInput { + id: number; + name?: string; + users?: number[]; +} diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index f6be88c..22795b2 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -7,13 +7,9 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; const routes: Routes = [ { - path: 'domains', - loadChildren: () => - import('src/app/modules/admin/domains/domains.module').then( - m => m.DomainsModule - ), - canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.domains] }, + path: '', + pathMatch: 'full', + redirectTo: 'urls', }, { path: 'groups', @@ -22,7 +18,7 @@ const routes: Routes = [ m => m.GroupsModule ), canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true }, + data: { permissions: [PermissionsEnum.groups], isInUserSpace: true }, }, { path: 'urls', @@ -36,9 +32,16 @@ const routes: Routes = [ PermissionsEnum.shortUrls, PermissionsEnum.shortUrlsByAssignment, ], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, + { + path: 'rooms', + loadChildren: () => + import('src/app/modules/admin/user-spaces/user-spaces.module').then( + m => m.UserSpacesModule + ), + }, { path: 'administration', loadChildren: () => diff --git a/web/src/app/modules/admin/administration/administration.module.ts b/web/src/app/modules/admin/administration/administration.module.ts index a010f0f..d967bf1 100644 --- a/web/src/app/modules/admin/administration/administration.module.ts +++ b/web/src/app/modules/admin/administration/administration.module.ts @@ -11,6 +11,15 @@ const routes: Routes = [ pathMatch: 'full', redirectTo: 'users', }, + { + path: 'domains', + loadChildren: () => + import( + 'src/app/modules/admin/administration/domains/domains.module' + ).then(m => m.DomainsModule), + canActivate: [PermissionGuard], + data: { permissions: [PermissionsEnum.domains] }, + }, { path: 'users', title: 'Users | Maxlan', diff --git a/web/src/app/modules/admin/domains/domains.columns.ts b/web/src/app/modules/admin/administration/domains/domains.columns.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.columns.ts rename to web/src/app/modules/admin/administration/domains/domains.columns.ts diff --git a/web/src/app/modules/admin/domains/domains.data.service.ts b/web/src/app/modules/admin/administration/domains/domains.data.service.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.data.service.ts rename to web/src/app/modules/admin/administration/domains/domains.data.service.ts diff --git a/web/src/app/modules/admin/domains/domains.module.ts b/web/src/app/modules/admin/administration/domains/domains.module.ts similarity index 72% rename from web/src/app/modules/admin/domains/domains.module.ts rename to web/src/app/modules/admin/administration/domains/domains.module.ts index fe3365a..d4fc7ff 100644 --- a/web/src/app/modules/admin/domains/domains.module.ts +++ b/web/src/app/modules/admin/administration/domains/domains.module.ts @@ -4,11 +4,11 @@ import { SharedModule } from 'src/app/modules/shared/shared.module'; import { RouterModule, Routes } from '@angular/router'; import { PermissionGuard } from 'src/app/core/guard/permission.guard'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { DomainsPage } from 'src/app/modules/admin/domains/domains.page'; -import { DomainFormPageComponent } from 'src/app/modules/admin/domains/form-page/domain-form-page.component'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; -import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; -import { HistoryComponent } from 'src/app/modules/admin/domains/history/history.component'; +import { DomainsPage } from 'src/app/modules/admin/administration/domains/domains.page'; +import { DomainFormPageComponent } from 'src/app/modules/admin/administration/domains/form-page/domain-form-page.component'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; +import { DomainsColumns } from 'src/app/modules/admin/administration/domains/domains.columns'; +import { HistoryComponent } from 'src/app/modules/admin/administration/domains/history/history.component'; const routes: Routes = [ { diff --git a/web/src/app/modules/admin/domains/domains.page.html b/web/src/app/modules/admin/administration/domains/domains.page.html similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.html rename to web/src/app/modules/admin/administration/domains/domains.page.html diff --git a/web/src/app/modules/admin/domains/domains.page.scss b/web/src/app/modules/admin/administration/domains/domains.page.scss similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.scss rename to web/src/app/modules/admin/administration/domains/domains.page.scss diff --git a/web/src/app/modules/admin/domains/domains.page.spec.ts b/web/src/app/modules/admin/administration/domains/domains.page.spec.ts similarity index 100% rename from web/src/app/modules/admin/domains/domains.page.spec.ts rename to web/src/app/modules/admin/administration/domains/domains.page.spec.ts diff --git a/web/src/app/modules/admin/domains/domains.page.ts b/web/src/app/modules/admin/administration/domains/domains.page.ts similarity index 91% rename from web/src/app/modules/admin/domains/domains.page.ts rename to web/src/app/modules/admin/administration/domains/domains.page.ts index 337b4e3..72c8059 100644 --- a/web/src/app/modules/admin/domains/domains.page.ts +++ b/web/src/app/modules/admin/administration/domains/domains.page.ts @@ -3,9 +3,9 @@ import { PageBase } from 'src/app/core/base/page-base'; import { ToastService } from 'src/app/service/toast.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; -import { DomainsColumns } from 'src/app/modules/admin/domains/domains.columns'; import { Domain } from 'src/app/model/entities/domain'; +import { DomainsColumns } from 'src/app/modules/admin/administration/domains/domains.columns'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; @Component({ selector: 'app-domains', diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.html b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.html similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.html rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.html diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.scss similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.scss rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.scss diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.spec.ts similarity index 100% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.spec.ts rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.spec.ts diff --git a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts similarity index 95% rename from web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts rename to web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts index f5994bb..0cb4874 100644 --- a/web/src/app/modules/admin/domains/form-page/domain-form-page.component.ts +++ b/web/src/app/modules/admin/administration/domains/form-page/domain-form-page.component.ts @@ -7,7 +7,7 @@ import { DomainCreateInput, DomainUpdateInput, } from 'src/app/model/entities/domain'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; @Component({ selector: 'app-domain-form-page', diff --git a/web/src/app/modules/admin/domains/history/history.component.html b/web/src/app/modules/admin/administration/domains/history/history.component.html similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.html rename to web/src/app/modules/admin/administration/domains/history/history.component.html diff --git a/web/src/app/modules/admin/domains/history/history.component.scss b/web/src/app/modules/admin/administration/domains/history/history.component.scss similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.scss rename to web/src/app/modules/admin/administration/domains/history/history.component.scss diff --git a/web/src/app/modules/admin/domains/history/history.component.spec.ts b/web/src/app/modules/admin/administration/domains/history/history.component.spec.ts similarity index 95% rename from web/src/app/modules/admin/domains/history/history.component.spec.ts rename to web/src/app/modules/admin/administration/domains/history/history.component.spec.ts index 1245f2e..1362268 100644 --- a/web/src/app/modules/admin/domains/history/history.component.spec.ts +++ b/web/src/app/modules/admin/administration/domains/history/history.component.spec.ts @@ -14,7 +14,7 @@ import { PageDataService } from 'src/app/core/base/page.data.service'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { PageColumns } from 'src/app/core/base/page.columns'; import { MockPageColumns } from 'src/app/modules/shared/test/page.columns.mock'; -import { DomainsDataService } from 'src/app/modules/admin/domains/domains.data.service'; +import { DomainsDataService } from 'src/app/modules/admin/administration/domains/domains.data.service'; describe('HistoryComponent', () => { let component: HistoryComponent; diff --git a/web/src/app/modules/admin/domains/history/history.component.ts b/web/src/app/modules/admin/administration/domains/history/history.component.ts similarity index 100% rename from web/src/app/modules/admin/domains/history/history.component.ts rename to web/src/app/modules/admin/administration/domains/history/history.component.ts diff --git a/web/src/app/modules/admin/administration/users/users.columns.ts b/web/src/app/modules/admin/administration/users/users.columns.ts index e0f9550..d10dc2c 100644 --- a/web/src/app/modules/admin/administration/users/users.columns.ts +++ b/web/src/app/modules/admin/administration/users/users.columns.ts @@ -25,12 +25,14 @@ export class UsersColumns extends PageColumns { translationKey: 'user.username', type: 'text', value: (row: User) => row.username, + filterable: true, }, { name: 'email', translationKey: 'user.email', type: 'text', value: (row: User) => row.email, + filterable: true, }, { name: 'roles', diff --git a/web/src/app/modules/admin/administration/users/users.page.ts b/web/src/app/modules/admin/administration/users/users.page.ts index bbdfbf7..dfa459c 100644 --- a/web/src/app/modules/admin/administration/users/users.page.ts +++ b/web/src/app/modules/admin/administration/users/users.page.ts @@ -26,6 +26,8 @@ export class UsersPage extends PageBase { }); } + async onInit(): Promise {} + load(silent?: boolean): void { if (!silent) this.loading = true; this.dataService diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html index 8f0373c..ea771ac 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html @@ -27,14 +27,13 @@ type="text" formControlName="name"/> -
+
+ [showClear]="true"/> diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts index f80f7d8..34d36f9 100644 --- a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ToastService } from 'src/app/service/toast.service'; import { FormPageBase } from 'src/app/core/base/form-page-base'; @@ -17,17 +17,13 @@ import { FeatureFlagService } from 'src/app/service/feature-flag.service'; templateUrl: './group-form-page.component.html', styleUrl: './group-form-page.component.scss', }) -export class GroupFormPageComponent - extends FormPageBase< - Group, - GroupCreateInput, - GroupUpdateInput, - GroupsDataService - > - implements OnInit -{ +export class GroupFormPageComponent extends FormPageBase< + Group, + GroupCreateInput, + GroupUpdateInput, + GroupsDataService +> { roles: Role[] = []; - isPerUserSetup = true; constructor( private features: FeatureFlagService, @@ -35,15 +31,9 @@ export class GroupFormPageComponent private cds: CommonDataService ) { super(); - } - - async ngOnInit() { - this.isPerUserSetup = await this.features.get('PerUserSetup'); - if (!this.isPerUserSetup) { - this.cds.getAllRoles().subscribe(roles => { - this.roles = roles; - }); - } + this.cds.getAllRoles().subscribe(roles => { + this.roles = roles; + }); if (!this.nodeId) { this.node = this.new(); diff --git a/web/src/app/modules/admin/groups/groups.data.service.ts b/web/src/app/modules/admin/groups/groups.data.service.ts index c40ce19..9687874 100644 --- a/web/src/app/modules/admin/groups/groups.data.service.ts +++ b/web/src/app/modules/admin/groups/groups.data.service.ts @@ -23,6 +23,7 @@ import { GroupUpdateInput, } from 'src/app/model/entities/group'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Injectable() export class GroupsDataService @@ -35,7 +36,8 @@ export class GroupsDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo + private apollo: Apollo, + private sidebarService: SidebarService ) { super(); } @@ -74,7 +76,14 @@ export class GroupsDataService ${DB_MODEL_FRAGMENT} `, variables: { - filter: filter, + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + ...(filter ?? []), + ], sort: sort, skip: skip, take: take, @@ -186,6 +195,7 @@ export class GroupsDataService `, variables: { input: { + userSpaceId: this.sidebarService.selectedUserSpace$.value?.id, name: object.name, roles: object.roles?.map(x => x.id), }, diff --git a/web/src/app/modules/admin/groups/groups.module.ts b/web/src/app/modules/admin/groups/groups.module.ts index f8762ad..41b2b64 100644 --- a/web/src/app/modules/admin/groups/groups.module.ts +++ b/web/src/app/modules/admin/groups/groups.module.ts @@ -22,7 +22,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsCreate], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, { @@ -31,7 +31,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsUpdate], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, { @@ -40,7 +40,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groups], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, ], diff --git a/web/src/app/modules/admin/groups/groups.page.ts b/web/src/app/modules/admin/groups/groups.page.ts index b080d40..dfddf35 100644 --- a/web/src/app/modules/admin/groups/groups.page.ts +++ b/web/src/app/modules/admin/groups/groups.page.ts @@ -1,68 +1,41 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { PageBase } from 'src/app/core/base/page-base'; import { ToastService } from 'src/app/service/toast.service'; import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; -import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { Group } from 'src/app/model/entities/group'; import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service'; import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns'; -import { AuthService } from 'src/app/service/auth.service'; -import { ConfigService } from 'src/app/service/config.service'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; +import { takeUntil } from 'rxjs/operators'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Component({ selector: 'app-groups', templateUrl: './groups.page.html', styleUrl: './groups.page.scss', }) -export class GroupsPage - extends PageBase - implements OnInit -{ +export class GroupsPage extends PageBase< + Group, + GroupsDataService, + GroupsColumns +> { constructor( private toast: ToastService, private confirmation: ConfirmationDialogService, - private auth: AuthService, - private config: ConfigService, - private features: FeatureFlagService + private sidebar: SidebarService ) { - super(true); - } + super(true, { + read: [], + create: [], + update: [], + delete: [], + restore: [], + }); - async ngOnInit() { - this.requiredPermissions = { - read: (await this.features.get('PerUserSetup')) - ? [] - : [PermissionsEnum.groups], - create: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.create ?? [] - )) - ? (this.requiredPermissions.create ?? []) - : [], - update: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.update ?? [] - )) - ? (this.requiredPermissions.update ?? []) - : [], - delete: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.delete ?? [] - )) - ? (this.requiredPermissions.delete ?? []) - : [], - restore: (await this.features.get('PerUserSetup')) - ? [] - : (await this.auth.hasAnyPermissionLazy( - this.requiredPermissions.restore ?? [] - )) - ? (this.requiredPermissions.restore ?? []) - : [], - }; + this.sidebar.selectedUserSpace$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(() => { + this.load(true); + }); } load(silent?: boolean): void { diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html index e4bddcb..8240320 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html @@ -65,7 +65,7 @@ > -
+

{{ 'common.domain' | translate }}

diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts index 48ebe48..207682f 100644 --- a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ToastService } from 'src/app/service/toast.service'; import { FormPageBase } from 'src/app/core/base/form-page-base'; @@ -10,45 +10,29 @@ import { import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service'; import { Group } from 'src/app/model/entities/group'; import { Domain } from 'src/app/model/entities/domain'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; @Component({ selector: 'app-short-url-form-page', templateUrl: './short-url-form-page.component.html', styleUrl: './short-url-form-page.component.scss', }) -export class ShortUrlFormPageComponent - extends FormPageBase< - ShortUrl, - ShortUrlCreateInput, - ShortUrlUpdateInput, - ShortUrlsDataService - > - implements OnInit -{ +export class ShortUrlFormPageComponent extends FormPageBase< + ShortUrl, + ShortUrlCreateInput, + ShortUrlUpdateInput, + ShortUrlsDataService +> { groups: Group[] = []; domains: Domain[] = []; - isPerUserSetup = true; - - constructor( - private features: FeatureFlagService, - private toast: ToastService - ) { + constructor(private toast: ToastService) { super(); - this.dataService.getAllGroups().subscribe(groups => { + this.dataService.getAllAvailableGroups().subscribe(groups => { this.groups = groups; }); - } - - async ngOnInit() { - this.isPerUserSetup = await this.features.get('PerUserSetup'); - - if (!this.isPerUserSetup) { - this.dataService.getAllDomains().subscribe(domains => { - this.domains = domains; - }); - } + this.dataService.getAllDomains().subscribe(domains => { + this.domains = domains; + }); if (!this.nodeId) { this.node = this.new(); diff --git a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts index ddfe661..289aa21 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts @@ -1,5 +1,5 @@ import { Injectable, Provider } from '@angular/core'; -import { forkJoin, merge, Observable } from 'rxjs'; +import { merge, Observable } from 'rxjs'; import { Create, Delete, @@ -25,6 +25,7 @@ import { import { Group } from 'src/app/model/entities/group'; import { Domain } from 'src/app/model/entities/domain'; import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; +import { SidebarService } from 'src/app/service/sidebar.service'; @Injectable() export class ShortUrlsDataService @@ -37,7 +38,8 @@ export class ShortUrlsDataService { constructor( private spinner: SpinnerService, - private apollo: Apollo + private apollo: Apollo, + private sidebarService: SidebarService ) { super(); } @@ -48,88 +50,113 @@ export class ShortUrlsDataService skip?: number | undefined, take?: number | undefined ): Observable> { - const query1 = this.apollo.query<{ shortUrls: QueryResult }>({ - query: gql` - query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) { - shortUrls(filter: $filter, sort: $sort) { - nodes { - id - shortUrl - targetUrl - description - loadingScreen - visits - group { + return this.apollo + .query<{ + shortUrls: QueryResult; + shortUrlsWithoutGroup: QueryResult; + }>({ + query: gql` + query getShortUrls( + $filter: [ShortUrlFilter] + $filter2: [ShortUrlFilter] + $sort: [ShortUrlSort] + ) { + shortUrls(filter: $filter, sort: $sort) { + nodes { id - name - } - domain { - id - name - } + shortUrl + targetUrl + description + loadingScreen + visits + group { + id + name + } + domain { + id + name + } - ...DB_MODEL + ...DB_MODEL + } + } + + shortUrlsWithoutGroup: shortUrls(filter: $filter2, sort: $sort) { + nodes { + id + shortUrl + targetUrl + description + loadingScreen + visits + group { + id + name + } + domain { + id + name + } + + ...DB_MODEL + } } } - } - ${DB_MODEL_FRAGMENT} - `, - variables: { - filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])], - sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], - skip, - take, - }, - }); - - const query2 = this.apollo.query<{ shortUrls: QueryResult }>({ - query: gql` - query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) { - shortUrls(filter: $filter, sort: $sort) { - nodes { - id - shortUrl - targetUrl - description - loadingScreen - visits - group { - id - name - } - domain { - id - name - } - - ...DB_MODEL - } - } - } - - ${DB_MODEL_FRAGMENT} - `, - variables: { - filter: [{ group: { isNull: true } }, ...(filter ?? [])], - sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], - skip, - take, - }, - }); - - return forkJoin([query1, query2]).pipe( - map(([result1, result2]) => { - const nodes = [ - ...result1.data.shortUrls.nodes, - ...result2.data.shortUrls.nodes, - ]; - const uniqueNodes = Array.from( - new Map(nodes.map(node => [node.id, node])).values() - ); - return { ...result1.data.shortUrls, nodes: uniqueNodes }; + ${DB_MODEL_FRAGMENT} + `, + variables: { + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { group: { deleted: { equal: false } } }, + { + group: { + userSpace: { + id: { + equal: this.sidebarService.selectedUserSpace$.value?.id, + }, + }, + }, + }, + ...(filter ?? []), + ], + filter2: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { + group: { + isNull: true, + }, + }, + ...(filter ?? []), + ], + sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], + skip, + take, + }, }) - ); + .pipe( + map(x => { + return { + count: x.data.shortUrls.count + x.data.shortUrlsWithoutGroup.count, + totalCount: + x.data.shortUrls.totalCount + + x.data.shortUrlsWithoutGroup.totalCount, + nodes: [ + ...x.data.shortUrls.nodes, + ...x.data.shortUrlsWithoutGroup.nodes, + ], + }; + }) + ); } loadById(id: number): Observable { @@ -263,6 +290,7 @@ export class ShortUrlsDataService `, variables: { input: { + userSpaceId: this.sidebarService.selectedUserSpace$.value?.id, shortUrl: object.shortUrl, targetUrl: object.targetUrl, description: object.description, @@ -365,12 +393,12 @@ export class ShortUrlsDataService .pipe(map(result => result.data?.shortUrl.restore ?? false)); } - getAllGroups() { + getAllAvailableGroups() { return this.apollo .query<{ groups: QueryResult }>({ query: gql` - query getGroups { - groups { + query getGroups($filter: [GroupFilter]) { + groups(filter: $filter) { nodes { id name @@ -378,6 +406,16 @@ export class ShortUrlsDataService } } `, + variables: { + filter: [ + { + userSpace: { + id: { equal: this.sidebarService.selectedUserSpace$.value?.id }, + }, + }, + { deleted: { equal: false } }, + ], + }, }) .pipe( catchError(err => { diff --git a/web/src/app/modules/admin/short-urls/short-urls.module.ts b/web/src/app/modules/admin/short-urls/short-urls.module.ts index a144ca7..ca524b9 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.module.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.module.ts @@ -22,7 +22,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsCreate], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, { @@ -31,7 +31,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsUpdate], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, { @@ -40,7 +40,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrls], - checkByPerUserSetup: true, + isInUserSpace: true, }, }, ], diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.html b/web/src/app/modules/admin/short-urls/short-urls.page.html index e0c3333..740c37c 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.page.html +++ b/web/src/app/modules/admin/short-urls/short-urls.page.html @@ -36,7 +36,6 @@ (onClick)="open(url.shortUrl)">

{{ 'short_url.short_url' | translate }}

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

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

+
+ + +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.name' | translate }}

+ +
+
+
+

{{ 'user_space.assign_users' | translate }}

+
+
+ +
+
+
+ {{ 'table.no_entries_found' | translate }} +
+
+
+

{{ 'user_space.invite_users' | translate }}

+
+ +
+
+
+
diff --git a/api/src/api_graphql/filter/collection/__init__.py b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.scss similarity index 100% rename from api/src/api_graphql/filter/collection/__init__.py rename to web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.scss diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts new file mode 100644 index 0000000..d7bfe12 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RoleFormPageComponent } from "src/app/modules/admin/administration/roles/form-page/role-form-page.component"; +import { SharedModule } from "src/app/modules/shared/shared.module"; +import { TranslateModule } from "@ngx-translate/core"; +import { AuthService } from "src/app/service/auth.service"; +import { ErrorHandlingService } from "src/app/service/error-handling.service"; +import { ToastService } from "src/app/service/toast.service"; +import { ConfirmationService, MessageService } from "primeng/api"; +import { ActivatedRoute } from "@angular/router"; +import { of } from "rxjs"; +import { ApiKeysDataService } from "src/app/modules/admin/administration/api-keys/api-keys.data.service"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +describe("ApiKeyFormpageComponent", () => { + let component: RoleFormPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RoleFormPageComponent], + imports: [ + BrowserAnimationsModule, + SharedModule, + TranslateModule.forRoot(), + ], + providers: [ + AuthService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ApiKeysDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RoleFormPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts new file mode 100644 index 0000000..b0d14e2 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/form-page/user-space-form-page.component.ts @@ -0,0 +1,119 @@ +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'; +import { catchError } from 'rxjs/operators'; + +@Component({ + selector: 'app-user-space-form-page', + templateUrl: './user-space-form-page.component.html', + styleUrl: './user-space-form-page.component.scss', +}) +export class UserSpaceFormPageComponent extends FormPageBase< + UserSpace, + UserSpaceCreateInput, + UserSpaceUpdateInput, + UserSpacesDataService +> { + constructor(private toast: ToastService) { + super(undefined, '../..'); + if (!this.nodeId) { + this.node = this.new(); + this.setForm(this.node); + + return; + } + + this.dataService.loadById(this.nodeId).subscribe(node => { + this.node = node; + this.setForm(this.node); + }); + } + + new(): UserSpace { + return {} as UserSpace; + } + + buildForm() { + this.form = new FormGroup({ + id: new FormControl(undefined), + name: new FormControl(undefined, [ + Validators.required, + ]), + emailsToInvite: new FormControl([]), + }); + 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, + users: this.node.users?.map(x => x.id) ?? [], + }; + } + + 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'].pristine + ? undefined + : this.form.controls['name'].value, + users: this.node.users?.map(x => x.id) ?? [], + }; + } + + protected handleEMailInvitation(id: number): void { + const emailsToInvite = this.form.controls['emailsToInvite'].value; + if (!(emailsToInvite && emailsToInvite.length > 0)) { + return; + } + + this.dataService + .inviteUsers(id, emailsToInvite) + .pipe( + catchError(err => { + this.spinner.hide(); + this.toast.error('action.failed', 'user_space.invite_users'); + throw err; + }) + ) + .subscribe(() => { + this.spinner.hide(); + this.toast.success('user_space.invited_users'); + }); + } + + create(object: UserSpaceCreateInput): void { + this.dataService.create(object).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } + + update(object: UserSpaceUpdateInput): void { + this.handleEMailInvitation(object.id); + this.dataService.update(object).subscribe(() => { + this.spinner.hide(); + this.toast.success('action.created'); + this.close(); + }); + } +} diff --git a/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts new file mode 100644 index 0000000..aceb93a --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/user-spaces.data.service.ts @@ -0,0 +1,330 @@ +import { Injectable, Provider } from '@angular/core'; +import { merge, Observable } from 'rxjs'; +import { + Create, + Delete, + PageDataService, + Restore, + Update, +} from 'src/app/core/base/page.data.service'; +import { Filter } from 'src/app/model/graphql/filter/filter.model'; +import { Sort, SortOrder } from 'src/app/model/graphql/filter/sort.model'; +import { Apollo, gql } from 'apollo-angular'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { + DB_HISTORY_MODEL_FRAGMENT, + DB_MODEL_FRAGMENT, +} from 'src/app/model/graphql/db-model.query'; +import { catchError, map } from 'rxjs/operators'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { + UserSpace, + UserSpaceCreateInput, + UserSpaceUpdateInput, +} from 'src/app/model/entities/user-space'; +import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service'; + +@Injectable() +export class UserSpacesDataService + extends PageDataService + implements + Create, + Update, + Delete, + Restore +{ + constructor( + private spinner: SpinnerService, + private apollo: Apollo + ) { + super(); + } + + load( + filter?: Filter[] | undefined, + sort?: Sort[] | undefined, + skip?: number | undefined, + take?: number | undefined + ): Observable> { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpaces( + $filter: [UserSpaceFilter] + $sort: [UserSpaceSort] + ) { + userSpaces(filter: $filter, sort: $sort) { + nodes { + id + name + owner { + id + username + } + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + filter: [filter], + sort: [{ id: SortOrder.DESC }, ...(sort ?? [])], + skip, + take, + }, + }) + .pipe(map(result => result.data.userSpaces)); + } + + loadById(id: number): Observable { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpace($id: Int) { + userSpaces(filter: { id: { equal: $id } }) { + nodes { + id + name + + owner { + id + username + } + + users { + id + username + } + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + id: id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data.userSpaces.nodes[0])); + } + + loadHistory(id: number) { + return this.apollo + .query<{ userSpaces: QueryResult }>({ + query: gql` + query getUserSpaceHistory($id: Int) { + userSpaces(filter: { id: { equal: $id } }) { + count + totalCount + nodes { + history { + id + name + + ...DB_HISTORY_MODEL + } + } + } + } + + ${DB_HISTORY_MODEL_FRAGMENT} + `, + variables: { + id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpaces?.nodes?.[0]?.history ?? [])); + } + + onChange(): Observable { + return merge( + this.apollo + .subscribe<{ userSpaceChange: void }>({ + query: gql` + subscription onUserSpaceChange { + userSpaceChange + } + `, + }) + .pipe(map(result => result.data?.userSpaceChange)), + + this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onGroupChange { + groupChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)) + ).pipe(map(() => {})); + } + + create(object: UserSpaceCreateInput): Observable { + return this.apollo + .mutate<{ userSpace: { create: UserSpace } }>({ + mutation: gql` + mutation createUserSpace($input: UserSpaceCreateInput!) { + userSpace { + create(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + name: object.name, + users: object.users, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.create)); + } + + update(object: UserSpaceUpdateInput): Observable { + return this.apollo + .mutate<{ userSpace: { update: UserSpace } }>({ + mutation: gql` + mutation updateUserSpace($input: UserSpaceUpdateInput!) { + userSpace { + update(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + id: object.id, + name: object.name, + users: object.users, + }, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.update)); + } + + delete(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { delete: boolean } }>({ + mutation: gql` + mutation deleteUserSpace($id: Int!) { + userSpace { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.delete ?? false)); + } + + restore(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { restore: boolean } }>({ + mutation: gql` + mutation restoreUserSpace($id: Int!) { + userSpace { + restore(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.restore ?? false)); + } + + inviteUsers(id: number, emails: string[]): Observable { + return this.apollo + .mutate<{ userSpace: { inviteUsers: boolean } }>({ + mutation: gql` + mutation inviteUsers($id: Int!, $emails: [String!]!) { + userSpace { + inviteUsers(id: $id, emails: $emails) + } + } + `, + variables: { + id, + emails, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.inviteUsers ?? false)); + } + + static provide(): Provider[] { + return [ + { + provide: PageDataService, + useClass: UserSpacesDataService, + }, + { + provide: PageWithHistoryDataService, + useClass: UserSpacesDataService, + }, + UserSpacesDataService, + ]; + } +} diff --git a/web/src/app/modules/admin/user-spaces/user-spaces.module.ts b/web/src/app/modules/admin/user-spaces/user-spaces.module.ts new file mode 100644 index 0000000..909f1a8 --- /dev/null +++ b/web/src/app/modules/admin/user-spaces/user-spaces.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; +import { UserSpaceFormPageComponent } from 'src/app/modules/admin/user-spaces/form-page/user-space-form-page.component'; + +const routes: Routes = [ + { + path: 'create', + component: UserSpaceFormPageComponent, + }, + { + path: 'edit/:id', + component: UserSpaceFormPageComponent, + }, +]; + +@NgModule({ + declarations: [UserSpaceFormPageComponent], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [UserSpacesDataService.provide()], +}) +export class UserSpacesModule {} diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.html b/web/src/app/modules/shared/components/chip-input/chip-input.component.html new file mode 100644 index 0000000..b404629 --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.html @@ -0,0 +1,15 @@ +
+
+ + + +
+
\ No newline at end of file diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.scss b/web/src/app/modules/shared/components/chip-input/chip-input.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts b/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts new file mode 100644 index 0000000..827af69 --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { ChipInputComponent } from './chip-input.component'; + +describe('ChipInputComponent', () => { + let component: ChipInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ChipInputComponent], + imports: [FormsModule], // Import FormsModule to resolve ngModel + }).compileComponents(); + + fixture = TestBed.createComponent(ChipInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/shared/components/chip-input/chip-input.component.ts b/web/src/app/modules/shared/components/chip-input/chip-input.component.ts new file mode 100644 index 0000000..419957c --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'app-chip-input', + templateUrl: './chip-input.component.html', + styleUrls: ['./chip-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ChipInputComponent), + multi: true, + }, + ], +}) +export class ChipInputComponent implements ControlValueAccessor { + @Input() type: string = 'text'; + @Input() placeholder?: string; + chips: string[] = []; + inputValue: string = ''; + + protected onChange: (value: string[]) => void = () => {}; + protected onTouched: () => void = () => {}; + + writeValue(value: string[]): void { + this.chips = value || []; + } + + registerOnChange(fn: (value: string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + addChip(): void { + if (!this.inputValue.trim()) { + return; + } + + if (this.type === 'number' && isNaN(Number(this.inputValue.trim()))) { + return; // Invalid number, do not add + } + if ( + this.type === 'email' && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.inputValue.trim()) + ) { + return; // Invalid email, do not add + } + this.chips.push(this.inputValue.trim()); + this.inputValue = ''; + this.onChange(this.chips); + } +} diff --git a/web/src/app/modules/shared/components/side-menu/side-menu.component.html b/web/src/app/modules/shared/components/side-menu/side-menu.component.html index d257a08..7df2b8b 100644 --- a/web/src/app/modules/shared/components/side-menu/side-menu.component.html +++ b/web/src/app/modules/shared/components/side-menu/side-menu.component.html @@ -1,49 +1,51 @@ - -
-
- {{ element.label | translate }} -
-
- - -
-
+ +
+
+ {{ element.label | translate }} +
+
+ + +
+
- -
- + + <{{ element.label }} - {{ element.visible }} | {{ element.command }}> +
+ -
-
- -
- +
+ + <{{ item.label }} - {{ item.visible }} | {{ item.command }}> +
+ +
+
+
-
-
-
-
+
diff --git a/web/src/app/modules/shared/components/side-menu/side-menu.component.spec.ts b/web/src/app/modules/shared/components/side-menu/side-menu.component.spec.ts index 762db12..8cd8802 100644 --- a/web/src/app/modules/shared/components/side-menu/side-menu.component.spec.ts +++ b/web/src/app/modules/shared/components/side-menu/side-menu.component.spec.ts @@ -1,6 +1,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SideMenuComponent } from './side-menu.component'; +import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from 'src/app/service/auth.service'; +import { KeycloakService } from 'keycloak-angular'; +import { ErrorHandlingService } from 'src/app/service/error-handling.service'; +import { ToastService } from 'src/app/service/toast.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; describe('SideMenuComponent', () => { let component: SideMenuComponent; @@ -9,6 +18,21 @@ describe('SideMenuComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SideMenuComponent], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(SideMenuComponent); diff --git a/web/src/app/modules/shared/components/side-menu/side-menu.component.ts b/web/src/app/modules/shared/components/side-menu/side-menu.component.ts index edd0dec..9b9d3d5 100644 --- a/web/src/app/modules/shared/components/side-menu/side-menu.component.ts +++ b/web/src/app/modules/shared/components/side-menu/side-menu.component.ts @@ -1,14 +1,32 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnDestroy } from '@angular/core'; import { MenuElement } from 'src/app/model/view/menu-element'; +import { GuiService } from 'src/app/service/gui.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-side-menu', templateUrl: './side-menu.component.html', styleUrl: './side-menu.component.scss', }) -export class SideMenuComponent { +export class SideMenuComponent implements OnDestroy { @Input() elements: MenuElement[] = []; + debug = false; + + unsubscribe$ = new Subject(); + + constructor(private gui: GuiService) { + this.gui.debug$.pipe(takeUntil(this.unsubscribe$)).subscribe(debug => { + this.debug = debug; + }); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + expand(element: MenuElement): void { element.expanded = true; } diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts index d221894..73725b9 100644 --- a/web/src/app/modules/shared/shared.module.ts +++ b/web/src/app/modules/shared/shared.module.ts @@ -72,6 +72,7 @@ import { getMainDefinition } from '@apollo/client/utilities'; import { Kind, OperationTypeNode } from 'graphql/index'; import { HistorySidebarComponent } from 'src/app/modules/shared/components/history/history-sidebar.component'; import { SliderModule } from 'primeng/slider'; +import { ChipInputComponent } from './components/chip-input/chip-input.component'; const sharedModules = [ StepsModule, @@ -146,9 +147,9 @@ function debounce(func: (...args: unknown[]) => void, wait: number) { } @NgModule({ - declarations: [...sharedComponents], + declarations: [...sharedComponents, ChipInputComponent], imports: [CommonModule, ...sharedModules], - exports: [...sharedModules, ...sharedComponents], + exports: [...sharedModules, ...sharedComponents, ChipInputComponent], providers: [ provideHttpClient(withInterceptors([tokenInterceptor])), provideApollo(() => { diff --git a/web/src/app/service/gui.service.ts b/web/src/app/service/gui.service.ts index 708d2a7..1fd6a0b 100644 --- a/web/src/app/service/gui.service.ts +++ b/web/src/app/service/gui.service.ts @@ -20,6 +20,8 @@ export class GuiService { hideGui$ = new BehaviorSubject(false); theme$ = new BehaviorSubject(this.config.settings.themes[0].name); + debug$ = new BehaviorSubject(false); + constructor( private router: Router, private apollo: Apollo, diff --git a/web/src/app/service/sidebar.data.service.ts b/web/src/app/service/sidebar.data.service.ts new file mode 100644 index 0000000..747f5dd --- /dev/null +++ b/web/src/app/service/sidebar.data.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { Apollo, gql } from 'apollo-angular'; +import { Observable } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { UserSpace } from 'src/app/model/entities/user-space'; +import { QueryResult } from 'src/app/model/entities/query-result'; +import { SpinnerService } from 'src/app/service/spinner.service'; + +@Injectable({ + providedIn: 'root', +}) +export class SidebarDataService { + constructor( + private apollo: Apollo, + private spinner: SpinnerService + ) {} + + getAssignedUserSpaces() { + return this.apollo + .query<{ assignedUserSpaces: QueryResult }>({ + query: gql` + query getAssignedUserSpaces { + assignedUserSpaces { + nodes { + id + name + + owner { + id + username + } + } + } + } + `, + }) + .pipe(map(result => result.data?.assignedUserSpaces.nodes)); + } + + onChange(): Observable { + return this.apollo + .subscribe<{ groupChange: void }>({ + query: gql` + subscription onUserSpaceChange { + userSpaceChange + } + `, + }) + .pipe(map(result => result.data?.groupChange)); + } + + delete(object: UserSpace): Observable { + return this.apollo + .mutate<{ userSpace: { delete: boolean } }>({ + mutation: gql` + mutation deleteUserSpace($id: Int!) { + userSpace { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.delete ?? false)); + } +} diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts index 49bff52..a394d41 100644 --- a/web/src/app/service/sidebar.service.ts +++ b/web/src/app/service/sidebar.service.ts @@ -3,7 +3,11 @@ import { BehaviorSubject } from 'rxjs'; import { MenuElement } from 'src/app/model/view/menu-element'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; +import { SidebarDataService } from 'src/app/service/sidebar.data.service'; +import { UserSpace } from 'src/app/model/entities/user-space'; +import { SpinnerService } from 'src/app/service/spinner.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { Router } from '@angular/router'; @Injectable({ providedIn: 'root', @@ -12,13 +16,51 @@ export class SidebarService { visible$ = new BehaviorSubject(true); elements$ = new BehaviorSubject([]); + selectedUserSpace$ = new BehaviorSubject( + undefined + ); + userSpaces: UserSpace[] = []; + constructor( private auth: AuthService, - private featureFlags: FeatureFlagService + private data: SidebarDataService, + private spinner: SpinnerService, + private router: Router, + private confirmation: ConfirmationDialogService ) { + this.loadUserSpaces(); this.auth.user$.subscribe(async () => { await this.setElements(); }); + + this.data.onChange().subscribe(() => { + this.loadUserSpaces(); + }); + + this.selectedUserSpace$.subscribe(async value => { + // skip initial value + if (value === undefined) { + return; + } + + this.setSelectedUserSpaceIdToLS(value ? value.id : 0); + await this.setElements(); + await this.router.navigate(['/admin/urls']); + }); + } + + 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() { @@ -31,35 +73,100 @@ export class SidebarService { // trust me, you'll need this async async setElements() { + const isSelectedUserSpaceOwner = + this.selectedUserSpace$.value?.owner?.id === this.auth.user$.value?.id; + const elements: MenuElement[] = [ { - label: 'common.domains', - icon: 'pi pi-sitemap', - routerLink: ['/admin/domains'], - visible: await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.domains, - ]), + label: 'sidebar.user_spaces', + icon: 'pi pi-list', + expanded: true, + items: [ + { + label: 'sidebar.user_space_add', + icon: 'pi pi-plus', + routerLink: ['/admin/rooms/create'], + }, + ...(this.userSpaces + ?.filter(x => x.name != this.selectedUserSpace$.value?.name) + .map(x => { + return { + label: x.name, + icon: 'pi pi-hashtag', + command: async () => { + this.spinner.show(); + this.selectedUserSpace$.next(x); + await this.setElements(); + await this.router.navigate(['/admin/urls']); + }, + } as MenuElement; + }) ?? []), + ], }, { - label: 'common.groups', - icon: 'pi pi-tags', - routerLink: ['/admin/groups'], - visible: - (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) || - (await this.featureFlags.get('PerUserSetup')), - }, - { - label: 'common.urls', - icon: 'pi pi-tag', - routerLink: ['/admin/urls'], - visible: - (await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.shortUrls, - PermissionsEnum.shortUrlsByAssignment, - ])) || (await this.featureFlags.get('PerUserSetup')), + label: this.selectedUserSpace$.value?.name || '', + icon: 'pi pi-hashtag', + visible: !!this.selectedUserSpace$.value, + expanded: true, + items: [ + { + label: 'sidebar.user_space_edit', + icon: 'pi pi-pencil', + visible: isSelectedUserSpaceOwner, + routerLink: [ + `/admin/rooms/edit/${this.selectedUserSpace$.value?.id}`, + ], + }, + { + label: 'common.groups', + icon: 'pi pi-tags', + routerLink: ['/admin/groups'], + visible: + !!this.selectedUserSpace$.value || + (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])), + }, + { + label: 'common.urls', + icon: 'pi pi-tag', + routerLink: ['/admin/urls'], + visible: + !!this.selectedUserSpace$.value || + (await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.shortUrls, + PermissionsEnum.shortUrlsByAssignment, + ])), + }, + { + label: 'sidebar.user_space_delete', + icon: 'pi pi-trash', + visible: isSelectedUserSpaceOwner, + command: () => { + this.confirmation.confirmDialog({ + header: 'dialog.delete.header', + message: 'dialog.delete.message', + accept: () => { + if (!this.selectedUserSpace$.value) { + return; + } + + this.spinner.show(); + this.data + .delete(this.selectedUserSpace$.value) + .subscribe(() => { + this.selectedUserSpace$.next(null); + this.spinner.hide(); + }); + }, + messageParams: { entity: this.selectedUserSpace$.value?.name }, + }); + }, + }, + ], }, + await this.sectionAdmin(), ]; + this.elements$.next(elements); } @@ -71,6 +178,14 @@ export class SidebarService { expanded: true, isSection: true, items: [ + { + label: 'common.domains', + icon: 'pi pi-sitemap', + routerLink: ['/admin/administration/domains'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.domains, + ]), + }, { label: 'sidebar.users', icon: 'pi pi-user', @@ -114,4 +229,13 @@ export class SidebarService { ], }; } + + protected getSelectedUserSpaceIdFromLS() { + const storedId = localStorage.getItem('sidebar_SelectedUserSpaceId'); + return storedId ? +storedId : null; + } + + protected setSelectedUserSpaceIdToLS(id: number) { + localStorage.setItem('sidebar_SelectedUserSpaceId', id.toString()); + } } diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index aa96be0..f48c939 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -2,6 +2,7 @@ "action": { "created": "Erstellt", "deleted": "Gelöscht", + "failed": "Fehlgeschlagen", "restored": "Wiederhergestellt", "updated": "Geändert" }, @@ -28,6 +29,7 @@ "domains": "Domains", "download": "Herunterladen", "editor": "Bearbeiter", + "enter_emails": "E-Mails eintragen", "error": "Fehler", "group": "Gruppe", "groups": "Gruppen", @@ -42,6 +44,7 @@ "updated": "Bearbeitet", "upload": "Hochladen", "urls": "URLs", + "user_space": "Team", "warning": "Warnung" }, "dialog": { @@ -239,6 +242,10 @@ }, "roles": "Rollen", "settings": "Einstellungen", + "user_space_add": "Team erstellen", + "user_space_delete": "Team löschen", + "user_space_edit": "Team bearbeiten", + "user_spaces": "Teams", "users": "Benutzer" }, "table": { @@ -265,5 +272,10 @@ "keycloak_id": "Keycloak Id", "user": "Benutzer", "username": "Benutzername" + }, + "user_space": { + "assign_users": "Benutzer", + "invite_users": "Benutzer einladen", + "invited_users": "Benutzer eingeladen" } } \ No newline at end of file diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 4f95c94..bbc7784 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -2,6 +2,7 @@ "action": { "created": "Created", "deleted": "Deleted", + "failed": "Failed", "restored": "Recovered", "updated": "Updated" }, @@ -28,6 +29,7 @@ "domains": "Domains", "download": "Download", "editor": "Editor", + "enter_emails": "Enter E-Mails", "error": "Fehler", "group": "Group", "groups": "Groups", @@ -42,6 +44,7 @@ "updated": "Updated", "upload": "Upload", "urls": "URLs", + "user_space": "Team", "warning": "Warning" }, "dialog": { @@ -239,6 +242,10 @@ }, "roles": "Roles", "settings": "Settings", + "user_space_add": "Add Team", + "user_space_delete": "Delete Team", + "user_space_edit": "Edit Team", + "user_spaces": "Teams", "users": "Users" }, "table": { @@ -265,5 +272,10 @@ "keycloak_id": "Keycloak Id", "user": "User", "username": "Username" + }, + "user_space": { + "assign_users": "Users", + "invite_users": "Invite users", + "invited_users": "Invited users" } } \ No newline at end of file diff --git a/web/src/styles.scss b/web/src/styles.scss index ce42dc9..dec3d2c 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -68,6 +68,11 @@ body { padding: 0; } } + + .no-border { + border: 0 !important; + outline: none !important; + } } header { diff --git a/web/src/styles/theme.scss b/web/src/styles/theme.scss index c22df45..fe4e264 100644 --- a/web/src/styles/theme.scss +++ b/web/src/styles/theme.scss @@ -70,7 +70,7 @@ color: $headerColor; } - input, .p-checkbox-box, .p-dropdown { + input, .input, .p-checkbox-box, .p-dropdown { border: 1px solid $accentColor; }