From 3f7a3983923dfce12efb4d8559683636c981cc76 Mon Sep 17 00:00:00 2001 From: edraft Date: Thu, 1 May 2025 21:03:40 +0200 Subject: [PATCH] Invite users #15 --- api/src/api/routes/invitation.py | 33 ++++++++++++ api/src/api_graphql/graphql/user_space.gql | 2 +- .../mutations/user_space_mutation.py | 53 +++++++++++++++++-- .../api_graphql/queries/user_space_query.py | 2 +- api/src/api_graphql/subscription.py | 2 +- .../data/schemas/public/user_invitation.py | 51 ++++++++++++++++++ .../schemas/public/user_invitation_dao.py | 16 ++++++ api/src/data/schemas/public/user_space_dao.py | 3 +- .../data/schemas/public/user_space_user.py | 10 ++++ .../schemas/public/user_space_user_dao.py | 8 ++- ...25-05-01-17-40-user-spaces2-invitation.sql | 5 ++ ...17-40-user-spaces2-new-user-invitation.sql | 23 ++++++++ .../administration/users/users.columns.ts | 2 + .../user-space-form-page.component.ts | 7 ++- .../user-spaces/user-spaces.data.service.ts | 7 +-- 15 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 api/src/api/routes/invitation.py create mode 100644 api/src/data/schemas/public/user_invitation.py create mode 100644 api/src/data/schemas/public/user_invitation_dao.py create mode 100644 api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql create mode 100644 api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql diff --git a/api/src/api/routes/invitation.py b/api/src/api/routes/invitation.py new file mode 100644 index 0000000..3b36434 --- /dev/null +++ b/api/src/api/routes/invitation.py @@ -0,0 +1,33 @@ +from starlette.requests import Request +from starlette.responses import RedirectResponse, JSONResponse + +from api.route import Route +from core.environment import Environment +from core.logger import Logger +from data.schemas.public.user_space_user import UserSpaceUser +from data.schemas.public.user_space_user_dao import userSpaceUserDao + +BasePath = f"/invitation" +logger = Logger(__name__) + + +@Route.get(f"{BasePath}/accept/{{invitation_id:path}}") +async def accept_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."}) + + user_space_user.invitation = None + await userSpaceUserDao.update(user_space_user) + + client_urls = Environment.get("CLIENT_URLS", str) + if client_urls is None: + return JSONResponse( + {"message": "Invitation accepted, but no client URLs configured."} + ) + + return RedirectResponse(client_urls.split(",")[0], status_code=303) diff --git a/api/src/api_graphql/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql index d86cc5c..dafada4 100644 --- a/api/src/api_graphql/graphql/user_space.gql +++ b/api/src/api_graphql/graphql/user_space.gql @@ -75,7 +75,7 @@ type UserSpaceMutation { delete(id: Int!): Boolean restore(id: Int!): Boolean - inviteUsers(emails: [String!]!): Boolean + inviteUsers(id: Int!, emails: [String!]!): Boolean } input UserSpaceCreateInput { diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py index 07c7564..0bd6956 100644 --- a/api/src/api_graphql/mutations/user_space_mutation.py +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from api.route import Route from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder @@ -7,6 +9,8 @@ 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 @@ -71,7 +75,7 @@ class UserSpaceMutation(MutationABC): f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) .with_require_any( - [Permissions.user_spaces_create, Permissions.user_spaces_update], + [Permissions.user_spaces_update], [self._resolve_input_user_space_assigned], ) ) @@ -141,8 +145,51 @@ class UserSpaceMutation(MutationABC): await userSpaceDao.restore(user_space) return True - async def resolve_invite_users(self, emails: list[str], *_): - pass + @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 = {} + + 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, + ) + ) + continue + + email_invitations[email] = invitation + await userSpaceUserDao.create( + UserSpaceUser(0, user_space.id, user.id, str(invitation)) + ) + + # todo: send actual emails + + return True @staticmethod async def _resolve_input_user_space_assigned(ctx: QueryContext): diff --git a/api/src/api_graphql/queries/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py index b443df4..e022232 100644 --- a/api/src/api_graphql/queries/user_space_query.py +++ b/api/src/api_graphql/queries/user_space_query.py @@ -18,4 +18,4 @@ class UserSpaceQuery(DbModelQueryABC): @staticmethod async def _get_users(space: UserSpace, *_): - return await userSpaceDao.get_users(space.id) + return await userSpaceDao.get_users(space.id, with_invitations=False) diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index 7b48d64..c4fb980 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -64,7 +64,7 @@ class Subscription(SubscriptionABC): self.subscribe( SubscriptionFieldBuilder("userSpaceChange").with_resolver( lambda message, *_: message.message - ) + ).with_public(True) ) self.subscribe( SubscriptionFieldBuilder("groupChange") 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_dao.py b/api/src/data/schemas/public/user_space_dao.py index aedac49..fd8c818 100644 --- a/api/src/data/schemas/public/user_space_dao.py +++ b/api/src/data/schemas/public/user_space_dao.py @@ -17,7 +17,7 @@ class UserSpaceDao(DbModelDaoABC[UserSpace]): ) return self.to_object(result[0]) - async def get_users(self, user_space_id: int) -> list: + async def get_users(self, user_space_id: int, with_invitations=True) -> list: result = await self._db.select_map( f""" SELECT u.* @@ -25,6 +25,7 @@ class UserSpaceDao(DbModelDaoABC[UserSpace]): 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'} """ ) diff --git a/api/src/data/schemas/public/user_space_user.py b/api/src/data/schemas/public/user_space_user.py index 6c093e6..5c9fa39 100644 --- a/api/src/data/schemas/public/user_space_user.py +++ b/api/src/data/schemas/public/user_space_user.py @@ -14,6 +14,7 @@ class UserSpaceUser(DbJoinModelABC): 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, @@ -24,6 +25,7 @@ class UserSpaceUser(DbJoinModelABC): ) self._user_space_id = user_space_id self._user_id = user_id + self._invitation = invitation @property def user_space_id(self) -> SerialId: @@ -44,3 +46,11 @@ class UserSpaceUser(DbJoinModelABC): 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 index b9a6bdc..280b8b6 100644 --- a/api/src/data/schemas/public/user_space_user_dao.py +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -12,11 +12,15 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): ) 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}] + f = [ + {UserSpaceUser.user_space_id: user_space_id}, + {UserSpaceUser.invitation: None}, + ] if not with_deleted: f.append({UserSpaceUser.deleted: False}) @@ -25,7 +29,7 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): async def get_by_user_id( self, user_id: str, with_deleted=False ) -> list[UserSpaceUser]: - f = [{UserSpaceUser.user_id: user_id}] + f = [{UserSpaceUser.user_id: user_id}, {UserSpaceUser.invitation: None}] if not with_deleted: f.append({UserSpaceUser.deleted: False}) 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/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/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 77692d7..c18855b 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 @@ -80,14 +80,14 @@ export class UserSpaceFormPageComponent extends FormPageBase< }; } - protected handleEMailInvitation() { + protected handleEMailInvitation(id: number): void { const emailsToInvite = this.form.controls['emailsToInvite'].value; if (!(emailsToInvite && emailsToInvite.length > 0)) { return; } this.dataService - .inviteUsers(emailsToInvite) + .inviteUsers(id, emailsToInvite) .pipe( catchError(err => { this.spinner.hide(); @@ -102,7 +102,6 @@ export class UserSpaceFormPageComponent extends FormPageBase< } create(object: UserSpaceCreateInput): void { - this.handleEMailInvitation(); this.dataService.create(object).subscribe(() => { this.spinner.hide(); this.toast.success('action.created'); @@ -111,7 +110,7 @@ export class UserSpaceFormPageComponent extends FormPageBase< } update(object: UserSpaceUpdateInput): void { - this.handleEMailInvitation(); + this.handleEMailInvitation(object.id); 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 8e1dc0f..aceb93a 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 @@ -290,17 +290,18 @@ export class UserSpacesDataService .pipe(map(result => result.data?.userSpace.restore ?? false)); } - inviteUsers(emails: string[]): Observable { + inviteUsers(id: number, emails: string[]): Observable { return this.apollo .mutate<{ userSpace: { inviteUsers: boolean } }>({ mutation: gql` - mutation inviteUsers($emails: [String!]!) { + mutation inviteUsers($id: Int!, $emails: [String!]!) { userSpace { - inviteUsers(emails: $emails) + inviteUsers(id: $id, emails: $emails) } } `, variables: { + id, emails, }, })