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 84975cf..e2564b5 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -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/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/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql index cc28baf..d86cc5c 100644 --- a/api/src/api_graphql/graphql/user_space.gql +++ b/api/src/api_graphql/graphql/user_space.gql @@ -74,6 +74,8 @@ type UserSpaceMutation { update(input: UserSpaceUpdateInput!): UserSpace delete(id: Int!): Boolean restore(id: Int!): Boolean + + inviteUsers(emails: [String!]!): Boolean } input UserSpaceCreateInput { diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index e23a529..dac85fb 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -1,4 +1,6 @@ from api_graphql.abc.mutation_abc import MutationABC +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 @@ -51,26 +53,32 @@ class Mutation(MutationABC): Permissions.user_spaces_update, Permissions.user_spaces_delete, ], - [self._test], + [lambda ctx: True], ), ) self.add_mutation_type( "group", "Group", - require_any_permission=[ - Permissions.groups_create, - Permissions.groups_update, - Permissions.groups_delete, - ], + require_any=( + [ + Permissions.groups_create, + Permissions.groups_update, + Permissions.groups_delete, + ], + [has_assigned_user_spaces], + ), ) self.add_mutation_type( "shortUrl", "ShortUrl", - require_any_permission=[ - Permissions.short_urls_create, - Permissions.short_urls_update, - Permissions.short_urls_delete, - ], + require_any=( + [ + Permissions.short_urls_create, + Permissions.short_urls_update, + Permissions.short_urls_delete, + ], + [has_assigned_user_spaces], + ), ) self.add_mutation_type( @@ -95,7 +103,3 @@ class Mutation(MutationABC): "privacy", "Privacy", ) - - @staticmethod - async def _test(*args, **kwargs): - return True diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index fc1c2ca..e430e11 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -4,6 +4,7 @@ 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 has_assigned_user_spaces from core.logger import APILogger from core.string import first_to_lower from data.schemas.public.group import Group @@ -26,7 +27,7 @@ class GroupMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.groups_create]) + .with_require_any([Permissions.groups_create], [has_assigned_user_spaces]) ) self.field( @@ -36,7 +37,7 @@ class GroupMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.groups_update]) + .with_require_any([Permissions.groups_update], [has_assigned_user_spaces]) ) self.field( @@ -45,7 +46,7 @@ class GroupMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.groups_delete]) + .with_require_any([Permissions.groups_delete], [has_assigned_user_spaces]) ) self.field( @@ -54,7 +55,7 @@ class GroupMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.groups_delete]) + .with_require_any([Permissions.groups_delete], [has_assigned_user_spaces]) ) @staticmethod @@ -113,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 03102b3..0c537eb 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -3,6 +3,7 @@ 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 has_assigned_user_spaces from core.logger import APILogger from core.string import first_to_lower from data.schemas.public.domain_dao import domainDao @@ -27,7 +28,9 @@ class ShortUrlMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.short_urls_create]) + .with_require_any( + [Permissions.short_urls_create], [has_assigned_user_spaces] + ) ) self.field( @@ -37,7 +40,9 @@ class ShortUrlMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.short_urls_update]) + .with_require_any( + [Permissions.short_urls_update], [has_assigned_user_spaces] + ) ) self.field( @@ -46,7 +51,9 @@ class ShortUrlMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.short_urls_delete]) + .with_require_any( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) self.field( @@ -55,7 +62,9 @@ class ShortUrlMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.short_urls_delete]) + .with_require_any( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) self.field( diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py index 36690db..07c7564 100644 --- a/api/src/api_graphql/mutations/user_space_mutation.py +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -3,6 +3,7 @@ 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 @@ -26,7 +27,6 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_create]) ) self.field( @@ -36,7 +36,10 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_update]) + .with_require_any( + [Permissions.user_spaces_update], + [self._resolve_input_user_space_assigned], + ) ) self.field( @@ -45,7 +48,10 @@ class UserSpaceMutation(MutationABC): .with_change_broadcast( f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) - .with_require_any_permission([Permissions.user_spaces_delete]) + .with_require_any( + [Permissions.user_spaces_delete], + [self._resolve_input_user_space_assigned], + ) ) self.field( @@ -57,6 +63,19 @@ class UserSpaceMutation(MutationABC): .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_create, 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__}") @@ -88,9 +107,7 @@ class UserSpaceMutation(MutationABC): user_space = await userSpaceDao.get_by_id(obj.id) if obj.name is not None: - already_exists = await userSpaceDao.find_by( - {UserSpace.name: obj.name, UserSpace.id: {"ne": obj.id}} - ) + already_exists = await userSpaceDao.find_by({UserSpace.name: obj.name}) if len(already_exists) > 0: raise ValueError(f"UserSpace {obj.name} already exists") @@ -123,3 +140,16 @@ class UserSpaceMutation(MutationABC): user_space = await userSpaceDao.get_by_id(id) await userSpaceDao.restore(user_space) return True + + async def resolve_invite_users(self, emails: list[str], *_): + pass + + @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/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py index cdd1b72..b443df4 100644 --- a/api/src/api_graphql/queries/user_space_query.py +++ b/api/src/api_graphql/queries/user_space_query.py @@ -17,5 +17,5 @@ class UserSpaceQuery(DbModelQueryABC): self.set_history_reference_dao(shortUrlDao, "userspaceid") @staticmethod - async def _get_users(group: UserSpace, *_): - return await userSpaceDao.get(group.id) + async def _get_users(space: UserSpace, *_): + return await userSpaceDao.get_users(space.id) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 657ffc6..461b1ef 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -15,6 +15,8 @@ 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_group_assignment_resolver, + by_user_space_assignment_resolver, + has_assigned_user_spaces, ) from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key_dao import apiKeyDao @@ -57,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], ) ) @@ -107,12 +111,13 @@ class Query(QueryABC): .with_dao(domainDao) .with_filter(DomainFilter) .with_sort(Sort[Domain]) - .with_require_any_permission( + .with_require_any( [ Permissions.domains, Permissions.domains_create, Permissions.domains_update, - ] + ], + [has_assigned_user_spaces] ) ) @@ -127,10 +132,11 @@ class Query(QueryABC): .with_dao(userSpaceDao) .with_filter(UserSpaceFilter) .with_sort(Sort[UserSpace]) - .with_require_any_permission( + .with_require_any( [ Permissions.user_spaces, - ] + ], + [lambda ctx: all(x.owner_id == ctx.user.id for x in ctx.data.nodes)] ) ) @@ -145,7 +151,7 @@ class Query(QueryABC): Permissions.short_urls_create, Permissions.short_urls_update, ], - [by_group_assignment_resolver], + [by_user_space_assignment_resolver, by_group_assignment_resolver], ) ) @@ -156,7 +162,7 @@ class Query(QueryABC): .with_sort(Sort[ShortUrl]) .with_require_any( [Permissions.short_urls], - [by_group_assignment_resolver], + [by_user_space_assignment_resolver, by_group_assignment_resolver], ) ) diff --git a/api/src/api_graphql/require_any_resolvers.py b/api/src/api_graphql/require_any_resolvers.py index 153aaaf..cff57fe 100644 --- a/api/src/api_graphql/require_any_resolvers.py +++ b/api/src/api_graphql/require_any_resolvers.py @@ -1,6 +1,7 @@ from api_graphql.service.collection_result import CollectionResult from api_graphql.service.query_context import QueryContext from data.schemas.public.group_dao import groupDao +from 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 @@ -9,10 +10,13 @@ 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.find_by_user_id(user.id) + us.user_space_id for us in await userSpaceUserDao.get_by_user_id(user.id) } for node in ctx.data.nodes: @@ -23,7 +27,7 @@ async def by_user_space_assignment_resolver(ctx: QueryContext) -> bool: if user_space.owner_id == user.id or user_space.id in assigned_user_space_ids: return True - return False + return len(ctx.data.nodes) == 0 async def by_group_assignment_resolver(ctx: QueryContext) -> bool: @@ -47,3 +51,8 @@ async def by_group_assignment_resolver(ctx: QueryContext) -> bool: ) return False + + +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 6821afd..7b48d64 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -1,5 +1,6 @@ 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 @@ -68,10 +69,10 @@ class Subscription(SubscriptionABC): 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/data/schemas/public/user_space_user_dao.py b/api/src/data/schemas/public/user_space_user_dao.py index 8ed77bc..b9a6bdc 100644 --- a/api/src/data/schemas/public/user_space_user_dao.py +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -10,8 +10,8 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): DbModelDaoABC.__init__( self, __name__, UserSpaceUser, "public.user_spaces_users" ) - self.attribute(UserSpaceUser.user_space_id, str) - self.attribute(UserSpaceUser.user_id, str) + self.attribute(UserSpaceUser.user_space_id, int) + self.attribute(UserSpaceUser.user_id, int) async def get_by_user_space_id( self, user_space_id: str, with_deleted=False diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py index e0d6d82..509c024 100644 --- a/api/src/service/data_privacy_service.py +++ b/api/src/service/data_privacy_service.py @@ -78,6 +78,7 @@ class DataPrivacyService: # Anonymize internal data await user.anonymize() + await userDao.delete(user) # Anonymize external data try: diff --git a/web/src/app/core/guard/permission.guard.ts b/web/src/app/core/guard/permission.guard.ts index fc07bcf..684ee2c 100644 --- a/web/src/app/core/guard/permission.guard.ts +++ b/web/src/app/core/guard/permission.guard.ts @@ -4,6 +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 { SidebarService } from 'src/app/service/sidebar.service'; const log = new Logger('PermissionGuard'); @@ -14,11 +15,20 @@ export class PermissionGuard { constructor( private router: Router, private toast: ToastService, - private auth: AuthService + private auth: AuthService, + private sidebar: SidebarService ) {} async canActivate(route: ActivatedRouteSnapshot): Promise { const permissions = route.data['permissions'] as PermissionsEnum[]; + const isInUserSpace = route.data['isInUserSpace'] as boolean; + + if (isInUserSpace) { + return ( + this.sidebar.selectedUserSpace$.value !== undefined && + this.sidebar.selectedUserSpace$.value !== null + ); + } if (!permissions || permissions.length === 0) { return true; 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 index 4b82c61..365e1f0 100644 --- a/web/src/app/model/entities/user-space.ts +++ b/web/src/app/model/entities/user-space.ts @@ -4,6 +4,7 @@ import { DbModelWithHistory } from 'src/app/model/entities/db-model'; export interface UserSpace extends DbModelWithHistory { id: number; name: string; + owner?: User; users?: User[]; } diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index 5bbc76b..22795b2 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -18,7 +18,7 @@ const routes: Routes = [ m => m.GroupsModule ), canActivate: [PermissionGuard], - data: { permissions: [PermissionsEnum.groups] }, + data: { permissions: [PermissionsEnum.groups], isInUserSpace: true }, }, { path: 'urls', @@ -32,6 +32,7 @@ const routes: Routes = [ PermissionsEnum.shortUrls, PermissionsEnum.shortUrlsByAssignment, ], + isInUserSpace: true, }, }, { diff --git a/web/src/app/modules/admin/groups/groups.module.ts b/web/src/app/modules/admin/groups/groups.module.ts index fb786ad..41b2b64 100644 --- a/web/src/app/modules/admin/groups/groups.module.ts +++ b/web/src/app/modules/admin/groups/groups.module.ts @@ -22,6 +22,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsCreate], + isInUserSpace: true, }, }, { @@ -30,6 +31,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groupsUpdate], + isInUserSpace: true, }, }, { @@ -38,6 +40,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.groups], + isInUserSpace: true, }, }, ], 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 a9553e8..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,6 +22,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsCreate], + isInUserSpace: true, }, }, { @@ -30,6 +31,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrlsUpdate], + isInUserSpace: true, }, }, { @@ -38,6 +40,7 @@ const routes: Routes = [ canActivate: [PermissionGuard], data: { permissions: [PermissionsEnum.shortUrls], + isInUserSpace: true, }, }, ], diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.ts b/web/src/app/modules/admin/short-urls/short-urls.page.ts index 9bfa224..6c7e08a 100644 --- a/web/src/app/modules/admin/short-urls/short-urls.page.ts +++ b/web/src/app/modules/admin/short-urls/short-urls.page.ts @@ -13,6 +13,7 @@ import { ConfigService } from 'src/app/service/config.service'; import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model'; import { SidebarService } from 'src/app/service/sidebar.service'; import { takeUntil } from 'rxjs/operators'; +import { Router } from '@angular/router'; @Component({ selector: 'app-short-urls', @@ -61,7 +62,8 @@ export class ShortUrlsPage extends PageBase< private toast: ToastService, private confirmation: ConfirmationDialogService, private config: ConfigService, - private sidebar: SidebarService + private sidebar: SidebarService, + private router: Router ) { super(true, { read: [], 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 index d9bc903..a12e9e2 100644 --- 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 @@ -27,5 +27,23 @@ type="text" formControlName="name"/> +
+
+

{{ 'user_space.assign_users' | translate }}

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

{{ 'user_space.invite_users' | translate }}

+
+ +
+
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 index 7a5fdf7..77692d7 100644 --- 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 @@ -8,6 +8,8 @@ import { UserSpaceUpdateInput, } from 'src/app/model/entities/user-space'; import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; +import { User } from 'src/app/model/auth/user'; +import { catchError } from 'rxjs/operators'; @Component({ selector: 'app-user-space-form-page', @@ -45,6 +47,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< name: new FormControl(undefined, [ Validators.required, ]), + emailsToInvite: new FormControl([]), }); this.form.controls['id'].disable(); } @@ -59,6 +62,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< getCreateInput(): UserSpaceCreateInput { return { name: this.form.controls['name'].value ?? undefined, + users: this.node.users?.map(x => x.id) ?? [], }; } @@ -69,11 +73,36 @@ export class UserSpaceFormPageComponent extends FormPageBase< return { id: this.form.controls['id'].value, - name: this.form.controls['name'].value ?? undefined, + name: this.form.controls['name'].pristine + ? undefined + : this.form.controls['name'].value, + users: this.node.users?.map(x => x.id) ?? [], }; } + protected handleEMailInvitation() { + const emailsToInvite = this.form.controls['emailsToInvite'].value; + if (!(emailsToInvite && emailsToInvite.length > 0)) { + return; + } + + this.dataService + .inviteUsers(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.handleEMailInvitation(); this.dataService.create(object).subscribe(() => { this.spinner.hide(); this.toast.success('action.created'); @@ -82,6 +111,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< } update(object: UserSpaceUpdateInput): void { + this.handleEMailInvitation(); this.dataService.update(object).subscribe(() => { this.spinner.hide(); this.toast.success('action.created'); 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 index d78ce6e..8e1dc0f 100644 --- 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 @@ -57,6 +57,10 @@ export class UserSpacesDataService nodes { id name + owner { + id + username + } ...DB_MODEL } @@ -85,6 +89,16 @@ export class UserSpacesDataService id name + owner { + id + username + } + + users { + id + username + } + ...DB_MODEL } } @@ -276,6 +290,29 @@ export class UserSpacesDataService .pipe(map(result => result.data?.userSpace.restore ?? false)); } + inviteUsers(emails: string[]): Observable { + return this.apollo + .mutate<{ userSpace: { inviteUsers: boolean } }>({ + mutation: gql` + mutation inviteUsers($emails: [String!]!) { + userSpace { + inviteUsers(emails: $emails) + } + } + `, + variables: { + emails, + }, + }) + .pipe( + catchError(err => { + this.spinner.hide(); + throw err; + }) + ) + .pipe(map(result => result.data?.userSpace.inviteUsers ?? false)); + } + static provide(): Provider[] { return [ { 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..419f281 --- /dev/null +++ b/web/src/app/modules/shared/components/chip-input/chip-input.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChipInputComponent } from './chip-input.component'; + +describe('ChipInputComponent', () => { + let component: ChipInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ChipInputComponent] + }) + .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/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/sidebar.data.service.ts b/web/src/app/service/sidebar.data.service.ts index be3dd89..747f5dd 100644 --- a/web/src/app/service/sidebar.data.service.ts +++ b/web/src/app/service/sidebar.data.service.ts @@ -24,6 +24,11 @@ export class SidebarDataService { nodes { id name + + owner { + id + username + } } } } diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts index 78d73dc..d6647a4 100644 --- a/web/src/app/service/sidebar.service.ts +++ b/web/src/app/service/sidebar.service.ts @@ -72,6 +72,9 @@ 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: 'sidebar.user_spaces', @@ -108,6 +111,7 @@ export class SidebarService { { label: 'sidebar.user_space_edit', icon: 'pi pi-pencil', + visible: isSelectedUserSpaceOwner, routerLink: [ `/admin/rooms/edit/${this.selectedUserSpace$.value?.id}`, ], @@ -117,7 +121,7 @@ export class SidebarService { icon: 'pi pi-tags', routerLink: ['/admin/groups'], visible: - this.selectedUserSpace$.value !== null && + !!this.selectedUserSpace$.value || (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])), }, { @@ -125,7 +129,7 @@ export class SidebarService { icon: 'pi pi-tag', routerLink: ['/admin/urls'], visible: - this.selectedUserSpace$.value !== null && + !!this.selectedUserSpace$.value || (await this.auth.hasAnyPermissionLazy([ PermissionsEnum.shortUrls, PermissionsEnum.shortUrlsByAssignment, @@ -134,6 +138,7 @@ export class SidebarService { { label: 'sidebar.user_space_delete', icon: 'pi pi-trash', + visible: isSelectedUserSpaceOwner, command: () => { this.confirmation.confirmDialog({ header: 'dialog.delete.header', diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index e76cf8b..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", @@ -270,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 3bbe59b..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", @@ -270,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; }