Compare commits

...

1 Commits

Author SHA1 Message Date
b815710dd6 Manage user space users #15
Some checks failed
Test API before pr merge / test-lint (pull_request) Failing after 10s
Test before pr merge / test-translation-lint (pull_request) Successful in 36s
Test before pr merge / test-lint (pull_request) Failing after 39s
Test before pr merge / test-before-merge (pull_request) Failing after 1m28s
2025-05-01 17:38:52 +02:00
36 changed files with 362 additions and 60 deletions

View File

@ -1,5 +1,5 @@
from abc import abstractmethod 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.abc.query_abc import QueryABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder

View File

@ -240,6 +240,18 @@ class QueryABC(ObjectType):
): ):
await self._require_any_permission(field.require_any_permission) 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) result = await resolver(*args, **kwargs)
if field.require_any is not None: if field.require_any is not None:

View File

@ -60,7 +60,7 @@ class MutationFieldBuilder(FieldBuilderABC):
def with_input( def with_input(
self, self,
input_type: Type[Union[InputABC, str, int, bool]], input_type: Type[Union[InputABC, str, int, bool, list]],
input_key: str = "input", input_key: str = "input",
) -> Self: ) -> Self:
self._input_type = input_type self._input_type = input_type

View File

@ -74,6 +74,8 @@ type UserSpaceMutation {
update(input: UserSpaceUpdateInput!): UserSpace update(input: UserSpaceUpdateInput!): UserSpace
delete(id: Int!): Boolean delete(id: Int!): Boolean
restore(id: Int!): Boolean restore(id: Int!): Boolean
inviteUsers(emails: [String!]!): Boolean
} }
input UserSpaceCreateInput { input UserSpaceCreateInput {

View File

@ -1,4 +1,6 @@
from api_graphql.abc.mutation_abc import MutationABC 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 from service.permission.permissions_enum import Permissions
@ -51,26 +53,32 @@ class Mutation(MutationABC):
Permissions.user_spaces_update, Permissions.user_spaces_update,
Permissions.user_spaces_delete, Permissions.user_spaces_delete,
], ],
[self._test], [lambda ctx: True],
), ),
) )
self.add_mutation_type( self.add_mutation_type(
"group", "group",
"Group", "Group",
require_any_permission=[ require_any=(
Permissions.groups_create, [
Permissions.groups_update, Permissions.groups_create,
Permissions.groups_delete, Permissions.groups_update,
], Permissions.groups_delete,
],
[has_assigned_user_spaces],
),
) )
self.add_mutation_type( self.add_mutation_type(
"shortUrl", "shortUrl",
"ShortUrl", "ShortUrl",
require_any_permission=[ require_any=(
Permissions.short_urls_create, [
Permissions.short_urls_update, Permissions.short_urls_create,
Permissions.short_urls_delete, Permissions.short_urls_update,
], Permissions.short_urls_delete,
],
[has_assigned_user_spaces],
),
) )
self.add_mutation_type( self.add_mutation_type(
@ -95,7 +103,3 @@ class Mutation(MutationABC):
"privacy", "privacy",
"Privacy", "Privacy",
) )
@staticmethod
async def _test(*args, **kwargs):
return True

View File

@ -4,6 +4,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_create_input import GroupCreateInput
from api_graphql.input.group_update_input import GroupUpdateInput from api_graphql.input.group_update_input import GroupUpdateInput
from api_graphql.require_any_resolvers import has_assigned_user_spaces
from core.logger import APILogger from core.logger import APILogger
from core.string import first_to_lower from core.string import first_to_lower
from data.schemas.public.group import Group from data.schemas.public.group import Group
@ -26,7 +27,7 @@ class GroupMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -36,7 +37,7 @@ class GroupMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -45,7 +46,7 @@ class GroupMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -54,7 +55,7 @@ class GroupMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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 @staticmethod
@ -113,9 +114,7 @@ class GroupMutation(MutationABC):
raise ValueError(f"Group with id {obj.id} not found") raise ValueError(f"Group with id {obj.id} not found")
if obj.name is not None: if obj.name is not None:
already_exists = await groupDao.find_by( already_exists = await groupDao.find_by({Group.name: obj.name})
{Group.name: obj.name, Group.id: {"ne": obj.id}}
)
if len(already_exists) > 0: if len(already_exists) > 0:
raise ValueError(f"Group {obj.name} already exists") raise ValueError(f"Group {obj.name} already exists")

View File

@ -3,6 +3,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_create_input import ShortUrlCreateInput
from api_graphql.input.short_url_update_input import ShortUrlUpdateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput
from api_graphql.require_any_resolvers import has_assigned_user_spaces
from core.logger import APILogger from core.logger import APILogger
from core.string import first_to_lower from core.string import first_to_lower
from data.schemas.public.domain_dao import domainDao from data.schemas.public.domain_dao import domainDao
@ -27,7 +28,9 @@ class ShortUrlMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -37,7 +40,9 @@ class ShortUrlMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -46,7 +51,9 @@ class ShortUrlMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -55,7 +62,9 @@ class ShortUrlMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(

View File

@ -3,6 +3,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.input.user_space_create_input import UserSpaceCreateInput from api_graphql.input.user_space_create_input import UserSpaceCreateInput
from api_graphql.input.user_space_update_input import UserSpaceUpdateInput 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.logger import APILogger
from core.string import first_to_lower from core.string import first_to_lower
from data.schemas.administration.user_dao import userDao from data.schemas.administration.user_dao import userDao
@ -26,7 +27,6 @@ class UserSpaceMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
) )
.with_require_any_permission([Permissions.user_spaces_create])
) )
self.field( self.field(
@ -36,7 +36,10 @@ class UserSpaceMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -45,7 +48,10 @@ class UserSpaceMutation(MutationABC):
.with_change_broadcast( .with_change_broadcast(
f"{first_to_lower(self.name.replace("Mutation", ""))}Change" 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( self.field(
@ -57,6 +63,19 @@ class UserSpaceMutation(MutationABC):
.with_require_any_permission([Permissions.user_spaces_delete]) .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, *_): async def resolve_create(self, obj: UserSpaceCreateInput, *_):
logger.debug(f"create user_space: {obj.__dict__}") logger.debug(f"create user_space: {obj.__dict__}")
@ -88,9 +107,7 @@ class UserSpaceMutation(MutationABC):
user_space = await userSpaceDao.get_by_id(obj.id) user_space = await userSpaceDao.get_by_id(obj.id)
if obj.name is not None: if obj.name is not None:
already_exists = await userSpaceDao.find_by( already_exists = await userSpaceDao.find_by({UserSpace.name: obj.name})
{UserSpace.name: obj.name, UserSpace.id: {"ne": obj.id}}
)
if len(already_exists) > 0: if len(already_exists) > 0:
raise ValueError(f"UserSpace {obj.name} already exists") raise ValueError(f"UserSpace {obj.name} already exists")
@ -123,3 +140,16 @@ class UserSpaceMutation(MutationABC):
user_space = await userSpaceDao.get_by_id(id) user_space = await userSpaceDao.get_by_id(id)
await userSpaceDao.restore(user_space) await userSpaceDao.restore(user_space)
return True 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)
]

View File

@ -17,5 +17,5 @@ class UserSpaceQuery(DbModelQueryABC):
self.set_history_reference_dao(shortUrlDao, "userspaceid") self.set_history_reference_dao(shortUrlDao, "userspaceid")
@staticmethod @staticmethod
async def _get_users(group: UserSpace, *_): async def _get_users(space: UserSpace, *_):
return await userSpaceDao.get(group.id) return await userSpaceDao.get_users(space.id)

View File

@ -15,6 +15,8 @@ from api_graphql.filter.user_filter import UserFilter
from api_graphql.filter.user_space_filter import UserSpaceFilter from api_graphql.filter.user_space_filter import UserSpaceFilter
from api_graphql.require_any_resolvers import ( from api_graphql.require_any_resolvers import (
by_group_assignment_resolver, 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 import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.api_key_dao import apiKeyDao
@ -57,19 +59,21 @@ class Query(QueryABC):
.with_filter(PermissionFilter) .with_filter(PermissionFilter)
.with_sort(Sort[Permission]) .with_sort(Sort[Permission])
) )
self.field( self.field(
DaoFieldBuilder("roles") DaoFieldBuilder("roles")
.with_dao(roleDao) .with_dao(roleDao)
.with_filter(RoleFilter) .with_filter(RoleFilter)
.with_sort(Sort[Role]) .with_sort(Sort[Role])
.with_require_any_permission( .with_require_any(
[ [
Permissions.roles, Permissions.roles,
Permissions.users_create, Permissions.users_create,
Permissions.users_update, Permissions.users_update,
Permissions.groups_create, Permissions.groups_create,
Permissions.groups_update, Permissions.groups_update,
] ],
[has_assigned_user_spaces],
) )
) )
@ -107,12 +111,13 @@ class Query(QueryABC):
.with_dao(domainDao) .with_dao(domainDao)
.with_filter(DomainFilter) .with_filter(DomainFilter)
.with_sort(Sort[Domain]) .with_sort(Sort[Domain])
.with_require_any_permission( .with_require_any(
[ [
Permissions.domains, Permissions.domains,
Permissions.domains_create, Permissions.domains_create,
Permissions.domains_update, Permissions.domains_update,
] ],
[has_assigned_user_spaces]
) )
) )
@ -127,10 +132,11 @@ class Query(QueryABC):
.with_dao(userSpaceDao) .with_dao(userSpaceDao)
.with_filter(UserSpaceFilter) .with_filter(UserSpaceFilter)
.with_sort(Sort[UserSpace]) .with_sort(Sort[UserSpace])
.with_require_any_permission( .with_require_any(
[ [
Permissions.user_spaces, 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_create,
Permissions.short_urls_update, 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_sort(Sort[ShortUrl])
.with_require_any( .with_require_any(
[Permissions.short_urls], [Permissions.short_urls],
[by_group_assignment_resolver], [by_user_space_assignment_resolver, by_group_assignment_resolver],
) )
) )

View File

@ -1,6 +1,7 @@
from api_graphql.service.collection_result import CollectionResult from api_graphql.service.collection_result import CollectionResult
from api_graphql.service.query_context import QueryContext from api_graphql.service.query_context import QueryContext
from data.schemas.public.group_dao import groupDao 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 data.schemas.public.user_space_user_dao import userSpaceUserDao
from service.permission.permissions_enum import Permissions 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): if not isinstance(ctx.data, CollectionResult):
return False return False
if len(ctx.data.nodes) == 0:
return True
user = ctx.user user = ctx.user
assigned_user_space_ids = { 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: 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: if user_space.owner_id == user.id or user_space.id in assigned_user_space_ids:
return True return True
return False return len(ctx.data.nodes) == 0
async def by_group_assignment_resolver(ctx: QueryContext) -> bool: async def by_group_assignment_resolver(ctx: QueryContext) -> bool:
@ -47,3 +51,8 @@ async def by_group_assignment_resolver(ctx: QueryContext) -> bool:
) )
return False 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

View File

@ -1,5 +1,6 @@
from api_graphql.abc.subscription_abc import SubscriptionABC from api_graphql.abc.subscription_abc import SubscriptionABC
from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder 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 from service.permission.permissions_enum import Permissions
@ -68,10 +69,10 @@ class Subscription(SubscriptionABC):
self.subscribe( self.subscribe(
SubscriptionFieldBuilder("groupChange") SubscriptionFieldBuilder("groupChange")
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)
.with_require_any_permission([Permissions.groups]) .with_require_any([Permissions.groups], [has_assigned_user_spaces])
) )
self.subscribe( self.subscribe(
SubscriptionFieldBuilder("shortUrlChange") SubscriptionFieldBuilder("shortUrlChange")
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)
.with_require_any_permission([Permissions.short_urls]) .with_require_any([Permissions.short_urls], [has_assigned_user_spaces])
) )

View File

@ -10,8 +10,8 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]):
DbModelDaoABC.__init__( DbModelDaoABC.__init__(
self, __name__, UserSpaceUser, "public.user_spaces_users" self, __name__, UserSpaceUser, "public.user_spaces_users"
) )
self.attribute(UserSpaceUser.user_space_id, str) self.attribute(UserSpaceUser.user_space_id, int)
self.attribute(UserSpaceUser.user_id, str) self.attribute(UserSpaceUser.user_id, int)
async def get_by_user_space_id( async def get_by_user_space_id(
self, user_space_id: str, with_deleted=False self, user_space_id: str, with_deleted=False

View File

@ -78,6 +78,7 @@ class DataPrivacyService:
# Anonymize internal data # Anonymize internal data
await user.anonymize() await user.anonymize()
await userDao.delete(user)
# Anonymize external data # Anonymize external data
try: try:

View File

@ -4,6 +4,7 @@ import { Logger } from 'src/app/service/logger.service';
import { ToastService } from 'src/app/service/toast.service'; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from 'src/app/service/auth.service'; import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { SidebarService } from 'src/app/service/sidebar.service';
const log = new Logger('PermissionGuard'); const log = new Logger('PermissionGuard');
@ -14,11 +15,20 @@ export class PermissionGuard {
constructor( constructor(
private router: Router, private router: Router,
private toast: ToastService, private toast: ToastService,
private auth: AuthService private auth: AuthService,
private sidebar: SidebarService
) {} ) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> { async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data['permissions'] as PermissionsEnum[]; const permissions = route.data['permissions'] as PermissionsEnum[];
const isInUserSpace = route.data['isInUserSpace'] as boolean;
if (isInUserSpace) {
return (
this.sidebar.selectedUserSpace$.value !== undefined &&
this.sidebar.selectedUserSpace$.value !== null
);
}
if (!permissions || permissions.length === 0) { if (!permissions || permissions.length === 0) {
return true; return true;

View File

@ -13,6 +13,7 @@ export function initializeKeycloak(
}, },
initOptions: { initOptions: {
onLoad: 'check-sso', onLoad: 'check-sso',
checkLoginIframe: false,
}, },
enableBearerInterceptor: false, enableBearerInterceptor: false,
}); });

View File

@ -20,6 +20,7 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
return next(req); return next(req);
} }
const auth = inject(AuthService);
return from(keycloak.getToken()).pipe( return from(keycloak.getToken()).pipe(
switchMap(token => { switchMap(token => {
if (!token) { if (!token) {
@ -46,7 +47,6 @@ export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
); );
}), }),
catchError(() => { catchError(() => {
const auth = inject(AuthService);
auth.logout().then(); auth.logout().then();
return next(req); return next(req);
}) })

View File

@ -4,6 +4,7 @@ import { DbModelWithHistory } from 'src/app/model/entities/db-model';
export interface UserSpace extends DbModelWithHistory { export interface UserSpace extends DbModelWithHistory {
id: number; id: number;
name: string; name: string;
owner?: User;
users?: User[]; users?: User[];
} }

View File

@ -18,7 +18,7 @@ const routes: Routes = [
m => m.GroupsModule m => m.GroupsModule
), ),
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permissions: [PermissionsEnum.groups] }, data: { permissions: [PermissionsEnum.groups], isInUserSpace: true },
}, },
{ {
path: 'urls', path: 'urls',
@ -32,6 +32,7 @@ const routes: Routes = [
PermissionsEnum.shortUrls, PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment, PermissionsEnum.shortUrlsByAssignment,
], ],
isInUserSpace: true,
}, },
}, },
{ {

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import { ConfigService } from 'src/app/service/config.service';
import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model'; import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model';
import { SidebarService } from 'src/app/service/sidebar.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'app-short-urls', selector: 'app-short-urls',
@ -61,7 +62,8 @@ export class ShortUrlsPage extends PageBase<
private toast: ToastService, private toast: ToastService,
private confirmation: ConfirmationDialogService, private confirmation: ConfirmationDialogService,
private config: ConfigService, private config: ConfigService,
private sidebar: SidebarService private sidebar: SidebarService,
private router: Router
) { ) {
super(true, { super(true, {
read: [], read: [],

View File

@ -27,5 +27,23 @@
type="text" type="text"
formControlName="name"/> formControlName="name"/>
</div> </div>
<div class="divider"></div>
<div class="flex flex-col gap-2">
<p class="font-bold label">{{ 'user_space.assign_users' | translate }}</p>
<div *ngIf="node.users && node.users.length > 0" class="flex flex-wrap gap-1">
<div *ngFor="let user of node.users">
<p-chip [label]="user.username" [removable]="true" (onRemove)="node.users.splice(node.users.indexOf(user), 1)"></p-chip>
</div>
</div>
<div *ngIf="node.users?.length === 0">
<span>{{ 'table.no_entries_found' | translate }}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="font-bold label">{{ 'user_space.invite_users' | translate }}</p>
<div>
<app-chip-input formControlName="emailsToInvite" type="email" placeholder="{{'common.enter_emails' | translate}}"/>
</div>
</div>
</ng-template> </ng-template>
</app-form-page> </app-form-page>

View File

@ -8,6 +8,8 @@ import {
UserSpaceUpdateInput, UserSpaceUpdateInput,
} from 'src/app/model/entities/user-space'; } from 'src/app/model/entities/user-space';
import { UserSpacesDataService } from 'src/app/modules/admin/user-spaces/user-spaces.data.service'; 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({ @Component({
selector: 'app-user-space-form-page', selector: 'app-user-space-form-page',
@ -45,6 +47,7 @@ export class UserSpaceFormPageComponent extends FormPageBase<
name: new FormControl<string | undefined>(undefined, [ name: new FormControl<string | undefined>(undefined, [
Validators.required, Validators.required,
]), ]),
emailsToInvite: new FormControl<string[]>([]),
}); });
this.form.controls['id'].disable(); this.form.controls['id'].disable();
} }
@ -59,6 +62,7 @@ export class UserSpaceFormPageComponent extends FormPageBase<
getCreateInput(): UserSpaceCreateInput { getCreateInput(): UserSpaceCreateInput {
return { return {
name: this.form.controls['name'].value ?? undefined, 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 { return {
id: this.form.controls['id'].value, 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 { create(object: UserSpaceCreateInput): void {
this.handleEMailInvitation();
this.dataService.create(object).subscribe(() => { this.dataService.create(object).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success('action.created'); this.toast.success('action.created');
@ -82,6 +111,7 @@ export class UserSpaceFormPageComponent extends FormPageBase<
} }
update(object: UserSpaceUpdateInput): void { update(object: UserSpaceUpdateInput): void {
this.handleEMailInvitation();
this.dataService.update(object).subscribe(() => { this.dataService.update(object).subscribe(() => {
this.spinner.hide(); this.spinner.hide();
this.toast.success('action.created'); this.toast.success('action.created');

View File

@ -57,6 +57,10 @@ export class UserSpacesDataService
nodes { nodes {
id id
name name
owner {
id
username
}
...DB_MODEL ...DB_MODEL
} }
@ -85,6 +89,16 @@ export class UserSpacesDataService
id id
name name
owner {
id
username
}
users {
id
username
}
...DB_MODEL ...DB_MODEL
} }
} }
@ -276,6 +290,29 @@ export class UserSpacesDataService
.pipe(map(result => result.data?.userSpace.restore ?? false)); .pipe(map(result => result.data?.userSpace.restore ?? false));
} }
inviteUsers(emails: string[]): Observable<boolean> {
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[] { static provide(): Provider[] {
return [ return [
{ {

View File

@ -0,0 +1,15 @@
<div class="flex flex-wrap items-center input rounded-md p-2.5">
<div class="flex flex-wrap items-center flex-grow gap-1">
<p-chip *ngFor="let chip of chips; let i = index" [label]="chip" [removable]="true" (onRemove)="onChange(chips)"/>
<input
pInputText
[type]="type"
[(ngModel)]="inputValue"
(keydown.enter)="addChip()"
(blur)="onTouched()"
class="no-border flex-grow border-0 p-0"
[placeholder]="placeholder"
/>
</div>
</div>

View File

@ -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<ChipInputComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ChipInputComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ChipInputComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -72,6 +72,7 @@ import { getMainDefinition } from '@apollo/client/utilities';
import { Kind, OperationTypeNode } from 'graphql/index'; import { Kind, OperationTypeNode } from 'graphql/index';
import { HistorySidebarComponent } from 'src/app/modules/shared/components/history/history-sidebar.component'; import { HistorySidebarComponent } from 'src/app/modules/shared/components/history/history-sidebar.component';
import { SliderModule } from 'primeng/slider'; import { SliderModule } from 'primeng/slider';
import { ChipInputComponent } from './components/chip-input/chip-input.component';
const sharedModules = [ const sharedModules = [
StepsModule, StepsModule,
@ -146,9 +147,9 @@ function debounce(func: (...args: unknown[]) => void, wait: number) {
} }
@NgModule({ @NgModule({
declarations: [...sharedComponents], declarations: [...sharedComponents, ChipInputComponent],
imports: [CommonModule, ...sharedModules], imports: [CommonModule, ...sharedModules],
exports: [...sharedModules, ...sharedComponents], exports: [...sharedModules, ...sharedComponents, ChipInputComponent],
providers: [ providers: [
provideHttpClient(withInterceptors([tokenInterceptor])), provideHttpClient(withInterceptors([tokenInterceptor])),
provideApollo(() => { provideApollo(() => {

View File

@ -24,6 +24,11 @@ export class SidebarDataService {
nodes { nodes {
id id
name name
owner {
id
username
}
} }
} }
} }

View File

@ -72,6 +72,9 @@ export class SidebarService {
// trust me, you'll need this async // trust me, you'll need this async
async setElements() { async setElements() {
const isSelectedUserSpaceOwner =
this.selectedUserSpace$.value?.owner?.id === this.auth.user$.value?.id;
const elements: MenuElement[] = [ const elements: MenuElement[] = [
{ {
label: 'sidebar.user_spaces', label: 'sidebar.user_spaces',
@ -108,6 +111,7 @@ export class SidebarService {
{ {
label: 'sidebar.user_space_edit', label: 'sidebar.user_space_edit',
icon: 'pi pi-pencil', icon: 'pi pi-pencil',
visible: isSelectedUserSpaceOwner,
routerLink: [ routerLink: [
`/admin/rooms/edit/${this.selectedUserSpace$.value?.id}`, `/admin/rooms/edit/${this.selectedUserSpace$.value?.id}`,
], ],
@ -117,7 +121,7 @@ export class SidebarService {
icon: 'pi pi-tags', icon: 'pi pi-tags',
routerLink: ['/admin/groups'], routerLink: ['/admin/groups'],
visible: visible:
this.selectedUserSpace$.value !== null && !!this.selectedUserSpace$.value ||
(await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])), (await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])),
}, },
{ {
@ -125,7 +129,7 @@ export class SidebarService {
icon: 'pi pi-tag', icon: 'pi pi-tag',
routerLink: ['/admin/urls'], routerLink: ['/admin/urls'],
visible: visible:
this.selectedUserSpace$.value !== null && !!this.selectedUserSpace$.value ||
(await this.auth.hasAnyPermissionLazy([ (await this.auth.hasAnyPermissionLazy([
PermissionsEnum.shortUrls, PermissionsEnum.shortUrls,
PermissionsEnum.shortUrlsByAssignment, PermissionsEnum.shortUrlsByAssignment,
@ -134,6 +138,7 @@ export class SidebarService {
{ {
label: 'sidebar.user_space_delete', label: 'sidebar.user_space_delete',
icon: 'pi pi-trash', icon: 'pi pi-trash',
visible: isSelectedUserSpaceOwner,
command: () => { command: () => {
this.confirmation.confirmDialog({ this.confirmation.confirmDialog({
header: 'dialog.delete.header', header: 'dialog.delete.header',

View File

@ -2,6 +2,7 @@
"action": { "action": {
"created": "Erstellt", "created": "Erstellt",
"deleted": "Gelöscht", "deleted": "Gelöscht",
"failed": "Fehlgeschlagen",
"restored": "Wiederhergestellt", "restored": "Wiederhergestellt",
"updated": "Geändert" "updated": "Geändert"
}, },
@ -28,6 +29,7 @@
"domains": "Domains", "domains": "Domains",
"download": "Herunterladen", "download": "Herunterladen",
"editor": "Bearbeiter", "editor": "Bearbeiter",
"enter_emails": "E-Mails eintragen",
"error": "Fehler", "error": "Fehler",
"group": "Gruppe", "group": "Gruppe",
"groups": "Gruppen", "groups": "Gruppen",
@ -270,5 +272,10 @@
"keycloak_id": "Keycloak Id", "keycloak_id": "Keycloak Id",
"user": "Benutzer", "user": "Benutzer",
"username": "Benutzername" "username": "Benutzername"
},
"user_space": {
"assign_users": "Benutzer",
"invite_users": "Benutzer einladen",
"invited_users": "Benutzer eingeladen"
} }
} }

View File

@ -2,6 +2,7 @@
"action": { "action": {
"created": "Created", "created": "Created",
"deleted": "Deleted", "deleted": "Deleted",
"failed": "Failed",
"restored": "Recovered", "restored": "Recovered",
"updated": "Updated" "updated": "Updated"
}, },
@ -28,6 +29,7 @@
"domains": "Domains", "domains": "Domains",
"download": "Download", "download": "Download",
"editor": "Editor", "editor": "Editor",
"enter_emails": "Enter E-Mails",
"error": "Fehler", "error": "Fehler",
"group": "Group", "group": "Group",
"groups": "Groups", "groups": "Groups",
@ -270,5 +272,10 @@
"keycloak_id": "Keycloak Id", "keycloak_id": "Keycloak Id",
"user": "User", "user": "User",
"username": "Username" "username": "Username"
},
"user_space": {
"assign_users": "Users",
"invite_users": "Invite users",
"invited_users": "Invited users"
} }
} }

View File

@ -68,6 +68,11 @@ body {
padding: 0; padding: 0;
} }
} }
.no-border {
border: 0 !important;
outline: none !important;
}
} }
header { header {

View File

@ -70,7 +70,7 @@
color: $headerColor; color: $headerColor;
} }
input, .p-checkbox-box, .p-dropdown { input, .input, .p-checkbox-box, .p-dropdown {
border: 1px solid $accentColor; border: 1px solid $accentColor;
} }