From b4866a7cae7207587c8d3a533383177e56d7f9ab Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sun, 31 Mar 2024 15:14:53 +1300 Subject: [PATCH] Added Guest Invitation APIs --- docs/design-principles/0150-all-use-cases.md | 19 +- docs/design-principles/0160-user-lifecycle.md | 88 +++++ docs/design-principles/README.md | 10 +- docs/images/EndUser-Lifecycle.png | Bin 53065 -> 57941 bytes docs/images/Sources.pptx | Bin 899637 -> 900746 bytes .../Persistence/AuditRepository.cs | 2 +- .../Persistence/EmailDeliveryRepository.cs | 1 + src/Application.Interfaces/Audits.Designer.cs | 9 + src/Application.Interfaces/Audits.resx | 3 + src/Application.Interfaces/UsageConstants.cs | 1 + .../Extensions/Tasks.cs | 22 ++ src/Application.Resources.Shared/EndUser.cs | 9 + .../IEndUsersService.cs | 3 +- .../INotificationsService.cs | 6 + .../IUserProfilesService.cs | 3 + .../IWebsiteUiService.cs | 2 + .../Persistence/CarRepository.cs | 2 +- .../DomainServices/ITokensService.cs | 2 + .../EmailAddressSpec.cs | 16 +- src/Domain.Shared/EmailAddress.cs | 2 +- src/Domain.Shared/Roles.cs | 17 + .../EndUsersApplicationSpec.cs | 325 +++++++++++++++-- .../InvitationsApplicationSpec.cs | 328 +++++++++++++++++ .../EndUsersApplication.cs | 250 ++++++++++--- .../EndUsersApplication.csproj | 1 + .../IEndUsersApplication.cs | 6 +- .../IInvitationsApplication.cs | 17 + .../InvitationsApplication.cs | 229 ++++++++++++ .../Persistence/IEndUserRepository.cs | 4 - .../Persistence/InvitationRepository.cs | 20 ++ .../Persistence/ReadModels/Invitation.cs | 21 ++ src/EndUsersApplication/Resources.Designer.cs | 18 + src/EndUsersApplication/Resources.resx | 6 + .../EndUserRootSpec.cs | 336 +++++++++++++++++ .../GuestInvitationSpec.cs | 170 +++++++++ src/EndUsersDomain/EndUserRoot.cs | 208 +++++++++++ src/EndUsersDomain/EndUsersDomain.csproj | 1 + src/EndUsersDomain/Events.cs | 67 ++++ src/EndUsersDomain/GuestInvitation.cs | 141 ++++++++ src/EndUsersDomain/Resources.Designer.cs | 81 +++++ src/EndUsersDomain/Resources.resx | 27 ++ src/EndUsersDomain/Validations.cs | 5 + .../EndUsersApiSpec.cs | 23 ++ .../InvitationsApiSpec.cs | 338 ++++++++++++++++++ ...assignPlatformRolesRequestValidatorSpec.cs | 65 ++++ .../AcceptGuestInvitationRequestSpec.cs | 53 +++ .../InviteGuestRequestValidatorSpec.cs | 41 +++ .../ResendGuestInvitationRequestSpec.cs | 53 +++ .../Api/EndUsers/EndUsersApi.cs | 18 +- .../UnassignPlatformRolesRequestValidator.cs | 24 ++ .../Api/Invitations/InvitationsApi.cs | 52 +++ .../InviteGuestRequestValidator.cs | 15 + .../ResendGuestInvitationRequestValidator.cs | 16 + .../VerifyGuestInvitationRequestValidator.cs | 16 + .../EndUsersInProcessServiceClient.cs | 8 +- src/EndUsersInfrastructure/EndUsersModule.cs | 14 + .../Persistence/EndUserRepository.cs | 35 -- .../Persistence/InvitationRepository.cs | 98 +++++ .../ReadModels/EndUserProjection.cs | 72 +++- .../Resources.Designer.cs | 27 ++ src/EndUsersInfrastructure/Resources.resx | 9 + .../PasswordCredentialsApplicationSpec.cs | 14 +- .../SingleSignOnApplicationSpec.cs | 55 +-- .../IPasswordCredentialsApplication.cs | 8 +- .../ISingleSignOnApplication.cs | 3 +- .../PasswordCredentialsApplication.cs | 6 +- .../SingleSignOnApplication.cs | 6 +- src/IdentityDomain/Validations.cs | 1 + ...sterPersonPasswordRequestValidatorSpec.cs} | 43 ++- ...nticateSingleSignOnRequestValidatorSpec.cs | 23 +- .../PasswordCredentialsApi.cs | 6 +- ...RegisterPersonPasswordRequestValidator.cs} | 18 +- ...uthenticateSingleSignOnRequestValidator.cs | 7 +- .../Api/SSO/SingleSignOnApi.cs | 3 +- .../Resources.Designer.cs | 38 +- src/IdentityInfrastructure/Resources.resx | 16 +- .../EmailNotificationsService.cs | 64 +++- .../ApplicationServices/WebsiteUiService.cs | 12 +- .../DomainServices/TokensService.cs | 15 +- .../EndUsers/InviteGuestRequest.cs | 10 + .../EndUsers/InviteGuestResponse.cs | 9 + .../EndUsers/ResendGuestInvitationRequest.cs | 10 + .../EndUsers/UnassignPlatformRolesRequest.cs | 12 + .../EndUsers/VerifyGuestInvitationRequest.cs | 9 + .../EndUsers/VerifyGuestInvitationResponse.cs | 9 + .../AuthenticateSingleSignOnRequest.cs | 2 + .../RegisterPersonPasswordRequest.cs | 2 + .../Stubs/StubNotificationsService.cs | 12 + .../WebApiSpec.cs | 38 +- src/SaaStack.sln.DotSettings | 10 + .../UserProfileApplicationSpec.cs | 57 ++- .../IUserProfilesApplication.cs | 3 + .../UserProfilesApplication.cs | 29 +- ...ofileContactAddressRequestValidatorSpec.cs | 4 +- .../ChangeProfileRequestValidatorSpec.cs | 4 +- ...geProfileContactAddressRequestValidator.cs | 2 +- .../ChangeProfileRequestValidator.cs | 2 +- .../Api/{ => Profiles}/UserProfilesApi.cs | 2 +- .../UserProfilesInProcessServiceClient.cs | 6 + .../UserProfilesModule.cs | 2 +- 100 files changed, 3716 insertions(+), 311 deletions(-) create mode 100644 docs/design-principles/0160-user-lifecycle.md create mode 100644 src/Application.Persistence.Common/Extensions/Tasks.cs create mode 100644 src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs create mode 100644 src/EndUsersApplication/IInvitationsApplication.cs create mode 100644 src/EndUsersApplication/InvitationsApplication.cs create mode 100644 src/EndUsersApplication/Persistence/InvitationRepository.cs create mode 100644 src/EndUsersApplication/Persistence/ReadModels/Invitation.cs create mode 100644 src/EndUsersDomain.UnitTests/GuestInvitationSpec.cs create mode 100644 src/EndUsersDomain/GuestInvitation.cs create mode 100644 src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs create mode 100644 src/EndUsersInfrastructure.UnitTests/Api/EndUsers/UnassignPlatformRolesRequestValidatorSpec.cs create mode 100644 src/EndUsersInfrastructure.UnitTests/Api/Invitations/AcceptGuestInvitationRequestSpec.cs create mode 100644 src/EndUsersInfrastructure.UnitTests/Api/Invitations/InviteGuestRequestValidatorSpec.cs create mode 100644 src/EndUsersInfrastructure.UnitTests/Api/Invitations/ResendGuestInvitationRequestSpec.cs create mode 100644 src/EndUsersInfrastructure/Api/EndUsers/UnassignPlatformRolesRequestValidator.cs create mode 100644 src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs create mode 100644 src/EndUsersInfrastructure/Api/Invitations/InviteGuestRequestValidator.cs create mode 100644 src/EndUsersInfrastructure/Api/Invitations/ResendGuestInvitationRequestValidator.cs create mode 100644 src/EndUsersInfrastructure/Api/Invitations/VerifyGuestInvitationRequestValidator.cs create mode 100644 src/EndUsersInfrastructure/Persistence/InvitationRepository.cs rename src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/{RegisterPersonRequestValidatorSpec.cs => RegisterPersonPasswordRequestValidatorSpec.cs} (70%) rename src/IdentityInfrastructure.UnitTests/Api/{PasswordCredentials => SSO}/AuthenticateSingleSignOnRequestValidatorSpec.cs (77%) rename src/IdentityInfrastructure/Api/PasswordCredentials/{RegisterPersonRequestValidator.cs => RegisterPersonPasswordRequestValidator.cs} (60%) rename src/IdentityInfrastructure/Api/{PasswordCredentials => SSO}/AuthenticateSingleSignOnRequestValidator.cs (72%) create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ResendGuestInvitationRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationResponse.cs rename src/UserProfilesInfrastructure.UnitTests/Api/{ => Profiles}/ChangeProfileContactAddressRequestValidatorSpec.cs (96%) rename src/UserProfilesInfrastructure.UnitTests/Api/{ => Profiles}/ChangeProfileRequestValidatorSpec.cs (95%) rename src/UserProfilesInfrastructure/Api/{ => Profiles}/ChangeProfileContactAddressRequestValidator.cs (97%) rename src/UserProfilesInfrastructure/Api/{ => Profiles}/ChangeProfileRequestValidator.cs (97%) rename src/UserProfilesInfrastructure/Api/{ => Profiles}/UserProfilesApi.cs (97%) diff --git a/docs/design-principles/0150-all-use-cases.md b/docs/design-principles/0150-all-use-cases.md index 55dbca3a..77a5f7ec 100644 --- a/docs/design-principles/0150-all-use-cases.md +++ b/docs/design-principles/0150-all-use-cases.md @@ -61,7 +61,11 @@ These are the main use cases of this product that are exposed via "public" APIs ### Users (End User) -1. Assign [platform] roles to the current user +1. Assign [platform] roles to an existing user +1. Unassign [platform] roles to an existing user (except `Standard` role) +1. Invite a guest to register on the platform (a referral) +1. Resend an invitation to a guest +1. Guest verifies an invitation is still valid ### Identities @@ -78,15 +82,15 @@ These are the main use cases of this product that are exposed via "public" APIs 1. Register a new machine -#### Passwords +#### Password Credentials 1. Authenticate the current user (with a password) -2. Register a new person (with a password) +2. Register a new person (with a password, and optional invitation) 3. Confirm registration of a person (from email) #### Single-Sign On -1. Authenticate and (auto-register) a person from another OAuth2 provider +1. Authenticate and (auto-register) a person from another OAuth2 provider (with an optional invitation) ### Images @@ -96,7 +100,7 @@ TBD 1. Create a new organization for the current user 2. Inspect a specific organization -3. +3. Invite another user to the organization (by email, or an existing user by email or by ID) ### Subscriptions @@ -104,9 +108,10 @@ TBD ### User Profiles -TBD +1. Change the names, phone, time zone of the profile, +2. Change the address of the profile -## BEFFE +## Backend for Frontend These are the main use cases of this product that are exposed via "public" APIs in the Frontend, e.g., `WebsiteHost`. diff --git a/docs/design-principles/0160-user-lifecycle.md b/docs/design-principles/0160-user-lifecycle.md new file mode 100644 index 00000000..8acd6cdc --- /dev/null +++ b/docs/design-principles/0160-user-lifecycle.md @@ -0,0 +1,88 @@ +# User Lifecycle + +## Design Principles + +* We want to use the email address as a unique identifier for each person's identity (in the universe), and we only want one representation of that person's identity across all organizations (tenants). However, a person will likely have several identities (i.e., a specific company identity versus a personal identity). +* We want any registered person in the system to invite another person (via an email address) to register themselves to the system. We want to capture their email address early for referral purposes. +* Only the invited person can self-register after they were "warm" invited, or they approached the platform "cold". +* When a person self-registers, regardless of whether they were invited (via an email address or not), they can choose the email address they wish to register with. +* A person can be invited to the platform or to a specific organization. In the latter case, they join that organization by default. + +## Implementation + +### End Users + +Users of the product are managed in the `EndUser` subdomain. + +* Essentially an `EndUser` is a representation of an "identity". + +* Users cannot be deleted. Once registered, they can be "disabled" (`Access=Suspended`) and cannot authenticate to use the product any further. + +* Once registered, the email address they registered with becomes their username. Even though they may change that email address (in the future), no two users on the platform can use the same email address. + +Users self-register in the `Identity` subdomain via either `PasswordCredentials`, `SSOUsers`, or other methods, each responsible for its own onboarding flow (e.g., passwords require email confirmations and a managed registration flow, whereas SSO can just automate it). + +* New users can be invited by another party and can respond to that invitation (e.g., a "warm" guest invitation) or register without an invite (e.g., a "cold" direct registration). + +![User Lifecycle](../images/EndUser-Lifecycle.png) + +### Organizations + +An `Organization` has a type of either `Personal` or `Shared`. + +* `Shared` organizations are intended for use by companies/workgroups/organizations/teams/etc. +* `Personal` organizations are for each `EndUser` (person or machine) to use on the platform and cannot be shared with others. +* A person/machine will have a membership to one `Personal` organization at all times, and can have a membership to one or more `Shared` organizations. + +#### Roles and Responsibilities + +* Any person can have the `Member` and/or `Owner` and/or `BillingAdmin` roles in an organization. +* Only `Owner` roles can assign/unassign roles to other members. +* Any person can have the `BillingAdmin` role of an organization, but they must also have the `Owner` role. +* Every organization (`Shared` or `Personal`) will always have a person who is the "billing subscriber". This person has a fiscal responsibility to pay for billing charges for that organization (tenant). The "billing subscriber" must always have the `Owner` and `BillingAdmin` roles at all times. +* When a person creates a new organization, they automatically become an `Owner` and `BillingAdmin` of it, as well as the "billing subscriber" for it. +* From that point, they can assign the `Owner` and `BillingAdmin` roles to one or more other members of the organization. +* The "billing subscriber" responsibility must be transferred via a (voluntary) payment method submission from another person with the `BillingAdmin` (role). + +#### Personal Organizations + +* Every `EndUser` (person or machine) has one `Personal` organization. + +* It is automatically created for them when they register on the platform. It is named after that person/machine. +* That person/machine is the only member of that organization. + +* They have the roles of `Owner` and `BillingAdmin`, and they are also the "billing subscriber" for it. + +* This organization cannot be deleted, and that person cannot be removed from it, nor can their roles be changed. + +> This organization is very important so that the product can be accessed at all times by them, regardless of whether the owner is a member of any `Shared` organizations or not. + +#### Shared Organizations + +* `Shared` organizations can be created at any time by any person on the platform (not machines). +* Any other person/machine can be invited to join them. When they are, they are created a `Membership` to that `Organization`, and each `Membership` maintains its own roles. +* A person (or machine) can be a `Member` (role) of any number of `Shared` organizations into which they can be invited, removed, or they can leave themselves. +* A person (not a machine) can be assigned/unassigned any number of roles in those other organizations. +* A `Shared` organization must have at least one `Owner` (role) and one `BillingAdmin` (role) at all times, and they can be the same person or different persons. Like a `Personal` organization, a `Shared` will have one and only one "billing subscriber", who is ultimately responsible for any charges for the organization. +* A `Shared` organization can be deleted. However, they have to be deleted by the designated "billing subscriber" and only once all members are removed from it. + +### Guest Invitations + +Guest invitations are the mechanism to introduce and refer new users to the product. + +#### To The Platform + +* Any authenticated user can invite a guest to the platform (i.e., without any affiliation to any organization) +* A "guest invitation" requires only an email address and has an expiry (7 days, by default) +* The person is contacted at that email address and given a link to register with the platform (in the web app). The link contains an `InvitationToken`. +* In the web app, the `InvitationToken` is first verified to check if it is valid (and not expired), and if so, the guest is presented with a registration form, which accepts an email address and password, which is pre-populated with the email address and a "guessed" name (derived from their email address). Or they can sign up with an SSO provider. +* In either case, when signing up with password credentials or signing in with SSO, the registration could include the referral `InvitationToken`. It will not include this token if they sign up on their own. +* On the server, the `InvitationToken` is used to "accept" the "guest invitation" before registering the user. This `InvitationToken` essentially ties the previous guest invitation to the `Unregistered` user that will now be registered with the new email address provided in the registration process (regardless of the email address they were invited with). +* The user is registered with their own `Personal` organization and has no other memberships with any other organizations. + +#### To An Organization + +* Any organization owner (role) can invite a guest to their organization (by email) or invite an existing user to their organization (by email or by ID) +* As above, a "guest invitation" requires only an email address and has an expiry (7 days, by default) +* A "guest invitation" follows the same process as above, except that when they eventually register (either by accepting the guest invitation with a different email address or by registering with the same email address that they were invited with), they will added to the organization. +* This organization (or the last one they were invited to) will become their default organization (rather than their `Personal` organization). diff --git a/docs/design-principles/README.md b/docs/design-principles/README.md index 4045b2c3..dab6bccf 100644 --- a/docs/design-principles/README.md +++ b/docs/design-principles/README.md @@ -1,18 +1,20 @@ # Design Principles +[All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so that you do not have to implement them yourselves) + * [REST API Design Guidelines](0010-rest-api.md) how REST API's should be designed * [REST API Framework](0020-api-framework.md) how REST API's are implemented -* [Modularity](0025-modularity.md) how we build modules that can be scaled-out later as the product grows +* [Modularity](0025-modularity.md) is how we build modules that can be scaled-out later as the product grows * [Recording/Logging/etc](0030-recording.md) how we do crash reporting, logging, auditing, and capture usage metrics * [Configuration Management](0040-configuration.md) how we manage configuration in the source code at design-time and runtime * [Domain Driven Design](0050-domain-driven-design.md) how to design your aggregates, and domains * [Dependency Injection](0060-dependency-injection.md) how you implement DI * [Persistence](0070-persistence.md) how you design your repository layer, and promote domain events -* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arms length, and testable, and how we integrate with any 3rd party system +* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arm's length, and testable, and how we integrate with any 3rd party system * [Authentication and Authorization](0090-authentication-authorization.md) how we authenticate and authorize users * [Email Delivery](0100-email-delivery.md) how we send emails and deliver them asynchronously and reliably * [Backend for Frontend](0110-back-end-for-front-end.md) the BEFFE web server that is tailored for a web UI, and brokers secure access to the backend * [Feature Flagging](0120-feature-flagging.md) how we enable and disable features at runtime -* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logically and physical infrastructure) +* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logical and physical infrastructure) * [Developer Tooling](0140-developer-tooling.md) all the tooling that is included in this codebase to help developers use this codebase effectively, and consistently -* [All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so you dont have to) \ No newline at end of file +* [User Lifecycle](0160-user-lifecycle.md) how are users managed on the platform, and the relationship to their organizations \ No newline at end of file diff --git a/docs/images/EndUser-Lifecycle.png b/docs/images/EndUser-Lifecycle.png index 9440ed0a2d255561ab34fe7404a5da462d492384..b542c9f787963bd8a0fb02810578942959782663 100644 GIT binary patch literal 57941 zcmd>mbySt#*XKh>h=PKkNJlb8x&(ei@4-O_ zAEH!G(7*?pos!feD8Gw*8T`*Emm2E5H!6a zBQB=mthbT zALt6|Q~J{XEe|vV`zBLrApIs!trvP$E%Uj#Oh-O5dY&y!C^)Xhf}^Co1xLEu(M8}i z#$KHPo8-xld&cG5)xz_hPy3VM+!U{WAuu86E)QT5H7mA=ko9;cd{DmL@T8!A_CxisX-}?f zK53uJqMyJn4Op-(04(@Z?$>4{Pa^sH+J_20vqoQ?d`c%$(pwNDj)ry%Djq4b7>zYa za(OTNk>q}r{qpwFYQgGZSpKqf-_`Jf?NCqEPul&Zn@jClq?IhyySg_y$+o#>!r1ljHkiVPGdG_#?x6p4?~dY+mJ zrjBbvMTa|!Iag}ThuMX=zLeFJ9175mESyzNnmaVbe7vpeq04#$f)HR4wqZ$MRKnz;EhrdtD$Kq<=oR8hoKjG=vBhU%8QF_yOV@g>S#vIgMwZWdjsl> zuFk(Xp5Fet9m{xhlGPS!;GtgsT2(}kU5fQK&=Px0eds5whjPlFh!OVob#Ydl6hmC_ zH@_uzmN*E?u8{864Tc~e1o}~w93UoiGgo! zpXCN9_5g;~pcoqI1Ub56X>{Jh9oQ%&Hs&m@0z|rTN1A6UB! zH2Xtee+mqRe+x~w2s%ly=n7;TeH^{YVn*U*H#hVW=CF6x`wt71hE)j-xE;e(hS|yb zEaLA&ISMy&Vyo?!ZO@PhtI0Z@Nyh*wcL%zO&*;`*bAu>Hr?*2dQL+uSeL#s;ETpZd z*t=}cVbaB!oJVYIZv$j`mpn84=_-ZyyA_Qx+H%4CL&DcP*GRowhKhzhqyJW=6$o-J z{7Bn)luso*k)w0w1^{KL)v%4+^c=BsVVow~>Jx*u3`(Q6SD24t|0*A>LR zGIs1PGibrzt8g5<$_#z{EQ}>+{2I|UDNw&G9j^NT_?D$}JrhII;N(UfS(Py3*<^hB zgHhR^B;Grf1=byEs;cP^)Ya5R#=oOsO)U-xjUW42P`naCEMoC3H?ZASjh8P(Ktr=uq4iYZ|V9jYTv`H+(C3L3M8XJqW#yrLkpUln?*QB%dL`=FsYl8v=F=X z<92rCag{=hb^0(MYRKu1FJU2vwcrTX#Bd23!Up2J=;s1o{CK}Cb9d}$Zw^z4j2DYVDcjAgLY3iO?L~w7jjik-6@nVmr--%>+4j@+L-_rL$`pv z)j_Zra9KBeQJVNN?JJr_SY#R=!TbuE8qvGDB#9^Jzn6MbjZRrTw>}os?#CtB2Rv1j z4Zj8KchJ|zd*rwc-UL8kplh`0R~wAFKVeP@%Jg&|z}0|N+bR0$dnPFp>$Gfk63c!8dXrnC%3VdEW57EKdgT^U@L8#|r9@byk+ zZql!;9>#@d#yyBvQQ%J$d>i*ozU+`yZwseEIEf;J`_D~;;x%ME-V0#K&&tUO$DzA3 zFgN|p7kjl*A@svbr4a3k^|H~q2Z%Ad;4vLJl{Z%#7IMDKT{ntE38PG4SZ+t0bcQWK zHJ9QO<8ZEs>Yuh9;x02mL%A7b?7sKZoCzBDinfemR~|5+ZH$&Hs42ueP#Z2Z4S2S5 z_nk14o0m*(WMo9OAab{FB|eL|($tu{dScv##Yu%n$F`N;RmrDaeN18U?(Q%ellY%& zs|3DKo%d*fZhLvOi}%O%d-PFR%N8SFZPKo{rmrwuH;1Ac(=!AHlx&wT^j-r==?S@1HBe)?qJ40rny+983_d?-HYo`j;)vN~f zS^h2Gu=Hip*cBy^j!&sBW9+-%ztNcEWOvE#ZH{6^@GT~nF)>e@Fa)K#^fD#gp6 z8m|d(ZS4_c9>oq8=oMsCNQuQGZg-@(-_tX6I}&yz4SDphiwfi4-Rp}js&MRvkz%rt zN`hOBy)y)vr-Y^&JR}RB0fr=#F|lj+?#Q&BTx*$Facfx@<;f*|Dup)p4D~lzaWneH z6+jYq=~$LV6qO(3`0wW<%1;6nCMO?YD4{&-AFWP#r`^@uYm&1O#sQ7W+|zQ(QA~l% z>$GxGQXUQ5F}cG1qP0tho2bK3UWjQMZKx%jU6!RzL0~rJ1U_Wb^qLW;!Sr%}mU%g3Wef0^GX04-h-q`%w{@RT!Il$)8ESuN>wPyM^Qm9_l7 zD~u`hDiH68O4%>T@m!Pq83Vt4351`i7gWq#=JK7fIwVc5tJlFwy9Pozs8PBGqwtV& z$~^f;2e5+b^tc{E9}^VPii#AA%X8n$yb73|e(IF+G+M%?@JC6N?5>;lj+fHHgJQk@ z2tha;o-F{|YF>aD)E{frIT2WkRbP?5^T) z-jObSM8XmK${XQ$*aqP+FiA48)fLeT>nJkG)IPmYo9#9VK$=N@pWNmU_VD@-Ywon? zBa7Wtt;!%l=imBVdL76 z)_*5tbQ5SAF?w`Wz_F0WE)sYh__jU#e%+hCDrU2wEylp;eLw)XEd%$`Wn=Tcj86ha ztsN_B)z}&_%VCpr+RNFQ(ox6T(e)@M95Zlvf!%*^rFERzI{j+nj%H-xY1OqeksaZHH1`xI66Ebi5=Rb`I91m2Sm^dNXFg7N2O3UG5qcFGm zP2j@TYvyno9vSzQb1{-@uC;zC>ZB8oJvM$?nL(sv8QBu%5OO1pQbq3O>R`d39Z&eX z81ehVT1vZ7_qFD9ZN(_%g)X@~6Y$NB^}wD=Ae$77+C*J*YZ<$h&`qMpC0S)u;o*u#)&(|1>TkGY+nCC#b7cFkutAciBVf(SCGm3Vs-KiBL$0#PXqF z{bFU$yqlm0RX4sg*&lJG!_>di+theVB8qleJR>KE6r(W2J)W(t?U!I!jO}8gQ&pd0 zy(hBfaj!-B_=5C!Seqfmn2gqVoQ1C;ku9XQ?WNRK=W*PD&t+)9U)4FTQY^HZTe7=% z@AC7ymOk~%9*&~0Yky299TY8VV37Kj|IVS5LRDu>Ag(hj+C#ido3khQeUSu=~& z)|T)Q+ZC45`8P=siEDbq;^WDiof)6!-DKw;UL%U-{VD>4yzgE%{G`(ynO+K>&4 zL}XDAxxg2KLQ>Tho)>o z(vELJ`hfjgAw^!k8+y<)c1pVC(@PNVHE$p1pF{Ox2v+DWDM6V189D?I4N-I6Bc3Jj z`}kRE8SpD0CRZBYr=GuB$cKOa*tu}ToL;T+LTl(GKVQudggi5iyH-6&Jjin4{7q_F zAO##;PW6&t8ADoZ8SpqX40eri0mlQ|iE`VXQx|d-?bdSf*5RZGo7>FTokXjuCE2aK z>#AoWEfLO<=4bRA%_oD~60QZ%hd@JeI=}KWn8A}8<~ZzDiH{_uuX<ZR>^nP0uESXz$wL-(8z;68z z-6Hmj7K*p|o+|k%4DbH)uOa@jQTN!Q_r|==+;ntwGUXFh5avck^C$~*HFJ*d`>+7S z3NWlaWZQ~dvapUEC&0iIb#(G5B?eyXEOZ9BuL}9P6tCQ27nUnB?!3(vAELF`8J}sz zJ3mxpFxwKW&Y_~BBCRd5S+fID=TxgM11zXl3XoBTONnKkHiUx>CY z6+z|m z0H-=)N)BmD&dNc$7mpqAWldGpjrQhDRaGMst!ho4&G9|qPGUD|`#t?*p0u^LZojl3 z)}Fb%E#o0xsQ7rPnR=|Uj;bL&+yk!aLOuM_v96O3!+VJqqCGU;H7m(3E1h+J6Cl4{ zv;KW6vFn!`p6C*OM#(1&$i&2t2f63y3D}fm+gS9 zI5dE*yyYJ~fw5wrp(aJPF+yZu2`A5RX>xNYiPt8@&1btJqn^K^8#2Lb_+EPr8!=9C z$h~+a;5iRs1?>Y4`opkbyXvKzXt(at;)nGX{JNwcDGz@&M-KVcTM>}e|L_n5^b zVF*#@8ssHnbFx%jK<@6GW>*%YB92~|P#UA?BI+8!4|yFOh$E$=h_DgdwZ(!B7`ZTN zf)YhoAht|o3wziu{g+<%6;<}6%PA|xl``maY^#12@B+;26JyVDDIEun%3cF3x2v7# z>%<&>V}T+&EYWf|5J`7t`6~=+kcJY`4|LXwCYePB2M6O7#AOFce^>x%D}A}qK|oCd z>|WgxroMk}v9O%iQC<>z>W`v&i=&gFP>-E_&Yd~cPA8S_jiG%J3XkJUiEkCiMNW3d zyJBT^>XP57_7XGz30BDz@S!t2UQ<@!*XbhlhTai*({HtXdon1n3V)nDboPSH58sK8 zpoEX*1FZ%Qai}kyt&$aIMfE#0acscGn~Bicy?RDnsnZ(Cq`@MDZpolrUZLTXuPkGX z{|gl+j6$h1NzJeq&1jzA8x82YL#;Lxg81eV&~U_g(T0Xu1X6wZA`-cU=78ipTbYbn zro=uMLcQldOEhFyVW`ji@cPbaBk)sV3W2{IK3g3{< z+KwxXtUSrKi8b{$uHAXa!--k9@=+d_v@s-tK=5cEPp;sex;jT!c9k|c%kq{rqnV^1 z?HRJBO7&#+M{{M8#OXZi|J>fjxtS<||B*%5TdjF(&e7 z7k76EBljYH)YtDAh6vl7{EkbeaJ9{Fp>{A$7hy$ox$fO%C^mjtNXpU;ts$*fuBc{Y zWxAjEmd_QtNK$sEd~V}_WWU>E%gXpOm%@BZ?rW7rEZybnImH>s*QxH9KRR;t{}42m z<-Djh&E|W=cBRyn0hbJB#~ZTA8)+_R)D{7X8SNZlZm|Wns{e(}-s$9g(nT5~IDfg} z=(8z>aV>$AF&5uc!pV`ZG8G*OaF(02F^IS&_$!7T^2u8GB@_`#&^2JSK{=Z2n8fQM@)+J=>BXaVs)b^Ru@L&UHnmbGbQ6>R-3!8 z!R~jNzro_y+5?UejH=o^M0Q=Dv-}xm0&eQLE9yMM z9b>D1TC+c5w$8=IBov>(LG98;=uME9FfAR)M<8X2bzErfYSURu?sv~is<0;LN2eL# zh|?zU?9_LG+0`pV8xp1uQ(9D7!0fVt+uU5tjyY~*k&QW#T2eJ}%f!~NW_=Xn;Fx?E z8@qRV!JT)IgI|+_pWM}U;R|tJCp-|3DGMM4N6YyCJI+Kp$r{}ni!sP+f317Z+=Vf?+L8kXh!dEmU;&qxmMK&@-o6&4~P8*!tmocKaynZ#Iz2CNesd zB7s*4oI_O4EB<8SNIzBk4idS}xW$BTzwsLu`s(pXlXGcSmv7OohuC4!iSnXp5HMQi zhbQL=Ax%bYse+2hP1zZA?cgof?@iAB&TgN+@mKKQk$R`kHA*MS@_jE<>4I{*;qv4> znf{LRYcAHgc3n_H_Yax%daut&Vs>_~*(0NsjBwQ;$b7LO0Zn6r{>$L$Ob#dqVv~8p ze;Q)%wHj8cZV*2rux@@g7qO*i&SxTu{+7sl-S;taDJVaX8b$n@T1VNX^7~-?vKke&g0_e${taQ|^>q`AEc`)yXXFikYHN z(z{@wQ2&b_Q#$Q8Mj^lDPjS1q9Ui=GwpljhOYD>gm3bG;_QeKia9sx|cl-#yVP#H6 zesAtdt4;obActLYcXs&qJ}F*-CwUsp4Vtf@E~GUFKc|KJ_ENmrI+567RI9Dp^JkHq zP>wxJl6Dgm@sQ05-e?*O{LLaPge6Awop)btvbuEf`OZ*y8_B_Pa!k6DEQCv|%?9)1 z;%~ZYK)ubyE3hqsb6#aL$e-N!dqL}wm3D@LSsY%?2WF^8#WNXGw%iQi-&hr_7u=J`VDHVf z?((xsUNjQ)*cI07&>ZesSD|c|R*HQqxs$El>dgEB*1o`i8`6?XRi;``NXPibcKD%2#22kpCI?%3$xbC3{(7Tpoo4h;;@(r^vdWr9KiR+4_rBEl&IQj9@V<3}%N@fbyTL+(Sehk|ch@@4GN z3qz=6HebhWNcr;ROG?W3t<@z$^RZ-d|91(KWzW(Qgc@3hk{qie+dro6JcO1~QC@RJW?<^zbBS@tR^RNYY#YQUZt z`%ZO;f6hzv!sR<1yqF{QjoCy;^s-SuRb%_)DfUfR=v;+P4`vqx7RQk(0f|Z~X^MI+ zpEGcz3IW8m+MgX>Wo2ZPez^8tvsN)xGM2c#A`DnHMZ3$DuP16;j^2NE!JaZ#XuNA{ z5MXGoP(9u@?~Y#BvG4)e)PA5AS_@)sQS$4+reaQ=N4%*+ z*P%5nBjZ+mTRqK)IX0Kw&Ul`j7g;O1>^jB|Cuu7TT7rOYj#}dBsKqEm5M7tBd>R_K z&ZN*s=jHOi5$d|N7R+@<=Rufo%`p(t=0%} zwNo@+tw%p&73pze8)$Xiw4yKZb8h0Q)$WtCJBAQ_Y% z`Jf75&)809FG9(b>CD|O3qU3YBEz5O#`*z6p8h*ScqsrwUb!L>``E4o1#zrv;##r;(v=lR=d~YhGPNWr!l?NfKVPDpz7u9Y(whHPKMVy9JjsF%m*^zb{IL z&;xJ>Z!Hw=1TRr|&of2OR2SGTBExf(%a!(#t;gX`W# z^m057h5xoe*LsQ`N(Vr0S-(xtFl_qZ-6vcmk#nw#;Ppy z#)3q@6L~bha9&(7LfeXeiTzQ|5s!Y!L>)h zwDo^7{CHEX?xjJ=&HK=oS!&WPfMkqRHa!+2g=hR~`kRAQaQKI4&|H_58!I-Fb#N%v z{A@5?%#au7wbs%|3~$QZs77@Iu!vo{#2xz}m>1-7C2G}<>#*!>8v>l@$mn+_ZpDYc zaT@rNr2&27CT=A4Y{V{0foK|P6Nwz-Xca%at|Y?iQIp2h@1|dUElkCPqsdY^ore4k`0!Wari4ml;u}uKJ2_c^j16r zs0)k~)Nr0UhCR$+xLl&K_nD!Yyu|aideZkWdfEOfAnbKtPLJ+bR5VJI8wzg^i|dy& z<=cNG4zjng`fn_B!mW8hn2`iKJ(k8LZi3f|?|mGLp-~#`Lo{>t2apC~nX9#a+xpN{ z@V-`x{nXX2i}V^R@P?DUw4XTgv)y;3@+bh_b7Q5JN?m@_ZsQ!p`U6x{@=9Y#&kOl~ z-stduBw=V!tT2rMFs8!UP7#IrnRwgV)4WBW+q49%s;wvBizmy&lPgHf&rg9EmVh^N zzPMl(FC!({Xo}f}Gk^O)fnS|>;P12Hi@Bh3Emyj08&N-TqPTi=-9!P|Rj&Jd@9o56 z(7@$umfR6vzN0S?DnY0w|&X<-TAJhr?Qs?+?%#+xe0 zshI)+%TJ1O>mC!nB2__R9-?VNkWAS+CLYI)vr42Xc74h@<>!QR%E*bwFaxf@!?oc<=U z)9=vm&8WAX8^RUhfvCq(bc3oz!*pVzUxyFdU*z~4FXjck3f}Qjj1K`I44v}=!2cC4 z_UL^8tSv+_6+9p+Nh;v;7Z?7||ARY7ek!h7ua5_UtS_Tj3YIaPUBdJPaPASPX~=_R zY*1)FK?YF_K$=A*QM?5K@YJ_Id;J7u+eTE2UX7fWGajJE+x#!xqXGg1y?~sb@BcoM z=VqilNw%q>a~4*IU(qA&&55cZfaSf-9XH2$3{S@JV2rwg_OIh7lYwIz}a5N%RKLeR6(Sapk^VO@xrA?qD1~&pmD_iOm&WbIsKa z1IF$XdS4yoHCjBD0;hpiiSo43CBc*!qHC@=cmTR_HN z4vEHDjF**cescP{ER?=8q`IK5@ydY$@wD2LLjaNdb^25Ju5BnaC*dEN2C|XXck1hx z(W$9DY%tdJI6Cf!J{$aX<91t8>1^hMp<1V8&nu1OpD;y*FV30P(+0m9IWARBWnU0H zojU>d{QZ4k+?3>jr;-Ta%a4F+g^T-DGU|)60Ed6wXYzc|raLB8uMnPBRtGyvjNZ-G zL9{U-vpx{t#`S)4!Eb4QBsPIu(qa3+c#9)>SqC}diF$O9wvE_c_8~6GS9Fm3 z#YKi-5!`pH0xW>}>~vmdO)&LBKF+I#cjRbI^xLZxTuuo9yrx1GoB;n?%fPw$5y}1e zlO4aVn(XCUmhP=%b*%SPm({&U)nydF0uIP_aX>owM(uI4g3hzylCr)J^9!Qt`BgOw z+1%>#l)R<`K?tYSy~mh6($Ec5K?R7dD9wHC6T?+Yu61lyPQQw~Dt=@n$%CcF10KN~ zLtK{u&FEq%D+FDkelbv6yc{N6s*2oPQ`52vvg*yIBpN6qvqpg1Ey(whDPVgfMT>Tg z`hsr%BDc*taCY5iLR-H=Svo@R$H86JVBEEtyR}@n{0t=-sKQ$1(c&|1t>N3uE?<(8v5d7QH^Go%9jAI zc73Wb>e*8ZZnr>t{ByFxvRnf1Rv<^$5(?Y)Yy-WpCZF1_Ws`Pyl5)w_o!|hsR z{uD%|>pNdx^}Ym1KY-~6c%(2srQKpOe<&(;!Pmw?z&iqRuAI)uaMl8sHuAO^4V@OD zzKrg#y1tojIfhEVd=Yyi1sbUkpjg1j)e;8kv2DA+h45v3kWb`~ZA3-4T)u^ipgs((B*<5sM4lVT?m{uw!Y= z>(_gpiC^7#L z31z|Az=#?93!se!e9>*SqoZTQ1yThU)&e{qK`rUD)Xs5mDsS z{GdzT6L@g}oa-XaueLk%)a*&&Hbd-oF>PuR`_L!=ZvCDC+oL#K;`sBRvKq0`H=4zp zF}N}9M`a0zMG7UT4vl+I()}zNM^q1G5#M-lRPk6tf`LeM#F zxHnbGNco!Yiwie(O-xo@P<`0L9th0>G_S+(5tcM$!m}2o^wHH~^)yhY+QU+-{;V&R zK(JD7+^DY%!!)2O+WBan0G_auG&EvA_x35D=gdyz)#?rpxcQ?6PCP}YR%ceGV0e4U zh~C*VV2FT!%c;I8bBUnox=w#I<=O@mTixX+irDi(3oss>(pmIgC3zQLIQ z2k8u7L0zTQ_Y)qK%c_cw9o@Ro?P1PvoeYthrn--9`64mM%z3XNramh->k|LY;;#}p z6XEHta<-cHzp-V~LhZFG8?)(UJf~VRy_I}61C0!JmwJ+#E&l#M4GHk z=YF?G$lPNS8D=J0>`rDS0!Ai3Bg?ZM6lOdUgh-l~mEV3ALV*J>6PXk8EoI z3BpLNTX~~?>rU}DDCN2*<1zg;l{EWQf}EZ!Z-Apql)T5&k}lYO-FrQ0Xpv__m8#QW zjIIxE6JqY}@;h_YxgKn!2mIMuZt zdJN|4%&RGa*6wU&Mu*{zZ|x>Y5OJThl725fnaq0CbCt-P6*jdqh?${~59j9b4iZn z{AgtNM}Qe_y@Yt6JU?Mm+-F45LB=v2{q@H>)soKPO8l|lZB^kR2P-HJzR=bhJt$}tBuiG#sL&G&x5s#<)3Iy}A!A7gspEyZ*Ol`nXXGyFs~)yY{q93a z$c6GnJO>wu=N4qiGqnL?vN#NU;^(SY6ZD2Xc7G9fFC!IQ_q|B6-CtUN`9!A$sk!V8 zU#*5aJ8S_)W660*WMt*I?Q_LO+)d|~HF7NrIs#RKtFUsDhpYOOyQO)@>#CgxpN(($ zRx!8pw5#B8Vq6uBGG{)6a+yygXFBm4V7ARuw&{ARhEow2j^Y`=R{8px+bo4ee!`f` z8na#2m+uAnvq{-i#*&Mt;oLP_Q6pTSameh~cR704gotNv5xNp#GkdeX*C)_m8c(1v z0!*q4P9-y&Mt;{Rd~|@f@R4(Ow zoc@gXx*UUw`norBu%+;W)9)FFRK%i@|Ip)E~cdeQg+dyG8?ED z=??ifQxWGE5{uW7R!;H+d8ocM84n3=LgJG()^E3WN(POp0=yi*+lz}6@pfK;t(nKJ z7wZ&jyk04u1AffqWH;Hvak44bmyV~UBg09(Gh-wD0dIcWrC|C|f) zGVgSfg|Wp)iV+EGi}4XTJK0%yOiKum#PiT^iUE1ds^vsA`nu89&iPA<2k~8?8Fct9*4ye7O+j8hW;F#8QSL{Yd_LKRieJo}oZ;u}s zQ-JE;7;{PX3pjgI9V{udB@C|W_PQy;lrG4>m#GiK-TdrS8VFdJPq5d~@ z2`?${@LvV^o^gK^^18!r;KHxO>hfy{z(DmrOw@Uo$MZsd5OWyCj}S9Ry0Z2IOg#Z=j|JjyLPQrW$-` zrGg$_F9;CvlWhI9gf2f5HIEWY$uyK*0xCX zD|0svPA4So^eR)exd`Tp3^~$cE|-KlhZ7ODR1Np4`#VFPN6VR@!IV?K$^3Z)pz73ulp__2Rt_3^mi_q2lZ8sqUcX*CcnU%S3SR3e&`XRth50vn zee*X^-(;E&{@{qHbOBrk2jH`rEIQ@@$Dn`F+{JvSzjZnnX$&Itfs;ZxUeAZe3NDg* zT4JBXFG*kzn{A9Qe)!-Ry!x}68+PgvDczai*kx}buNu_!BpV@M*pJ3rF^pnoQF-2!ZbAj>BJm&&hHBh~*|~z(eL+fELae-f?bB#kt@T1|&@yap zn$qiJ3nY29yT4>N>y9@-kag=);@?Q%k#dxk-#$(Q>`YFrqw7u(W_s0>N=it4Qr1~~ zReO?$+`fra@_3=v%3S!#@uc2p)nu1M2C7W@(Vgn~{@3R@4=cUac)Z--Imf6r178vs zBDG7L>~&h{1Vz~&Ev~G50;y^ni5gP13pyDx2o^e=3kP#`qg4*8EmU4({r%#Q{KE?w zd7&hNbOiuVk0%xA%8KGOl}v$A@^J-Ipkh6V0T*Da`*o5gE7UhqBs)p0qMAnrcI(A)wn8+SYUmsuRncA#J4I(f2fq zZRgwGzkgpo;kud{EKCD2USNLSzt^3;oB*cw3WQZ%2I{eopZPNzK#mHWw4fbIEmfvd zFz8#lDXy(*=O!Do>XS|b$%&|Z6t=YzN1Ax7FaofxiGd`qStog(;lt~7X^%u?YtycYeF zety-B^va<)Mge?CM7a+y`mr~oD7O@(ABR{|SdUw#1*s*v8D2mB;@}*}2L$ayJ z(nQ-vYIMG{yw#*QPG)FYCSjj+0B%E=`D8(k+YYvfbE=8LF|4EBy(;UcQDZt6xDzErtJ`sdkc+M^>4D#Sf$Rb({J_^aJd>j5I?K570CuwGggUU+#_1= z(sGj8ATU0YIR)K)^e_}iMPf1h~ zCuqOyqGJZ2$T5K6Sygc2)`Zd(hmA|OQF@olC7RaJwCfbV#GF#ONFA{u4L(ypBfdHU(>zw#L{SU$5jI~7x*svhx82iYJqyKcbbPBlcF{Z0ynJBYu4B+J(HtN%GmKyYn(9C``cafx-mHbIuDBbxNqh1S)?9_;|u} zu8laYf9gYyhs!s?1pIkD?w9c&OOmywd7hL&S6~kcTwnmM#}0}sb=wq1I)AuxTZa5y zxQ|S|^2UO+QoynjuYyJ&VyIc1%{xKrAy5eqeh7B<}U@;(RP=g<+YRv00`zAX8 zn5$n+unKy63PbJ@ly0bT6j)a3J|F~_&zj!ekfvw&_yUcdM*(#Jy=SQk1QoJaKFS5X ze~{TLCUOsN%hW{BV2mz1wT987*ez54H>npz!Rvo4O1oE~*}rgO#~)-?%jwj;ujtrj zx*8bc0{B}GPlniJ>IpQ>xn zxL(oOO#f_%=e96?^(thl%KdO#54oZqWr=$PUMJ38Dhswp;EKgh-dr%l`RjrbDU_%6}MX>I6E=w1_OAzg?y z@kCTl=4n@M^Ssk2diiclV&eeFSiWBUDM*#@mTzKR)Ltu4W)xKbyr?ON6|Yv9!17~z zE6?cOmptl`e)-LC3JAV9&1$?=U8T*eq)g$}t5?nN=$t|Y>OhC8RqfsY$h;Je-d)tK zpy7WE&Ia2Hi+}1Jbyr_W()tTW`q1ZYOxAlLHpWqt_h5{i0=J0pefgZ6aa489@kX_P z<66i&a$aliUA&9Z2PAkh7h1nd&V6>K?Yure*gamiN+d*F*dv{yU^E3XK5}7qjp9|t zWcTe}DXR7RA>h1souc3UOaU5sdbpUpihVv5bza2=CHtTLl7x7KYI0XBO$RinD?g4Q zK;i+RaJpR19mmRWg)h+bJ~#Q z7YA4Pz%THXDh}Zt#&L20=6%f}`qv?P`w3z^bYbXp$J8&KM12Pe1Nbf%$qYabYF0^2 zxe>JGQIxC?6{Uh+N%r2}ujj)E=LL1=pB#|Do69Z?s^I08YRZMqP*iD&>7s_ERc2mk z(oM-Fq1ZM{;Qcl?2^7%k_?RS60qZvdmO#syM0o?t?A_F=GxbMLPvdT%%9$e z$KGf$g5fG)+`MKBU`#8R>+N8FA7T>%$WV*FU~^J2?y58sw zJ?D#a%rv69S3YuTA=)xf#fYj?dJl?1>76BxHAYSXl=o5!0;=@1bG)Dz`LTbb)@d?i z%Z@cG$$6_M-NIEN^j3|-sJsW6Qt*P0`^EljI{^4xI+eJL@(=3eH6XIy9~3c^2LoLy z`?q*3x@M5rhYcpMUcnHN^Mg!n>z`1%3k*BI@BF+)tqKxn$0eOn%Z4$~;IPo4s)~vc zfb#+Ydt?sT?wB-b9QI`p$^**Cxv^e-lp+OYav-RPH{R5t@Vjp|?y)u(TR$fe>I_~0omZ$A z2>)&a5ZH^d`LCZ8ox&ynTxVqs_|w4B_{HV(Gd!z_&P=Q37hGw!)O=acW(wF0>n&wajvKItO1TlW5*4V*C@9>Pjmeh$aZlZ%ewVf^5Au=XmWq#l4;(nHy#A??Uv#>VbYe)rz#Od<*+cko z%YP&7E#sozqPF271Qm}csE86GB_&eQVbRhdBB7Lo(hUO!q9EPjAl*_*mqmxtLw63{ z{j33xdhX|be(#6()A`^T{yWy*d&RY`iyi5*@ES!d)^gfi#sla6f=|_pGxq`c6Zv%e z#`@Odwf_Nrc|HDY_z*vpa2$uv;lz*U=G;^XKVT7^{z0`QHFFym`&Ln?K!G!B+HHc~ z)ua<*EK%l1&&Qu33@}(jgl={V|MTyU3_KQ+q%mF#GTO_vUC)8FwdNjFYD=V5f1n_2 z365jwl}FFUpH=?(k?yY_#lnKJHI|zUx=6&c*?y|nqj6OHgNgARe?}64Z5eltp3}iH z3E=4dS{NuNs6kbsdo@a<7^X^n`+ikVRVt<@|idjAw3~<0Xn#>i)PjPWY+7 zY4=^dU!TMxh9Y&gKNC?l=9acYx@E#PGs1RYr1VOi_xQ6P;c~jgZqIBkBep;P`W8UH zT4B-Bw+5IRn}1%4R{;FQ6DZd})kYiA654t%VFMNpF=S#Ur`?IZ{yWG#YEI~*$qav?~Bkk$b_<*kU9rU0ZjinbI7Vu6)a<#TNJN5hd28i7}kmc|@$6+oc}EN+5# z#x(##ACU}xMxIf#2>2Q0MJ|u2a`OM%-KtlM`{~$gY};*>b#Q^q;aoa5lE#0&a^kr$Pd*Pt+%~4%hC$`731NT z1#GAFFV8`4y8Xl#l*DjOL~?F!i?+kYW2!~ljln%nb4n|neQVUtUPZaUTCd;!?B%ov z^gV9vKg>nA7cd$xuJ-T$^mHP|slmhHBRm{UaPZ0$lVJaVFzhnGqRLChnIyQp)v@O!}R>kZ>2zu3oYTGyMS#8<%e@WojdiySXy|Mh7N2&sg zXEk*2pnan_mf2YqBlu|xL!4ic2tCjbj#g@Lh^60dIYsbTC_6D$US2XU2CM~)X3FC0 zBZ*cob6bl13`jN9a=DR@dw+5Dx1pfwbG&ESkQB-Vc|TiK#mpS&o2H>=G^RTCtIfKT z*NdLqq2s;xDiYV92#1f0;BkoD@K=Nn;mO*$R^Aw)7V#KbGYg)x(ujEnoLiy9&Ccx3RKJ(}sn zkK=cjzQA1i13Qx$6VUR1U45toHAoh*K#d`$_bX3TVhx~Q?dj-Y&v_^FQ z^5i8V2eLQD1KgQym#+{V_6UqJ4Imme%Jfxi2p)eIKJmjrA*FDA>KJ+zdy^lg!T2L3 zZqRGkG?ypMQd1xS*VFjT0qW5Z($cVd0oHG>zeL_VelP_uez>N1S~Qn}MhCfRAUt&@ z$f14v#hhbqnWk3h`$&mLX$g(Azgk0Tl*@6#7oF`mkXa5nC-L6S$@EW_pZZoHFRxKN zTm-4{bb=;(?l(8no1e_AyE&=EWl^*OU5MpwgL=Eo_Nlbu*Xk83u`l&`{Cc-;?+)n4 zWxRPgAw_gO)maMQUg?p@oo^E?7wsaZl!&vC@_j4a9lMZIRi?Z-W;xqrw0aM)N{zkg zKmK4akyczsBq-_RshZ?Og^ss|CY+6ECawJj4RiIkm&-1uDN23Kp5P#T@r3OLkKd^3 zn@EX`!v+%nWEZ^b?B?d-E(4=$Ncl7j-bdM}H|pngZDLd|ORRg)+POM;VJ4e)8CA8o zX#EXEcZb-f2d5-aa|qVF-KTwo_QH$#6zkZU$%p=m;l4LS*K5rf`0Jl3c340)g`vUi z*5wZmt`n*7O3_{CO}&+Jzi`UMZq&jJ>qOvX z-B;KYO~2aHNE_qmMP{}vsUH&O_|gwSq2ju>gO@8DwYQ>Ky)q27-O*Hc1jWo%L61de zU*>s4rh^BL_&PuH!}#qlr?W(aX&RA`RLqSR^tm_p7*Q@0mmv_$?;A0PFguy;o075R zKbF=z1`cxB*0=~ypZ4=9BQ;(rw5LMe$!RNv>e+fz6c+pfFykYuaTcYWm`f%7dwn)ZGSzhNz9*cxO>`rV%c#su<$>gwfjpy&dgKkIoRB?+eQ$;VNdQu>0z3 z6@$vPSPc4=yUuC#W|Fx2$uDm1R!U!3)U2*)ST#uk24UtDdpGoY-dUZ06Whh}i-1pb zQr@DM$Wy|g#e(A;6Jl}AsAyQU5Ngj9f-jR2s6n^tc2AO+k^oLu*XEF~reUYb>UNbX zX&OOu(MHuaJCE&dALVwxlI@~<9{n+MN5=4V3?kkg!Fs-e@%u*E`)`*|Tq|ZbqSv$0 zd3?DaZq~dC$l0sz3mQxa*x3gd_{L19xt>`)S?ScsY9d$E2_@M22B+!Nsoa-Pu5QFj z^$16pSrul;FILOj|3Nbqhm(11`DsiSymGywwJa%hq($h9dK#jaf^{D`yfGnhQQjqY zIVi@?vguU7WdtFXQ8u&wp9$jPyxqo%)U_*8U;3U3xlK2lMEP=0Ipf}8VMTBu<-Hc2 zalEX+taWajUH|DC`vw-)9}8c9V7vxb!BE%tXTcO%E3_JIryE?4egf{kwjQcpp@(}H zlNxT^JNe2))~A}J?PkM6WogPg0FM}X?;A5WEo2u`dS$EMp}OM<6~ro%b1 ze!hb2ea$Pd>do{G-#H=){q2?DKUq=Vz6&a;U%O#(d-&}(sqNC@BdT$JxC-rj#)#JH zRO6tvpRYT=R_(*s4nF;PS>b* z&`V0L=nQho3;P>2Z(QfodH8eoZyCuo@qew*vUxmqg6r#IS@QI;Q@nK4qJb zL_v8eKHrFF+^!jcEV zbGZ%%y7Nz|db&TE#yF0XyBwmYg(K#9iuXasSaY|=6To|Dr z(e*bPM38Ap5;FXejum(tJE`&kl7?ynQQ42%ou{u_oluFnieU6jLiUCRN>M*C`L@2Y4 z!+KWuizibeFCqZHM@@Uy_vfc8@1Emb8(9Y{mJNoSfmLKk+M+Z6oom>0TItm{Ssv(R z24-(4YSG4u)&Ts=Sl64+YWb6w6MYS#i)Gjn5nLkkI^<1mQ{YQgiogqwo#JP?^yBok zBu>?B2tS`@V2UWRfy4U8@l=}hzIjLI($kapU{2#BNTa?EX#f^JFPbJJk?0` zzHJ(Xjg1aOV8Eh#}3ujs^ z_mMBOA22W=h~~LuL*(wD@v`qv)Ya9IWD@iqC#rlq5ag!!c7R2R`zs=TTUwFhLZ7-4 zdTXUl#;y*+>ixAbT7ll|uAGq;h3WH}({M}#PHL+CG-!kHx)=IX`bp7%KnW?2T{&(- z<-4O%T*rt=cPliItx#GbcB-ChZWzR^t}ypwPH}vhaRz4t&U#mXlI>BMU>>f?!pSYO zh1!}lhH>5(C3@(+nw;a6zPHnIXGGPi%A$orGqyar#JA2646@Ce?KQp5-*lVf)zsn` zk9(ya(@A8TgO^<`l_ac*#=G`KT?|}5)DE%UPJeCVtT%ZD*{5s%@*@F#$rJhb4$ngd zagdheePP#IFaQfJ26U(aA-|o*1!>=cnf)b8z6dtGzae%*!@lKdD~yQt*!CFUf+^e-=nl0o8HH~@b*d~ zCr!1k{ZJU?T&qE1WrDZxFT?NOT-{_LW||)#$BLLnJW@sG)4*dgJJx#=Ki`@lld2&WebY; zvZ;8HTk{OnvYUPk#5C&k1oiFc3a1_sn-t#F(U|z&g#KmK{adr~aqFj*cFF8A-V}SY z-W5&}(2jVKER<5_G|pg5H*U&DVa^-1Ki@BNeXMD8Qzd^~QT!R9ZU`P1=g_rgml=*1 zUTBgud8Bx!DwS}hN$vjYtPk8#*0oLa(P=gWgJm(8a)(N^@0FPnr;`;6t85on;^dQY zv-P`U_EwV7UnX+&wgg>`nxZ2(5a#IILeArjBaJ zF8kxDSaa+vfcqrecTBd_Oj8F zmCP>3i_EdCT(~5@P5O?Pw(XzD?tP>!xF4)<%+q_{mXEjD_P*z%d_?r3@7j{bvE`c` z9T~4SaqjD@EQIOa!N)$;6H$IuBO;lKiE>X-2JSjEcsp_Y#aV3>7imTV5v9d@iOa>k zZ2+X35;7Do{Z-t`(7E2bKz)&d*F#}xi!IkN1us}w+z^ZFpBmR44sWGkp&-1hycTe+ z$Dl+%XNft8KFWOT8ZTfoJ8r~f=oz+_q#G))E0IyCk3FvroMl?VZ(o0~b+uh6bbroo zT36{R5KgtbMsnbzt{>l0k-PpPPF3oa(B7i&E%{x~d2&o$xa2Z0+O7vu(iW>Gav=x- zpdC_uZ^0TmA<; z@)&&-8)Bj~UZGhL(VNKYt+e%n*X&jfTGOtSY!e_Sfs?{PnUa z=)KIS`Xy?F@Uk0r%#x^&eDw1IbgfEL@K_2t#?u0e83mA-ronaFwh*Q@)z!Cf*+RoL zOA-c--DiTqw*w~=>jH#SAAA&L&L}rV>;Duovao5Vy3Zd!6*6Wb{fbgO zgWPS+_oBvgyrFN5i7J5w{GytD1a0-!2M(DM`wRDC$y^1X1)g|IK%~^Tc~B0uy1ELq zZ;`y_sN%FPdqw45dld@5Nx7ykFO7nN@{Z_MM^0Ev-0AE4seVE;8W$ez%l8z|SA^x! zA;{1UqaVdTncR9emO^^T+G#>$oio|qiWF~$;%q9ckD;igFq2Iba02(bDFLnlfMHeI z)M;=3gB^8qk<--o{=8*a55sGn+vW_cKK@2_@d7DmF&Y>x^TG6-`O@e9Y2Gv_^KLTPNdf&ySMuO8zN7k)6 zUetHOTDBSggSXRga4Nj{zk1ieqmUPqi&9}YYzz8WN`X;j&P^bnOymtxTQ9Mt?wU>b z5otf|{IgzlsgCEamNw6(%TSSD@y0fB{$bVpeqod#!OTVJDtH&D$yHMIBt;}7t4nOr zzFu=L0-4tzHO4lN@Qp8?i+!MG@8V`2UzF9W_#u*T>0WMxk}SN8G(^`Bf)ZpUb!btc z_*)~?)QV^&ih&ZX33+A51`Di<{o?jmGitY}NcG5IQl}exVq6wq@#-DHU1=#!(emwg zK?UgDN|cNmS55KMcNAa$N}W}N&ebF~1ehWRmg4?3Thh|8N~`t&I!Z+~kB9aM;P1=& z@FDaBQ$e>ZqG{R4Qkv4ez1_@;V4M5zMN?~}tYFSv)ECTDWE6v*jfe;wiYc}k z^@6S++NVYKfWb3KcE~19b5dg3d`#s_@LoC})g7`)LDL>>h2D*KUwc;Ls-ScTsm*mI zb2ik#;g?J!ep4tqMq^!P63)b#r@_KBPcI`?sSPkTk$!4c@VOW&5fqC?aF2Rf zA<9kkt3mO@^5wmO)vwjgpJw*6z?`h1kgF=u)^Oe(I^^PT^??mQ{9W7^iv-;4W6Mhz z0fWmk@`xse1VjeoS?v9Eo!7OQK~#QRfGZn3ceTY(x$_DHZ*T zpK_9C+Iu~Xd=?&Ro`kPW?|M|D(DC%<#usZp0W{_SQ}ra4+FhMc)in|8K(-)O`dF>r zDpmWt`1)J%_xRzSb9$+g=xfVq)HD*lS#)Ey(C|7_HBFlcQhMw`uHK$Yo&EAr92~x_ zvttMskaNMx!(i{80Po_p(i~~_EgRo&ESMiD&l9B}FJqR}RpC5Jg-huH6=AQP?@S1m zw*&Rbm^|)H>S)xWbH58O)jB#PZAX6o{NO}Niq9Mi&vwy_)X!5j$uhnFe3d)d;*SOA z)}6<8SlG+z7w;Y!sh8hvD~cbgRb6JAnVw5~n9g+b)52T7IFg#_Fp*%r_a>WYHSs$9 z*tZoWAL>LtZe8IS+>bSok9jc9H~3ZJq1v}7!o%_JEYn+W-6g^p`4jY^_d?M;*qtHN zU4GW(C$d#z83g1bZ-Y182<|~=p(&)*r>*!vsl}vHa|btDcSpO4)5v&wM_#xj`0c>* zc-A7{{nyOaNho=QnsuE40TANa=%k`G#pJzCte&-mT=nAa?sMN)GzQcIqiV)4)5fCg z(#HcN)P0mGZQF15W*wDETbd3kiK5tkfp!ZlsWFK7K*c)}pMv*ZcE7Kfc+P3~Nkra{ zkbo%J)WKP#nk3| zned>IPw4Zmmql+Mm92S2VXm-Os_PY#z<4ulY_6F&DBTT_%Oe+bJ zY35xH51W5NMTKqh4vnXR>%MYBG8-nG+2Ev~FKgPk4fV}-1OAS_ieatlXZ6}vD8FGn zgN8GXzE(Qh{!#L^Ly1sTO=Ll}_Rx>-@DMX=eouEi<@5 zIlTAtLstO;s&H8Zeje|9%%YLkFQ6b3xnkQ@oUS3*STAP8OU8n$p$e9N! zNBVc$#@_aQx%Ph_j7Ktl#i^J!dzZTn~X5MF1xXkkP=Aty|j32 zpxos)RAYP6XBDSUrHRm9n|~AZ^#i6M^_;14#4_~maczk;PKmsfhEuaTWyQ*4pX1j; zwA}0^B;K>7hzoU%r3FwzGNAUnyN93vY|}Xa)`j^kP30)}}?h%@!c3!2HK=zz2PMK^=>h9p$`t;M8C2b9~ zlhPC^vBE#!x`k#u{nm?zlk9rz7N*qBu<#$8iilPb=b*reE9-YR;bgp>Bg$85cd*JW zc#AqiIHQfWQOx34ikpNMf6*fn{Qb8+`bo~k2aVp_jGAzMJ*|170SqeARj6U@itm(& zT9s^V^5_qm#=X+3yqQ1(VE*}beV(I2+Hk)RN~?I`=ZyiImsgnuR!9At9?r0Wx^5N&l|!#M)}J>g;-J#ZN*>OXk^jQ zoLZPkWc1F@V$DXt?fyWXv*X9j*7JIxM%}AGc#r>Fs?Hl_LhgC_fRx9~t?i2%zKKbM z_gs}h1;f`IP4(S%lEucLtxTWC3OTe1R1v7oPvVhf6yl?EQvonurDKn&B>cFW3X_%^ zsXy6`US&SM+V97byv}dGFF`$BcJ@h@Ir@jGLwj~{J;mwHc*r0*eZM;^S#eYv7Z1Df z+Zdlk=OYhoV&>x(_4Ul@g_+bw?{ExEl)r^cwZGf3$7A_}$b@|Jp4xmeU)Of++@4;E7EdhmgvBH{ z);iXv+bCW&jwNy&X)}b{`3j??QDx(c6%#%`U5}jgsg5ekTuC_Jy-=kI1Q$;_%?+8f z&&-{n+zm8j_k0ai>JR2!DFupNAs-#T<&dC-`dvOfG+E_O_jGkuX4CpSmc{GVR*d|H zR<2mi3{w-1U02A+*!yZ;@<0a-!bOA)W)tVn?1KOB56U`+ZWN@D21Y=?$Bl%_{cP1d z2eO%0e0@xr8x@UdOl17tajPEnVNxh=chF-kXi3pZ$mPil($-8xf^GXA+3v5l;hQH< zb31D)G}6w6431kuHJIN1ZRF3a#=B&$OujrBBm9QETKsk%BOMOyZT%C%8}T#4oJ;&s zAf>cxzTVqwtm}bhSDM}0pCL9n8S*lEb{+ZbQNm>eQPn(us2 z0%z?4fCYf!1galTML#{4_B9;mZK0INd))~w5Ly6j7);TyvQU^I*1Xh6TYhKxbs-S_ za0<@6+V6Uwd>OX^8PF2s-XbZ3UHgeERr6zhKV5y}7SDX^w{cS1dD5Mm`3H!)eT<=S zbd0@m?>ClR|HiTy_i1<9dJGvb(_itiIid?ynl$)Rc=6jRKOK%6mqviq`pi;X${~HkEF)Ds zBut99x1n9LhUkGP3;xUTXG@p=d>nt!?GQRr8CCXhH6bC0?9QJKBDB2dFg^Pa5CquF ziM}z^QQ{^JhT{6av%Z87tu8_Q6t{xzlW?FbNEvx{rMxp$3meM z#3!a$O#2LYtOW(Cm#{#g77PH-=-GtoXqzhazL<04a8btE4t*>LfKB$oLzc;ffISPQ6vD)6BxxGqb58&PeEpD zAK0=dH%UZPPxCRK0qo-F+0ysO4T&~ z!A>~K-ZM29xBu1H;^7_BwtoBg4c{tB1e60KrNIdlKV_g1ED*R9mf3Svg`wmEdNWVZ(5FCz}v#Uf_ z{<+RtRRa3!!|`~{4`pTLtqY7>eOnD{S}r8`|NYS+_E5ezqKqMt@iY5%1|i7=j>cPY zz9^?+Kvuo8tRmmtS_PmD?)+`F z2D;|tRr9fz(zSaHE;MJ*Yp$r=MxjmNzTP?fqxZ(Tu!Ng$fG#P33E1}?Hpjx+&u~}{ z->x=;kxyd70-M_g-;VrkSz$Pu1m(LG>UL38)}6VTPH9At#;wBez$=E#;TWO;Rk+FT zJnza{ljYTwfe*zFng#OB&{*kC6R)@=!8lIrT$w;~!_ZEmDv*37L(Jl6r9oxrGjpKd zhbYza?;&>ed8BVS-$gWCkFscZbDjnvQAZRd!dXJAS9`)q?GhjUAg6A9WdX=P>bmRP z4*$Iw_~+e)l|D#Z7yowJJ-+Gyq6VO(7K+%XHyKZKccNfWSo34NBjmfCi6|&y@=m@gIamy z0eCnr;ny0jAgY7^`WVK6w7PU&FH$(>rF=L7FS}-A{I$tV3MxG>1jSw7gBe1-5xa)G zB6t+azb93je(4>k(}6;W39EayXEi8S3iNZ_cyJ0lmIJ7<67BE#LFj!UuM-}YjgX0| zhGo^k04w2Kj>{}v2-c0~A78?rg&qK#fpPdMUj7LeqU#7E9uFL3qTyO3yk-?kz2Tds z$2aI)mPSe40}Sy8ucQO$wb<=6F0KW6w!xt0AWk?Aa_HBFIX-O=(m66m<`MAZ*lSMQ zYrwb|j{%Sh;F?dJa<6x1!yCXQu6&Mzr1{SesF*rwFVf~7ty~BlM3m0e>aay{7y{M~ zE99b5WFlU1omjqTu_Bbc#ra6U?ZbhB;vua`gLY6$(4ac%B3Cm4c7YUd*ye9eRBnrI zEuc;hI4*SOssFP(4pJ+-8v|G4>QsSWRFn&Y@gzVAoBsGtZRRF3A1jp7Jkqs~YI9>r z1kj(!W#C|I^5zc^IJ_NHHG@gxfn2^su+C~Nncleml0N=mIOm~#!gP14Z=^7}kcg0D z2VaWV`MK=|vMx>D@fEX@k5n9IJ}pkjRV-%ricON_5 zAYh6!uO+Iq_OwCTvX+%AX4o^R%9Z5~PX>sWrCS9cSRHTdNul?PC~yj)Q$;yhZak(9 zqUm;<)t*&O%eEB7X$j1j_JOubyV)Zkre*kEb9Z^Xv6VOB7!Du%^lj-bE&LXDmDZer z(%|f+MweG`*q^I$>OckK!eNYcZut2R~ZmJ?<&jj7SKj9nW4J4*+oBu z=0$66QpCrR03~ji5S~RoLsH z19JPA4&$F7Q+V|XB1(Q=ppZ)rnhci%<)Pgsj;mZgzG^uAM%#Z)gzO?I6&R#ABI)K; zH=M4w5A*S|K~S}9+&k+VB)yi2D>)ZSmz%8DLEsBEdIh8}{%mPi_<|VX73gc7wSu1Z zo4GF@0IJAUBft>|hpjZ?y!4Kdry>Odnj0){BbsDTID9>3Ur=09ngleozwbAA!+<}w z|7fL3%@~xI$_^G;)$l<70A{zKH(xd$PPL9r zT*}6Ve~ZO^eXJ5btQ@PJqQj-cCAs2oz_aU2A0EEI8rCS`)^3IIczso?xJxhX?6DRr zWu+1~cv-Q-h)wz^yld~S%Ef6f|6kkxccCn905u3*f4q6c`f<^Qp(o;;llc2L&^$_G`+)TFvIYdiRgw@EI8pRc_^dR&!GMtmJlo``y(RdMq z6HjEVP_?}|J@%j!p1`n;@Lf%gMJ`vbt6G{2$5Y*3MHvg>51!}v?XYA=5#DiB&w4XM zp**ZgGFbnwbcf0Hl4ZO7T|D~o$d{Ot4M}9Yng~1lqkiT)Nxo5>svM160Q5x z)oTh=|6Je;bZIg)LPG>R;{O?}Fr#9y(P?+G6{ z0xg?Q(3-s#rdxZ9`-6_*Q(MrHG`+lh%AE!l;hOPKQd`HycK@}^n?S##miGSsTJL~O z(9O{ZL-t?3eOKcMn7~Y`*iBFzP{jnlVYrp%;@myqOgWZL*!5c%!~5Mn$E{E*-7Fp& zL_A3P#OAso#c;Dx$LZb~ul@47U-St9_jD;IzOFrEHUNM2PX)wtR>@@{pxW75#pqXF z)Zv@kL``isVNzkL)B;f~XsgBotNlCKyfE-Yu=g%z{02gN7%TD+ZTwv_M2k`@3vPkO zP6kDh!&H1v&IM)?cD+;KBG8rSZ7gEI*9EDnn|Bc60)J2F1>)^D7O3fU9CNW!T5jG+ zo39ht^<{RPCYRe4$^y(&W0-F00M6RRlOfV=QK7Bz?ibTWK317h`gjY6`g09joW?!* znW(9GxA~reER>VOH}U)t6QtHv~7 zY+Tj_8O`&yp9y!;)!}SlA6u_nD`-7MnK<3L^@0|`u9+{Zv$;J$#&(~sfC^f8b=bRe z^r}CUt#zj=CqKnftEPfRWCb4ZIhe+wI!fE#axg@j<1RL0w z+-~_2f_8D3#9g&Fb>$LZ^wVTcu5_wJSwbI2>_hZj8#?b! zMAMu*T6y&Be@3x<(3pA5ZoeyipKqzh_uHL2$=BQSQ9rYM`ZXOvX&AcQvAkvoRvL|# ztoZ@@Kp^Y~bwto6%rk6^FhUu-_1QF$V}zD_9#@Xzpt}~q33up;Wt2nDQ{uUU zw1ZM9aH&cN+P9f^lxG>;p}{;!VZ;g51T|TZBgG1SKIF5)d^=m9zvm&II$b-XPaw?V ze+d#7jT4?> za~}hj(b$!^^*(?S`s9Ip>}B0gY!`7$CH6n&0T`cD#@J1nY@1#jxoRVz=m%|}6o_Yv z-2$kvJoy8|scfl`EyFpF-o=h16CVNgwIPdbOqL|ZHYA%bwuP!BGJ-4v1Qb{;S6Z{Q zgYVxxXgR)kbgxMNn~}TBLx>1cFS0sRaiGtBKLUJc=oA=a? zb?s~OXKN4lb~b?kZjL;;W{lr@e6lAJL~8oWoYMuxh3J;)?_odWDY1w5rev;IMo#(V z0|n*V-3(1dGjmjBX_TbP70ycxS05BA6p+we=&N`(7$YNYMfcOPnw@mwHqh~M${l;A zFSqNTELpmTB*@%(x z1(J(;p6HsQ)WU5y^WBAJdRlbamb=nR!MVbb8J+!NcoP!7QSU8yZb@IE2(bG6{7CM} zXN(K)pR>RQ40|9bP-0DT{*uQ;1Y^-(UsQxPi-x_`*PCv<|I!6_0R{m|RjLCR^@^=_ z%aN+x`idPHcu~tY)hmiuJ2Zh{A=S{rzQ5mjzHC};RZOqyZ9qC_)b6P{Nhaln_|ZIk zjoS3A^81YpytY#9UaMSnep?N;s z;-da>^W3qMmE6wISx$K6BZ5_ZR)~pwK4yP2=B{B7y(A=M(>|mTqw992(`fQsXH9j2 z-lm-dAF8UTqS_jhHB661r~aerR=`o;mMp7+`~70t@RM#QP68kB@!|1$wqK z_N(pcyu{tY{3e}ql{VEXcSurc#|>OAeLc-Y#tI}I9Q*WzLCF@)IxUFkiKK<`BQc|dm`FqcWjXNstbjTpb-uv4#JXNd2SxYz-Gr))ht*())^ zEB-V;=AM=K+D!Nc_%?Ku*w4ExX-eT`m!7KIBi9YERvzsQzX5C&2Y(7IZ>M%br?AFx zEu;LKlHahv$cZ8$qb3Sh&AdRA+r%d8ZOkLF=go3;U6>n&ZtuSt0fpto)`XVjLT8-AEd3z2A>lV5L1~YrFfiKCLaV;>3i`T z>ocA5I{WQeuTD~$WF-VP$AdXE-No7-{pS~N5tU#74lZxyO(BWBytzg`zMmK!nZopC z1oN3D#%+i{)jnxf*~|aP5^G$o&WYDY+`e)eZ(hG&jtAQt=#$E8`m>6AKlejvNBM#W zyCkwzIV!cw5s0MSMlKF0WJ%PT`&KfrlP~w=5BFL(@BZ{dCYYNxIcyPlzssTIDjaC7 zEyAan8C(>sBo}>ey1%DT<$22PZL<;8{M~@&suM3OnlB*4luo;U9brFKBU+P%Tulcu z!LV5__nV%QN33wQ*76qctKGqt=eMojd~~7x%oYRtiom-@0?9(eb(q|a&k@!6Q@}{m zgLLjWjVIJU9ja`!UBdm3M$+!}yeaK^J|ySJO$uu#_8UcTz#F6Hu-Be#r*i`fyt(a zG0W=0J9*;3b+b*@Z+cYxIiW$$%e7Kxk73Sd>wd<~YbqE(mh?_f&eSh!yf(8Q&&$;B zNprXqW`Hb$t@UTJbMU9{!kgHUvOui$vD~TWU2-{hxhMLV=_cIiHS3Rw=uj20=MerX z_=5{)NZrStV@Khx=;+dCfMWd7pEs804;_tfH|wSbSeZFL6b+U0G#k<6Hbyk(TSc#z zbUrZ5o*=&cd;rDHiKstvABk$dsUO9ai(PRLs>uD<#xLV#A53pM_u*XPs3Y#Iu%-wR z)8tN%CetG^k)ApZKS2UkV=8GAg6G|?kILycFbNxQn3@kJC7wUVg)bm>;+gn$xclLK z`Z_X?`6eR8qxEoHMb*a)Kq2C61bkbOoY>pAk@q2Th)4&di>us)3RIiU4_>mo<8y&c z98Qo+OmORxk5G(94WH4{eG#zNrCmc(687I#zvCmHC$BVyQInGzqiYvYwT;eaR`kZ< z-~Oymz`t5hv1c^-BldF@BA_{+nVUeexTdh{IUOHjn<`Z5ze@{pab`QN9SB4qWcE?R zc-j7~21O<@_J%@V?N$WGeyu?5bw44Z`T2Uw7^#vN^~1G`c``%jT((hs^(N_NzL68w{i#*FYjCo)&I0{fNC}?^ ziZMCOiXelbF7koJ=OezY8bhwAcg-4w)k0SJY<^da+NYs-WGz$H{ldn%&blIegNASpLw*y=m46!LKks}?5jql~%+~tT zd3=%ewn+L=P9`QfH9ucqO1+IhJr%RoW6n$6ZL|L%jTj!wJ*ULSn;%_>|8E1oCh`xO zZe!LlQdsDeJKk-?Z+p5$B6j`o?Z>&}W_c32<0S z(J#$Ngldi+oL$dFV|Y4n!VQUuA-aOd(C7E%bnqvU&Zyu21P&~?dV>qs&YgilU|zyM z?DcOAVgF=@|J~;UdPfkTf;BWW>!=?zh>(S6*o`d))|R6i7rk-O1P38?2XT^E3i1DI z2J$_kVU#7J+Z%(YUFik;&2C;lMx~FBl}P#eb%Xog593;h06ley^Zv$naN@@)U%WV8 zS#UQ-P`4{{HceuGetBDYj2XS>TH#h-sA)J|rbOWu!-H@KbdRSeox2&BtxzKsWTQ9z z5gDiq`x9415TYv!!}AY#Q*E;}yH6Bm0Xw@Pt@2Qq*}gXcTj5~Z)Dx$-8O{~~{kM>( z5B%Me#CkUZ?!u9MytXhnpBeLU*O~+8o@)O22cxEUTg+#!(ssMe<80@oS)px$-y;rK*ldPajW_nGBZuz+mO`*qNZ%kTw z6-Ik_U)?s$-({tSlr3la0{N{kbCSMuom$8fmPYN9>`vZcVvD!lGo>Pxb)1YQVjsN{ z)3NMJNc(&O>H8E*Nx9?Q`PcVH2|xByxvt~{kQ&ycPV9!dH<*xhi885qcMVK$-P zmYmQ0vbmjsgP=PL=N5sWcRgTN`Me z;dnNw`5_Qb&R{;(YT@TL?a^b%8E7Uz5^>}3p?~QXeqUMr>Hxb%F=9@|GX=?sJlSx@@*ffK#6JT_!bvdvC z{Xf-d5R4^_jH^S7ONvt=x|1;O2aO6LC_W!-(1?g?3@ll)!vO-GQ#$#Yv>TO&T1POj~09rMlDHYhUgwzaG+fS>Kl zlho!Jb-+^(Y%`SqL}_8Zf6=^syOzDF9#5qq>Mkh8l8fG@RVUpyj&wxjZggbnrR8p_7OmI|au7BPc04~FAPkm&2s;Sb4-r}u9 z;xVao_Tc{pd=KwwcZJ#jq2*n_qM$wcw0YyI+plsh$PAc?*GPjNei1wA#cgnKvwQ|pSZ7f`tfX&?mV~5nj5EH|( zozW&|mg3zX{RxWcp6V5l+ANv<+_;trB@&j+jy?ZuS;Yx%hKo^}?Ps`>1kO!dAzO*i zuwFh{hHMF zk`Zf9O~nRF^tpOop{?LO7bln4_tK8IQ|^r= zalTVa?1N)$F};AaU(LF)r;+mThT*tbOqQa-zWUZiR{$46P09P=ULesqEN?5cQ>;6|c$GU0!JM{NHou(im#Zo=5!iBqn9S@#GZj@_X zMfL<-UvP`voM+RS3_q^G5zht$)0CdOZxhEA<<+!JXL#LMxzhJ91*-JLWCd_iH#-id z&W6c+K|NGcU;hqiaa{xHo8iNlq!qOB};Db&`)ZVQ+1>39W) zaDdOP5B%yFmq!=JrS$sz8%igTQm7+<1EXCS20>y=pi5R%& zW#xGfZX@hd^0~3!r9#dGzMK|r$PPQ^l(x&0-)z>AIopakLdyjQt~1i>r@jwCr5U5~cwM*f~Gm}h9NC@U@9>&TNsAgIgSGhv!AdUc#()%eJ^ zD#O^Gi%)e+ohBUUe=|bx$A&R<@gI4dk$c6Lu=jkgrfMsf1rVP5I5dJ$k`qI?*$X2y_n5S)6_?iWsq5WeK5mMKudUVcbs@8 zXJ=^_sF)iiEb68ud+-{a+J6+N1=Dbd#W~2@ysi+A`9FodWmr{P_ddKx#ef6S2&i;- zNU8`(=cY@#L8Kcc6ayo|Knla{>W85R| z4US;HC`NJFJ$RuCs-h1|(eo>TL}9H{>?{CJ{$fD?1cS`#sRY%=2g4hU{bxD>ibfEY7& z3wt;SVK38CL!(my3vYxtND(9cdj|sp)*blD2=}O!8R~|;|D3CPVtZjT1AD1By}Fs^ zVc)AOL&@y|(_hVNpK(pOuwgGfAtJ0!ja4@uD=l2{$*hQ@a_iXw%+uX=i}S+*tNhMz zx$chq84I03dTI=W(}!$Z5(M2R{f0UxPVSNC@EQB6r$~b`ZVoUpzi+rJO}`BScL8W^ zdOZ%)n`MEYsE1S@5BDif=4K<;4CZmSjhC8kH2}%OiriXT^y9XgJ38GmiN)`y%a}g7S1cV zRO*@BHNt2l6IL{LI_7(n>jXJ|UML2vJrtI?C8JDBv7ZZWHWG<`9aj=n`Ggb=y42%w zOgua8OgKz@y;79(^iUU+z!-=t%_C|hKx4<`QkL_k*2$%1!Jh;oOc014V9JFd7m+G$ zurfxxg5S}L+pLMYT_6G& zcLNIU5I&aR*Q&n#@IG)y9#~3u4+t%2f*Wqkpt1Mc2Y2qjVkd#5rX5Ly;J(skeGOn( z92Fz~(TdH6|0vz!K_c&aSD0y@#`N!BSW&{;!w2Oy@zTw9a83hos;_mS^3Q*kTZ<+$ z6C+&zx>!Kx4vtR5wt=T=hDk3DVzlA7!I#z7zo5U@8*=0(T6fI>3f@wm~!jnn+b zwCj!a-^T{mCbX9Y-f0ASBn!AIVXf9lu8OOF<(}-RD0b<*E;!_)yeUw%J9iot zkKHjq$^;)cJLywQU2d)qME__N2FKIkaK%u7L|z?IUo<^BuIO>L2jibq;VB_Jm$U=t z3^TCqPehLE7wf=&v2?ab(!&2$e1Er=APYdSxyGkNX=7+}0Hh>8hb-Ir_%o!$BB^tT zlOs4Ow{(*#G!r7!^UQtha!=l~l&f1hPDUL7AV-7|WS^+VM&yMan^~A`8-E+_?#EoE z*RB<&lMe)`2{(|`ivPP-;YAPo6+=v8t$wuR&MGaPo6jGp*YkxsF^;n}xcQ)oe)?dc z6L>ANls|ff`hF!FcVAi&fJVA1nj&>uyTApRZg9%Se}wfLRGar)_SSCId%gI@qusg+ zdk~tnnOiR+TSq(fsxr-EG=!cV`eVEOUEF_7G4f|PUjiACZN;!{4(Jp3N^)y&kvg-0 zmcC(@y*%ks*~8{L`4K%^(d~>)uhhFdpNsqV?GmBEMA7y5GLV&fOoJ$|=XIwXK}XFC z@^65;lz0GcB$@}1Nql=q0$_A&&kup}qUL^^A*;t~RooE0)H_I09xaYY=$js>Ndvjq zn8-!?m2!e~px@BH7aKx5tW)A6jcsZ}2M)mjn0?jf zj1l?fqEwpdG?*b|Y-sFFh@3yRp(H0Mcpc)Kr~9=IlIPDKK_OfAf=_oL;#1)H;#q^VHYAXvda29zXDt;ecF zcKd%k5THFlno#O8 #@kx_<;bP@GybR6$eBTJ>QwvAdT97ll^jmh-B^&$e2XCMjp zEtGdXy1ypP%|>u3g*^dSZK!9d>bz3(Y8qPH)IC^V5kKXm?i-_kmkzlehoId;1gdd+ zzma-}w%pfzX5Jsx72*-FTd&VbL3T6WP0*W3Xg_WE&-Fy+{{&pH6376|crRM2`bs$^ zmDu;|L2x*r87DKaG`2mv6BZ%av1$^S=>GEa6uqh-a#2@q$&+2T(GUmu+iX}bJ<(T2 zy~b89K2gGOl8sUpd?dBOu8SF?o6a};l?YOyGuWwPGCs1pw{fttu=&X4UizQFY=FcC z#!7g7GnlF_xZi#Zu8Yr<51a{0qe@1b z&i?^EV#Uc`;SspGPDzqs;|YVwuun+5O-$sA2m!%>AW)L@N66-wJ<;jz#d5L5(EH;PQxle9ux>^qHsy)EA}ny5Mm4jo=LJ+-K5&VGbq`$ki!>l@HvI1q#n9I}h}2pb<2B;GS6Dk9(y=IaO9G)Ryh@D4z6i82)nMC1H`_6p+=8%Br;GSrChu$%p# zxB+TJd2(m-+LL1ZViVmVAS4CRU%$I|=1ae#RBQh{l{4$FkX}E--6Znm-Wd$Q^1xmJ zy8LVe>%RkXrCgIL0ymgz*DrCU#ggYE85A@?8TOpX=aA~dwTJt%z5{gVs1gKyr=bso*4;E z+zT9R?{I{~`9Gns{dZf!EBYV;V(%rv;{BG;xLBS@F-X?im&=U@SiZn7!nQiXqp#BR76*< zANt&%uB%4=|M~z8OQ@9|0unU!o!wEi`L6=N9UqUNyuR@Wh_9_!H5^+G zlch}#2awFScoPE$g%T&OkU-KM=Y_4XS`ISU_OmmG3tkp1dA7px|Bv#1FM+HKTy=~W zll}RWufopjL-~6rFw|w{mkI@Kb;ut4)zUa;Ea+D-I|6|DqyAbs-6D;F;VuqD?lBA2 zKYuU0ul-k6PG3^^jzg7l8_>ZK(Z+?yMG3~FfI2Jlc&QL=X%UJ3S8v9j!Tk9F*au)v z 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 T9r_7~-$;ncJ}-Et_vZfrn`!&_ literal 53065 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 delta 11742 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 + /// Looks up a localized string similar to EndUser.PlatformRolesUnassigned. + /// + public static string EndUserApplication_PlatformRolesUnassigned { + get { + return ResourceManager.GetString("EndUserApplication_PlatformRolesUnassigned", resourceCulture); + } + } + /// /// Looks up a localized string similar to EndUser.TenantRolesAssigned. /// diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx index e7167c26..7dcfe3bc 100644 --- a/src/Application.Interfaces/Audits.resx +++ b/src/Application.Interfaces/Audits.resx @@ -48,6 +48,9 @@ EndUser.PlatformRolesAssigned + + EndUser.PlatformRolesUnassigned + SingleSignOn.AutoRegistered diff --git a/src/Application.Interfaces/UsageConstants.cs b/src/Application.Interfaces/UsageConstants.cs index 50b79947..fe0cfaeb 100644 --- a/src/Application.Interfaces/UsageConstants.cs +++ b/src/Application.Interfaces/UsageConstants.cs @@ -43,6 +43,7 @@ public static class UsageScenarios public const string Audit = "Audited"; public const string BookingCancelled = "Booking Cancelled"; public const string BookingCreated = "Booking Created"; + public const string GuestInvited = "User Guest Invited"; public const string MachineRegistered = "Machine Registered"; public const string Measurement = "Measured"; public const string PersonRegistrationConfirmed = "User Registered"; diff --git a/src/Application.Persistence.Common/Extensions/Tasks.cs b/src/Application.Persistence.Common/Extensions/Tasks.cs new file mode 100644 index 00000000..9a5eeeda --- /dev/null +++ b/src/Application.Persistence.Common/Extensions/Tasks.cs @@ -0,0 +1,22 @@ +using Common; + +namespace Application.Persistence.Common.Extensions; + +public static class Tasks +{ + /// + /// Runs all the specified and returns the first if any + /// + public static async Task> WhenAllAsync(params Task>[] tasks) + { + var results = await Task.WhenAll(tasks); + + var hasError = results.Any(result => !result.IsSuccessful); + if (hasError) + { + return results.First(result => !result.IsSuccessful).Error; + } + + return results.All(result => result.Value); + } +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs index 2ad4e770..82b43fee 100644 --- a/src/Application.Resources.Shared/EndUser.cs +++ b/src/Application.Resources.Shared/EndUser.cs @@ -56,4 +56,13 @@ public class Membership : IIdentifiableResource public List Roles { get; set; } = new(); public required string Id { get; set; } +} + +public class Invitation +{ + public required string EmailAddress { get; set; } + + public required string FirstName { get; set; } + + public string? LastName { get; set; } } \ No newline at end of file diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 74a43324..13dd8560 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -19,7 +19,8 @@ Task> RegisterMachinePrivateAsync(ICallerContex string? timezone, string? countryCode, CancellationToken cancellationToken); - Task> RegisterPersonPrivateAsync(ICallerContext caller, string emailAddress, + Task> RegisterPersonPrivateAsync(ICallerContext caller, string? invitationToken, + string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/INotificationsService.cs b/src/Application.Services.Shared/INotificationsService.cs index a57fba2f..684acd85 100644 --- a/src/Application.Services.Shared/INotificationsService.cs +++ b/src/Application.Services.Shared/INotificationsService.cs @@ -8,6 +8,12 @@ namespace Application.Services.Shared; /// public interface INotificationsService { + /// + /// Notifies a user, via email, that they have been invited to register with the platform + /// + Task> NotifyGuestInvitationToPlatformAsync(ICallerContext caller, string token, + string inviteeEmailAddress, string inviteeName, string inviterName, CancellationToken cancellationToken); + /// /// Notifies a user, via email, to confirm their account registration /// diff --git a/src/Application.Services.Shared/IUserProfilesService.cs b/src/Application.Services.Shared/IUserProfilesService.cs index 6f6009d6..1c05bd1a 100644 --- a/src/Application.Services.Shared/IUserProfilesService.cs +++ b/src/Application.Services.Shared/IUserProfilesService.cs @@ -16,4 +16,7 @@ Task> CreatePersonProfilePrivateAsync(ICallerContext Task, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); + + Task> GetProfilePrivateAsync(ICallerContext caller, string userId, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/IWebsiteUiService.cs b/src/Application.Services.Shared/IWebsiteUiService.cs index 0d76a3ba..d3dd5cb9 100644 --- a/src/Application.Services.Shared/IWebsiteUiService.cs +++ b/src/Application.Services.Shared/IWebsiteUiService.cs @@ -6,4 +6,6 @@ namespace Application.Services.Shared; public interface IWebsiteUiService { string ConstructPasswordRegistrationConfirmationPageUrl(string token); + + string CreateRegistrationPageUrl(string token); } \ No newline at end of file diff --git a/src/CarsInfrastructure/Persistence/CarRepository.cs b/src/CarsInfrastructure/Persistence/CarRepository.cs index f06b5431..d3a9afd3 100644 --- a/src/CarsInfrastructure/Persistence/CarRepository.cs +++ b/src/CarsInfrastructure/Persistence/CarRepository.cs @@ -5,13 +5,13 @@ using CarsApplication.Persistence.ReadModels; using CarsDomain; using Common; -using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; using Unavailability = CarsApplication.Persistence.ReadModels.Unavailability; +using Tasks = Common.Extensions.Tasks; namespace CarsInfrastructure.Persistence; diff --git a/src/Domain.Services.Shared/DomainServices/ITokensService.cs b/src/Domain.Services.Shared/DomainServices/ITokensService.cs index 49e020f0..e25fb310 100644 --- a/src/Domain.Services.Shared/DomainServices/ITokensService.cs +++ b/src/Domain.Services.Shared/DomainServices/ITokensService.cs @@ -7,6 +7,8 @@ public interface ITokensService { APIKeyToken CreateAPIKey(); + string CreateGuestInvitationToken(); + string CreateJWTRefreshToken(); string CreatePasswordResetToken(); diff --git a/src/Domain.Shared.UnitTests/EmailAddressSpec.cs b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs index 6d444e7d..4c45a701 100644 --- a/src/Domain.Shared.UnitTests/EmailAddressSpec.cs +++ b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs @@ -27,7 +27,7 @@ public void WhenConstructedAndInvalidEmail_ThenReturnsError() [Fact] public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName() { - var name = EmailAddress.Create("auser@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("auser@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Auser"); name.LastName.Should().BeNone(); @@ -36,7 +36,7 @@ public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName() [Fact] public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsName() { - var name = EmailAddress.Create("afirstname.amiddlename.alastname@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("afirstname.amiddlename.alastname@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Afirstname"); name.LastName.Value.Text.Should().Be("Alastname"); @@ -45,7 +45,7 @@ public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsNam [Fact] public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName() { - var name = EmailAddress.Create("afirstname.alastname@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("afirstname.alastname@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Afirstname"); name.LastName.Value.Text.Should().Be("Alastname"); @@ -54,7 +54,7 @@ public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName() [Fact] public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName() { - var name = EmailAddress.Create("afirstname+anothername@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("afirstname+anothername@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Afirstname"); name.LastName.Should().BeNone(); @@ -63,7 +63,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName() [Fact] public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturnsName() { - var name = EmailAddress.Create("afirstname+9@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("afirstname+9@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Afirstname"); name.LastName.Should().BeNone(); @@ -72,7 +72,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturns [Fact] public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsNameWithFallbackFirstName() { - var name = EmailAddress.Create("-@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("-@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName); name.LastName.Should().BeNone(); @@ -81,7 +81,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsN [Fact] public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNameWithNoLastName() { - var name = EmailAddress.Create("afirstname.b@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("afirstname.b@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be("Afirstname"); name.LastName.Should().BeNone(); @@ -90,7 +90,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNa [Fact] public void WhenGuessPersonNameFromEmailAndGuessedFirstAndLastNameNotValid_ThenReturnsNameWithNoLastName() { - var name = EmailAddress.Create("1.2@company.com").Value.GuessPersonName(); + var name = EmailAddress.Create("1.2@company.com").Value.GuessPersonFullName(); name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName); name.LastName.Should().BeNone(); diff --git a/src/Domain.Shared/EmailAddress.cs b/src/Domain.Shared/EmailAddress.cs index 13969ca1..918e26dc 100644 --- a/src/Domain.Shared/EmailAddress.cs +++ b/src/Domain.Shared/EmailAddress.cs @@ -38,7 +38,7 @@ public static ValueObjectFactory Rehydrate() } [SkipImmutabilityCheck] - public PersonName GuessPersonName() + public PersonName GuessPersonFullName() { var name = GuessPersonNameFromEmailAddress(Value); diff --git a/src/Domain.Shared/Roles.cs b/src/Domain.Shared/Roles.cs index 9b8975e5..770db80f 100644 --- a/src/Domain.Shared/Roles.cs +++ b/src/Domain.Shared/Roles.cs @@ -50,6 +50,23 @@ public static Result Create(params string[] roles) return new Roles(list); } + public static Result Create(params RoleLevel[] roles) + { + var list = new List(); + foreach (var role in roles) + { + var rol = Role.Create(role); + if (!rol.IsSuccessful) + { + return rol.Error; + } + + list.Add(rol.Value); + } + + return new Roles(list); + } + private Roles() : base(new List()) { } diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index d2daa5bc..0492b61a 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -5,8 +5,10 @@ using Common.Configuration; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces; using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; using Domain.Shared; using EndUsersApplication.Persistence; using EndUsersDomain; @@ -24,11 +26,12 @@ public class EndUsersApplicationSpec { private readonly EndUsersApplication _application; private readonly Mock _caller; + private readonly Mock _endUserRepository; private readonly Mock _idFactory; + private readonly Mock _invitationRepository; private readonly Mock _notificationsService; private readonly Mock _organizationsService; private readonly Mock _recorder; - private readonly Mock _repository; private readonly Mock _userProfilesService; public EndUsersApplicationSpec() @@ -51,9 +54,10 @@ public EndUsersApplicationSpec() settings.Setup( s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny())) .Returns(""); - _repository = new Mock(); - _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + _endUserRepository = new Mock(); + _endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + _invitationRepository = new Mock(); _organizationsService = new Mock(); _organizationsService.Setup(os => os.CreateOrganizationPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -68,16 +72,16 @@ public EndUsersApplicationSpec() _notificationsService = new Mock(); _application = - new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, _notificationsService.Object, - _organizationsService.Object, - _userProfilesService.Object, _repository.Object); + new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, + _notificationsService.Object, _organizationsService.Object, _userProfilesService.Object, + _invitationRepository.Object, _endUserRepository.Object); } [Fact] public async Task WhenGetPersonAndUnregistered_ThenReturnsUser() { var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); var result = await _application.GetPersonAsync(_caller.Object, "anid", CancellationToken.None); @@ -92,24 +96,27 @@ public async Task WhenGetPersonAndUnregistered_ThenReturnsUser() } [Fact] - public async Task WhenRegisterPersonAndNotAcceptedTerms_ThenReturnsError() + public async Task WhenRegisterPersonAsyncAndNotAcceptedTerms_ThenReturnsError() { - var result = await _application.RegisterPersonAsync(_caller.Object, "anemailaddress", "afirstname", "alastname", + var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", + "afirstname", + "alastname", "atimezone", "acountrycode", false, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUsersApplication_NotAcceptedTerms); } [Fact] - public async Task WhenRegisterPersonAndNotExist_ThenRegisters() + public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegistration() { + var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); + .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -133,9 +140,9 @@ public async Task WhenRegisterPersonAndNotExist_ThenRegisters() } }); - var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname", - "alastname", - Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None); + var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", + "afirstname", + "alastname", null, null, true, CancellationToken.None); result.Should().BeSuccess(); result.Value.Id.Should().Be("anid"); @@ -152,24 +159,44 @@ public async Task WhenRegisterPersonAndNotExist_ThenRegisters() result.Value.Profile.DisplayName.Should().Be("afirstname"); result.Value.Profile.EmailAddress.Should().Be("auser@company.com"); result.Value.Profile.Timezone.Should().Be("atimezone"); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); _organizationsService.Verify(os => os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", OrganizationOwnership.Personal, It.IsAny())); _userProfilesService.Verify(ups => ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - Timezones.Default.ToString(), CountryCodes.Default.ToString(), It.IsAny())); + null, null, It.IsAny())); + _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [Fact] - public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration() + public async Task WhenRegisterPersonAsyncAndAcceptingGuestInvitation_ThenCompletesRegistration() { + _caller.Setup(cc => cc.CallerId) + .Returns(CallerConstants.AnonymousUserId); + var tokensService = new Mock(); + tokensService.Setup(ts => ts.CreateGuestInvitationToken()) + .Returns("aninvitationtoken"); var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), + EmailAddress.Create("auser@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), "auser@company.com", + It.IsAny())) + .ReturnsAsync(Optional.None); _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => @@ -195,8 +222,8 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration( } }); - var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname", - "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None); + var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "auser@company.com", + "afirstname", "alastname", null, null, true, CancellationToken.None); result.Should().BeSuccess(); result.Value.Id.Should().Be("anid"); @@ -213,13 +240,15 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration( result.Value.Profile.DisplayName.Should().Be("afirstname"); result.Value.Profile.EmailAddress.Should().Be("auser@company.com"); result.Value.Profile.Timezone.Should().Be("atimezone"); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync("aninvitationtoken", It.IsAny())); _organizationsService.Verify(os => os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", OrganizationOwnership.Personal, It.IsAny())); _userProfilesService.Verify(ups => ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - Timezones.Default.ToString(), CountryCodes.Default.ToString(), It.IsAny())); + null, null, It.IsAny())); _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -227,7 +256,135 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration( } [Fact] - public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail() + public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenRegisters() + { + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + _userProfilesService.Setup(ups => + ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + Id = "aprofileid", + Type = UserProfileType.Person, + UserId = "apersonid", + DisplayName = "afirstname", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + EmailAddress = "auser@company.com", + Timezone = "atimezone", + Address = + { + CountryCode = "acountrycode" + } + }); + + var result = await _application.RegisterPersonAsync(_caller.Object, "anunknowninvitationtoken", + "auser@company.com", + "afirstname", + "alastname", null, null, true, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anid"); + result.Value.Access.Should().Be(EndUserAccess.Enabled); + result.Value.Status.Should().Be(EndUserStatus.Registered); + result.Value.Classification.Should().Be(EndUserClassification.Person); + result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); + result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); + result.Value.Profile!.Id.Should().Be("aprofileid"); + result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); + result.Value.Profile.Address.CountryCode.Should().Be("acountrycode"); + result.Value.Profile.Name.FirstName.Should().Be("afirstname"); + result.Value.Profile.Name.LastName.Should().Be("alastname"); + result.Value.Profile.DisplayName.Should().Be("afirstname"); + result.Value.Profile.EmailAddress.Should().Be("auser@company.com"); + result.Value.Profile.Timezone.Should().Be("atimezone"); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync("anunknowninvitationtoken", It.IsAny())); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", + OrganizationOwnership.Personal, + It.IsAny())); + _userProfilesService.Verify(ups => + ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", + null, null, It.IsAny())); + _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task + WhenRegisterPersonAsyncAndAcceptingGuestInvitationWithExistingPersonsEmailAddress_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns(CallerConstants.AnonymousUserId); + var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + var otherUser = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(otherUser); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), "auser@company.com", + It.IsAny())) + .ReturnsAsync(new UserProfile + { + Id = "anotherprofileid", + Type = UserProfileType.Person, + UserId = "anotherpersonid", + DisplayName = "afirstname", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + EmailAddress = "anotheruser@company.com" + }.ToOptional()); + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + + var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "auser@company.com", + "afirstname", "alastname", null, null, true, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, + Resources.EndUsersApplication_AcceptedInvitationWithExistingEmailAddress); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync("aninvitationtoken", It.IsAny())); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "auser@company.com", + It.IsAny())); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _userProfilesService.Verify(ups => + ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _notificationsService.Verify( + ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyEmail() { var endUser = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; endUser.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value); @@ -253,7 +410,7 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail( Timezone = "atimezone" }.ToOptional()); endUser.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); - _repository.Setup(rep => + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(endUser); _notificationsService.Setup(ns => @@ -262,8 +419,9 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail( It.IsAny())) .ReturnsAsync(Result.Ok); - var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname", - "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None); + var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", + "afirstname", + "alastname", null, null, true, CancellationToken.None); result.Should().BeSuccess(); result.Value.Id.Should().Be("anid"); @@ -280,6 +438,8 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail( result.Value.Profile.DisplayName.Should().Be("afirstname"); result.Value.Profile.EmailAddress.Should().Be("anotheruser@company.com"); result.Value.Profile.Timezone.Should().Be("atimezone"); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); _organizationsService.Verify(os => os.CreateOrganizationPrivateAsync(_caller.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -292,7 +452,70 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail( } [Fact] - public async Task WhenRegisterMachineByAnonymousUser_ThenRegistersWithNoFeatures() + public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_ThenRegisters() + { + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + _invitationRepository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _userProfilesService.Setup(ups => + ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + Id = "aprofileid", + Type = UserProfileType.Person, + UserId = "apersonid", + DisplayName = "afirstname", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + EmailAddress = "auser@company.com", + Timezone = "atimezone", + Address = + { + CountryCode = "acountrycode" + } + }); + + var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", + "afirstname", + "alastname", null, null, true, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anid"); + result.Value.Access.Should().Be(EndUserAccess.Enabled); + result.Value.Status.Should().Be(EndUserStatus.Registered); + result.Value.Classification.Should().Be(EndUserClassification.Person); + result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); + result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); + result.Value.Profile!.Id.Should().Be("aprofileid"); + result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); + result.Value.Profile.Address.CountryCode.Should().Be("acountrycode"); + result.Value.Profile.Name.FirstName.Should().Be("afirstname"); + result.Value.Profile.Name.LastName.Should().Be("alastname"); + result.Value.Profile.DisplayName.Should().Be("afirstname"); + result.Value.Profile.EmailAddress.Should().Be("auser@company.com"); + result.Value.Profile.Timezone.Should().Be("atimezone"); + _invitationRepository.Verify(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", + OrganizationOwnership.Personal, + It.IsAny())); + _userProfilesService.Verify(ups => + ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", + null, null, It.IsAny())); + } + + [Fact] + public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFeatures() { _userProfilesService.Setup(ups => ups.CreateMachineProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), @@ -344,12 +567,12 @@ public async Task WhenRegisterMachineByAnonymousUser_ThenRegistersWithNoFeatures } [Fact] - public async Task WhenRegisterMachineByAuthenticatedUser_ThenRegistersWithBasicFeatures() + public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithBasicFeatures() { _caller.Setup(cc => cc.IsAuthenticated) .Returns(true); var adder = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(adder); adder.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value); adder.AddMembership("anotherorganizationid".ToId(), Roles.Empty, Features.Empty); @@ -409,11 +632,11 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns() var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, Optional.None); - _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None); - _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); var result = await _application.AssignPlatformRolesAsync(_caller.Object, "anassigneeid", @@ -425,6 +648,32 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns() } #endif +#if TESTINGONLY + [Fact] + public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns() + { + _caller.Setup(cc => cc.CallerId) + .Returns("anassignerid"); + var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assignee.Register(Roles.Create(PlatformRoles.Standard, PlatformRoles.TestingOnly).Value, + Features.Create(PlatformFeatures.Basic).Value, + Optional.None); + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + .ReturnsAsync(assignee); + var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None); + _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) + .ReturnsAsync(assigner); + + var result = await _application.UnassignPlatformRolesAsync(_caller.Object, "anassigneeid", + [PlatformRoles.TestingOnly.Name], + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Roles.Should().ContainSingle(PlatformRoles.Standard.Name); + } +#endif + #if TESTINGONLY [Fact] public async Task WhenAssignTenantRolesAsync_ThenAssigns() @@ -436,13 +685,13 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns() Optional.None); assignee.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); - _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None); assigner.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); - _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); var result = await _application.AssignTenantRolesAsync(_caller.Object, "anorganizationid", "anassigneeid", @@ -463,7 +712,7 @@ public async Task WhenFindPersonByEmailAsyncAndNotExists_ThenReturnsNone() ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None()); @@ -483,7 +732,7 @@ public async Task WhenFindPersonByEmailAsyncAndExists_ThenReturns() ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(endUser.ToOptional()); @@ -499,7 +748,7 @@ await _application.FindPersonByEmailAddressAsync(_caller.Object, "auser@company. public async Task WhenGetMembershipsAndNotRegisteredOrMemberAsync_ThenReturnsUser() { var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None); @@ -522,7 +771,7 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser() EmailAddress.Create("auser@company.com").Value); user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.PaidTrial).Value); - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None); @@ -544,7 +793,7 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser() [Fact] public async Task WhenCreateMembershipForCallerAsyncAndUserNoExist_ThenReturnsError() { - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Error.EntityNotFound()); var result = @@ -560,7 +809,7 @@ public async Task WhenCreateMembershipForCallerAsync_ThenAddsMembership() var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, EmailAddress.Create("auser@company.com").Value); - _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); var result = diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs new file mode 100644 index 00000000..9096fef5 --- /dev/null +++ b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs @@ -0,0 +1,328 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using EndUsersApplication.Persistence; +using EndUsersDomain; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; +using PersonName = Application.Resources.Shared.PersonName; + +namespace EndUsersApplication.UnitTests; + +[Trait("Category", "Unit")] +public class InvitationsApplicationSpec +{ + private readonly InvitationsApplication _application; + private readonly Mock _caller; + private readonly Mock _repository; + private readonly Mock _notificationsService; + private readonly Mock _recorder; + private readonly Mock _tokensService; + private readonly Mock _userProfilesService; + + public InvitationsApplicationSpec() + { + _recorder = new Mock(); + _caller = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + var settings = new Mock(); + settings.Setup( + s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny())) + .Returns(""); + _repository = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + var endUserRepository = new Mock(); + endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + _userProfilesService = new Mock(); + _notificationsService = new Mock(); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateGuestInvitationToken()) + .Returns("aninvitationtoken"); + + _application = + new InvitationsApplication(_recorder.Object, idFactory.Object, _tokensService.Object, + _notificationsService.Object, _userProfilesService.Object, _repository.Object); + } + + [Fact] + public async Task WhenInviteGuestAsyncAndInviteeAlreadyRegistered_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + var invitee = EndUserRoot + .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + invitee.Register(Roles.Empty, Features.Empty, EmailAddress.Create("aninvitee@company.com").Value); + _repository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) + .ReturnsAsync(invitee); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "aninviterdisplayname", + Name = new PersonName + { + FirstName = "aninviterfirstname" + }, + UserId = "aninviterid", + Id = "aprofileid" + }); + + var result = + await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUsersApplication_GuestAlreadyRegistered); + _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + } + + [Fact] + public async Task WhenInviteGuestAsyncAndEmailOwnerAlreadyRegistered_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + _repository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "adisplayname", + Name = new PersonName + { + FirstName = "afirstname" + }, + UserId = "anotheruserid", + Id = "aprofileid" + }.ToOptional()); + + var result = + await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUsersApplication_GuestAlreadyRegistered); + _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _userProfilesService.Verify(ups => + ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "aninvitee@company.com", + It.IsAny())); + _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + } + + [Fact] + public async Task WhenInviteGuestAsyncAndAlreadyInvited_ThenReInvitesGuest() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + var invitee = EndUserRoot + .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); + _repository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) + .ReturnsAsync(invitee); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "aninviterdisplayname", + Name = new PersonName + { + FirstName = "aninviterfirstname" + }, + UserId = "aninviterid", + Id = "aprofileid" + }); + + var result = + await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.EmailAddress.Should().Be("aninvitee@company.com"); + result.Value.FirstName.Should().Be("Aninvitee"); + result.Value.LastName.Should().BeNull(); + _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken", + "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny())); + } + + [Fact] + public async Task WhenInviteGuestAsync_ThenInvitesGuest() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + _repository.Setup(rep => + rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "aninviterdisplayname", + Name = new PersonName + { + FirstName = "aninviterfirstname" + }, + UserId = "aninviterid", + Id = "aprofileid" + }); + + var result = + await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.EmailAddress.Should().Be("aninvitee@company.com"); + result.Value.FirstName.Should().Be("Aninvitee"); + result.Value.LastName.Should().BeNull(); + _userProfilesService.Verify(ups => + ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "aninvitee@company.com", + It.IsAny())); + _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken", + "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny())); + _repository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); + _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + } + + [Fact] + public async Task WhenResendGuestInvitationAsyncAndInvitationNotExists_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + _repository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.ResendGuestInvitationAsync(_caller.Object, "aninvitationtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenResendGuestInvitationAsyncAndInvitationExists_ThenReInvites() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + var invitee = EndUserRoot + .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); + _repository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "aninviterdisplayname", + Name = new PersonName + { + FirstName = "aninviterfirstname" + }, + UserId = "aninviterid", + Id = "aprofileid" + }); + + var result = + await _application.ResendGuestInvitationAsync(_caller.Object, "aninvitationtoken", CancellationToken.None); + + result.Should().BeSuccess(); + _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken", + "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny())); + _repository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); + _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + } + + [Fact] + public async Task WhenVerifyGuestInvitationAsyncAndInvitationNotExists_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + _repository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.VerifyGuestInvitationAsync(_caller.Object, "aninvitationtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenVerifyGuestInvitationAsyncAndInvitationExists_ThenVerifies() + { + _caller.Setup(cc => cc.CallerId) + .Returns("aninviterid"); + var inviter = EndUserRoot + .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + var invitee = EndUserRoot + .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); + _repository.Setup(rep => + rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(invitee.ToOptional()); + + var result = + await _application.VerifyGuestInvitationAsync(_caller.Object, "aninvitationtoken", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.EmailAddress.Should().Be("aninvitee@company.com"); + result.Value.FirstName.Should().Be("Aninvitee"); + result.Value.LastName.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index cc70dc30..d02ab56b 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -19,18 +19,19 @@ public class EndUsersApplication : IEndUsersApplication { internal const string PermittedOperatorsSettingName = "Hosts:EndUsersApi:Authorization:OperatorWhitelist"; private static readonly char[] PermittedOperatorsDelimiters = [';', ',', ' ']; + private readonly IEndUserRepository _endUserRepository; private readonly IIdentifierFactory _idFactory; + private readonly IInvitationRepository _invitationRepository; private readonly INotificationsService _notificationsService; private readonly IOrganizationsService _organizationsService; private readonly IRecorder _recorder; - private readonly IEndUserRepository _repository; private readonly IConfigurationSettings _settings; private readonly IUserProfilesService _userProfilesService; public EndUsersApplication(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, INotificationsService notificationsService, IOrganizationsService organizationsService, - IUserProfilesService userProfilesService, - IEndUserRepository repository) + IUserProfilesService userProfilesService, IInvitationRepository invitationRepository, + IEndUserRepository endUserRepository) { _recorder = recorder; _idFactory = idFactory; @@ -38,13 +39,14 @@ public EndUsersApplication(IRecorder recorder, IIdentifierFactory idFactory, ICo _notificationsService = notificationsService; _organizationsService = organizationsService; _userProfilesService = userProfilesService; - _repository = repository; + _invitationRepository = invitationRepository; + _endUserRepository = endUserRepository; } public async Task> GetPersonAsync(ICallerContext context, string id, CancellationToken cancellationToken) { - var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + var retrieved = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -60,7 +62,7 @@ public async Task> GetPersonAsync(ICallerContext context, public async Task> GetMembershipsAsync(ICallerContext context, string id, CancellationToken cancellationToken) { - var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + var retrieved = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -118,7 +120,7 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, if (context.IsAuthenticated) { - var adder = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + var adder = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); if (!adder.IsSuccessful) { return adder.Error; @@ -133,7 +135,7 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, } } - var saved = await _repository.SaveAsync(machine, cancellationToken); + var saved = await _endUserRepository.SaveAsync(machine, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; @@ -145,35 +147,81 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, return machine.ToRegisteredUser(defaultOrganizationId, profile); } - public async Task> RegisterPersonAsync(ICallerContext context, string emailAddress, - string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, - CancellationToken cancellationToken) + public async Task> RegisterPersonAsync(ICallerContext context, + string? invitationToken, string emailAddress, string firstName, string? lastName, string? timezone, + string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { if (!termsAndConditionsAccepted) { return Error.RuleViolation(Resources.EndUsersApplication_NotAcceptedTerms); } - var username = EmailAddress.Create(emailAddress); - if (!username.IsSuccessful) + var email = EmailAddress.Create(emailAddress); + if (!email.IsSuccessful) { - return username.Error; + return email.Error; } - var existingUser = await FindPersonByEmailAddressInternalAsync(context, username.Value, cancellationToken); - if (!existingUser.IsSuccessful) + var username = email.Value; + + var existingUser = Optional.None; + if (invitationToken.HasValue()) { - return existingUser.Error; + var retrievedGuest = + await FindInvitedGuestWithInvitationTokenAsync(invitationToken, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } + + if (retrievedGuest.Value.HasValue) + { + var existingRegisteredUser = + await FindProfileWithEmailAddressAsync(context, username, cancellationToken); + if (!existingRegisteredUser.IsSuccessful) + { + return existingRegisteredUser.Error; + } + + if (existingRegisteredUser.Value.HasValue) + { + return Error.EntityExists(Resources.EndUsersApplication_AcceptedInvitationWithExistingEmailAddress); + } + + var invitee = retrievedGuest.Value.Value; + var acceptedById = context.ToCallerId(); + var accepted = invitee.AcceptGuestInvitation(acceptedById, username); + if (!accepted.IsSuccessful) + { + return accepted.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Guest user {Id} accepted their invitation", invitee.Id); + existingUser = new EndUserWithProfile(invitee, null); + } } - EndUserRoot user; + if (!existingUser.HasValue) + { + var registeredOrGuest = + await FindRegisteredPersonOrInvitedGuestByEmailAddressAsync(context, username, cancellationToken); + if (!registeredOrGuest.IsSuccessful) + { + return registeredOrGuest.Error; + } + + existingUser = registeredOrGuest.Value; + } + + EndUserRoot unregisteredUser; UserProfile? profile; - if (existingUser.Value.HasValue) + if (existingUser.HasValue) { - user = existingUser.Value.Value.User; - if (user.Status == UserStatus.Registered) + unregisteredUser = existingUser.Value.User; + + if (unregisteredUser.Status == UserStatus.Registered) { - profile = existingUser.Value.Value.Profile; + profile = existingUser.Value.Profile; if (profile.NotExists() || profile.Type != UserProfileType.Person || profile.EmailAddress.HasNoValue()) @@ -181,7 +229,8 @@ public async Task> RegisterPersonAsync(ICallerC return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); } - var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, user.Id, + var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, + unregisteredUser.Id, profile.EmailAddress, profile.DisplayName, profile.Timezone, profile.Address.CountryCode, cancellationToken); if (!notified.IsSuccessful) @@ -190,15 +239,15 @@ public async Task> RegisterPersonAsync(ICallerC } _recorder.TraceInformation(context.ToCall(), - "Attempted re-registration of user: {Id}, with email {EmailAddress}", user.Id, emailAddress); + "Attempted re-registration of user: {Id}, with email {EmailAddress}", unregisteredUser.Id, email); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonReRegistered, new Dictionary { - { UsageConstants.Properties.Id, user.Id }, - { UsageConstants.Properties.EmailAddress, emailAddress } + { UsageConstants.Properties.Id, unregisteredUser.Id }, + { UsageConstants.Properties.EmailAddress, email } }); - return user.ToRegisteredUser(user.Memberships.DefaultMembership.Id, profile); + return unregisteredUser.ToRegisteredUser(unregisteredUser.Memberships.DefaultMembership.Id, profile); } } else @@ -209,11 +258,11 @@ public async Task> RegisterPersonAsync(ICallerC return created.Error; } - user = created.Value; + unregisteredUser = created.Value; } - var profiled = await _userProfilesService.CreatePersonProfilePrivateAsync(context, user.Id, emailAddress, - firstName, lastName, timezone, countryCode, cancellationToken); + var profiled = await _userProfilesService.CreatePersonProfilePrivateAsync(context, unregisteredUser.Id, + username, firstName, lastName, timezone, countryCode, cancellationToken); if (!profiled.IsSuccessful) { return profiled.Error; @@ -222,9 +271,9 @@ public async Task> RegisterPersonAsync(ICallerC profile = profiled.Value; var permittedOperators = GetPermittedOperators(); var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username.Value, + EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username, permittedOperators); - var registered = user.Register(platformRoles, platformFeatures, username.Value); + var registered = unregisteredUser.Register(platformRoles, platformFeatures, username); if (!registered.IsSuccessful) { return registered.Error; @@ -237,7 +286,7 @@ public async Task> RegisterPersonAsync(ICallerC } var defaultOrganization = - await _organizationsService.CreateOrganizationPrivateAsync(context, user.Id, + await _organizationsService.CreateOrganizationPrivateAsync(context, unregisteredUser.Id, organizationName.Value.FullName, OrganizationOwnership.Personal, cancellationToken); if (!defaultOrganization.IsSuccessful) @@ -246,32 +295,32 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, user.Id, } var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); - var enrolled = user.AddMembership(defaultOrganizationId, tenantRoles, + var enrolled = unregisteredUser.AddMembership(defaultOrganizationId, tenantRoles, tenantFeatures); if (!enrolled.IsSuccessful) { return enrolled.Error; } - var saved = await _repository.SaveAsync(user, cancellationToken); + var saved = await _endUserRepository.SaveAsync(unregisteredUser, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", user.Id); - _recorder.AuditAgainst(context.ToCall(), user.Id, + _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", unregisteredUser.Id); + _recorder.AuditAgainst(context.ToCall(), unregisteredUser.Id, Audits.EndUsersApplication_User_Registered_TermsAccepted, - "EndUser {Id} accepted their terms and conditions", user.Id); + "EndUser {Id} accepted their terms and conditions", unregisteredUser.Id); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonRegistrationCreated); - return user.ToRegisteredUser(defaultOrganizationId, profile); + return unregisteredUser.ToRegisteredUser(defaultOrganizationId, profile); } public async Task> CreateMembershipForCallerAsync(ICallerContext context, string organizationId, CancellationToken cancellationToken) { - var retrieved = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + var retrieved = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -288,7 +337,7 @@ public async Task> CreateMembershipForCallerAsync(ICal return membered.Error; } - var saved = await _repository.SaveAsync(user, cancellationToken); + var saved = await _endUserRepository.SaveAsync(user, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; @@ -315,7 +364,8 @@ public async Task, Error>> FindPersonByEmailAddressAsyn return email.Error; } - var retrieved = await FindPersonByEmailAddressInternalAsync(context, email.Value, cancellationToken); + var retrieved = + await FindRegisteredPersonOrInvitedGuestByEmailAddressAsync(context, email.Value, cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -329,13 +379,13 @@ public async Task, Error>> FindPersonByEmailAddressAsyn public async Task> AssignPlatformRolesAsync(ICallerContext context, string id, List roles, CancellationToken cancellationToken) { - var retrievedAssignee = await _repository.LoadAsync(id.ToId(), cancellationToken); + var retrievedAssignee = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); if (!retrievedAssignee.IsSuccessful) { return retrievedAssignee.Error; } - var retrievedAssigner = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + var retrievedAssigner = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); if (!retrievedAssigner.IsSuccessful) { return retrievedAssigner.Error; @@ -355,7 +405,7 @@ public async Task> AssignPlatformRolesAsync(ICallerContex return assigned.Error; } - var updated = await _repository.SaveAsync(assignee, cancellationToken); + var updated = await _endUserRepository.SaveAsync(assignee, cancellationToken); if (!updated.IsSuccessful) { return updated.Error; @@ -372,17 +422,63 @@ public async Task> AssignPlatformRolesAsync(ICallerContex return updated.Value.ToUser(); } + public async Task> UnassignPlatformRolesAsync(ICallerContext context, string id, + List roles, CancellationToken cancellationToken) + { + var retrievedAssignee = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); + if (!retrievedAssignee.IsSuccessful) + { + return retrievedAssignee.Error; + } + + var retrievedAssigner = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedAssigner.IsSuccessful) + { + return retrievedAssigner.Error; + } + + var assignee = retrievedAssignee.Value; + var assigner = retrievedAssigner.Value; + var assigneeRoles = Roles.Create(roles.ToArray()); + if (!assigneeRoles.IsSuccessful) + { + return assigneeRoles.Error; + } + + var unassigned = assignee.UnassignPlatformRoles(assigner, assigneeRoles.Value); + if (!unassigned.IsSuccessful) + { + return unassigned.Error; + } + + var updated = await _endUserRepository.SaveAsync(assignee, cancellationToken); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + _recorder.TraceInformation(context.ToCall(), + "EndUser {Id} has been unassigned platform roles {Roles}", + assignee.Id, roles.JoinAsOredChoices()); + _recorder.AuditAgainst(context.ToCall(), assignee.Id, + Audits.EndUserApplication_PlatformRolesUnassigned, + "EndUser {AssignerId} unassigned the platform roles {Roles} from assignee {AssigneeId}", + assigner.Id, roles.JoinAsOredChoices(), assignee.Id); + + return updated.Value.ToUser(); + } + public async Task> AssignTenantRolesAsync(ICallerContext context, string organizationId, string id, List roles, CancellationToken cancellationToken) { - var retrievedAssignee = await _repository.LoadAsync(id.ToId(), cancellationToken); + var retrievedAssignee = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); if (!retrievedAssignee.IsSuccessful) { return retrievedAssignee.Error; } - var retrievedAssigner = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + var retrievedAssigner = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); if (!retrievedAssigner.IsSuccessful) { return retrievedAssigner.Error; @@ -403,7 +499,7 @@ public async Task> AssignTenantRolesAsync( } var membership = assigned.Value; - var updated = await _repository.SaveAsync(assignee, cancellationToken); + var updated = await _endUserRepository.SaveAsync(assignee, cancellationToken); if (!updated.IsSuccessful) { return updated.Error; @@ -420,11 +516,41 @@ public async Task> AssignTenantRolesAsync( return assignee.ToUserWithMemberships(); } - private async Task, Error>> FindPersonByEmailAddressInternalAsync( + private async Task, Error>> + FindRegisteredPersonOrInvitedGuestByEmailAddressAsync(ICallerContext caller, EmailAddress emailAddress, + CancellationToken cancellationToken) + { + var existingProfile = await FindProfileWithEmailAddressAsync(caller, emailAddress, cancellationToken); + if (!existingProfile.IsSuccessful) + { + return existingProfile.Error; + } + + if (existingProfile.Value.HasValue) + { + return existingProfile; + } + + var existingInvitation = await FindInvitedGuestWithEmailAddressAsync(emailAddress, cancellationToken); + if (!existingInvitation.IsSuccessful) + { + return existingInvitation.Error; + } + + if (existingInvitation.Value.HasValue) + { + return existingInvitation; + } + + return Optional.None; + } + + private async Task, Error>> FindProfileWithEmailAddressAsync( ICallerContext caller, EmailAddress emailAddress, CancellationToken cancellationToken) { var retrieved = - await _userProfilesService.FindPersonByEmailAddressPrivateAsync(caller, emailAddress, cancellationToken); + await _userProfilesService.FindPersonByEmailAddressPrivateAsync(caller, emailAddress, + cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -433,7 +559,7 @@ private async Task, Error>> FindPersonByEmai if (retrieved.Value.HasValue) { var profile = retrieved.Value.Value; - var user = await _repository.LoadAsync(profile.UserId.ToId(), cancellationToken); + var user = await _endUserRepository.LoadAsync(profile.UserId.ToId(), cancellationToken); if (!user.IsSuccessful) { return user.Error; @@ -442,7 +568,14 @@ private async Task, Error>> FindPersonByEmai return new EndUserWithProfile(user.Value, profile).ToOptional(); } - var invitedGuest = await _repository.FindInvitedGuestByEmailAddressAsync(emailAddress, cancellationToken); + return Optional.None; + } + + private async Task, Error>> FindInvitedGuestWithEmailAddressAsync( + EmailAddress emailAddress, CancellationToken cancellationToken) + { + var invitedGuest = + await _invitationRepository.FindInvitedGuestByEmailAddressAsync(emailAddress, cancellationToken); if (!invitedGuest.IsSuccessful) { return invitedGuest.Error; @@ -453,6 +586,19 @@ private async Task, Error>> FindPersonByEmai : Optional.None; } + private async Task, Error>> FindInvitedGuestWithInvitationTokenAsync( + string token, CancellationToken cancellationToken) + { + var invitedGuest = + await _invitationRepository.FindInvitedGuestByTokenAsync(token, cancellationToken); + if (!invitedGuest.IsSuccessful) + { + return invitedGuest.Error; + } + + return invitedGuest.Value; + } + private Optional> GetPermittedOperators() { return _settings.Platform.GetString(PermittedOperatorsSettingName) diff --git a/src/EndUsersApplication/EndUsersApplication.csproj b/src/EndUsersApplication/EndUsersApplication.csproj index baf837c8..301f209b 100644 --- a/src/EndUsersApplication/EndUsersApplication.csproj +++ b/src/EndUsersApplication/EndUsersApplication.csproj @@ -17,6 +17,7 @@ + diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 801be720..8c2e37f5 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -27,7 +27,11 @@ Task> GetMembershipsAsync(ICallerContext c Task> RegisterMachineAsync(ICallerContext context, string name, string? timezone, string? countryCode, CancellationToken cancellationToken); - Task> RegisterPersonAsync(ICallerContext context, string emailAddress, + Task> RegisterPersonAsync(ICallerContext context, string? invitationToken, + string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); + + Task> UnassignPlatformRolesAsync(ICallerContext context, string id, List roles, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/IInvitationsApplication.cs b/src/EndUsersApplication/IInvitationsApplication.cs new file mode 100644 index 00000000..0ed68df4 --- /dev/null +++ b/src/EndUsersApplication/IInvitationsApplication.cs @@ -0,0 +1,17 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace EndUsersApplication; + +public interface IInvitationsApplication +{ + Task> InviteGuestAsync(ICallerContext context, string emailAddress, + CancellationToken cancellationToken); + + Task> ResendGuestInvitationAsync(ICallerContext context, string token, + CancellationToken cancellationToken); + + Task> VerifyGuestInvitationAsync(ICallerContext context, string token, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs new file mode 100644 index 00000000..54496cb3 --- /dev/null +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -0,0 +1,229 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using EndUsersApplication.Persistence; +using EndUsersDomain; + +namespace EndUsersApplication; + +public class InvitationsApplication : IInvitationsApplication +{ + private readonly IIdentifierFactory _idFactory; + private readonly IInvitationRepository _repository; + private readonly INotificationsService _notificationsService; + private readonly IRecorder _recorder; + private readonly ITokensService _tokensService; + private readonly IUserProfilesService _userProfilesService; + + public InvitationsApplication(IRecorder recorder, IIdentifierFactory idFactory, ITokensService tokensService, + INotificationsService notificationsService, IUserProfilesService userProfilesService, + IInvitationRepository repository) + { + _recorder = recorder; + _idFactory = idFactory; + _tokensService = tokensService; + _notificationsService = notificationsService; + _userProfilesService = userProfilesService; + _repository = repository; + } + + public async Task> InviteGuestAsync(ICallerContext context, string emailAddress, + CancellationToken cancellationToken) + { + var retrievedInviter = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + + var email = EmailAddress.Create(emailAddress); + if (!email.IsSuccessful) + { + return email.Error; + } + + var retrievedGuest = + await _repository.FindInvitedGuestByEmailAddressAsync(email.Value, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } + + EndUserRoot invitee; + if (retrievedGuest.Value.HasValue) + { + invitee = retrievedGuest.Value.Value; + if (invitee.Status == UserStatus.Registered) + { + return Error.EntityExists(Resources.EndUsersApplication_GuestAlreadyRegistered); + } + } + else + { + var retrievedEmailOwner = + await _userProfilesService.FindPersonByEmailAddressPrivateAsync(context, emailAddress, + cancellationToken); + if (!retrievedEmailOwner.IsSuccessful) + { + return retrievedEmailOwner.Error; + } + + if (retrievedEmailOwner.Value.HasValue) + { + return Error.EntityExists(Resources.EndUsersApplication_GuestAlreadyRegistered); + } + + var created = EndUserRoot.Create(_recorder, _idFactory, UserClassification.Person); + if (!created.IsSuccessful) + { + return created.Error; + } + + invitee = created.Value; + } + + var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, + async (inviterId, newToken) => + await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + var saved = await _repository.SaveAsync(invitee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Guest {Id} was invited", invitee.Id); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.GuestInvited, + new Dictionary + { + { nameof(EndUserRoot.Id), invitee.Id }, + { nameof(UserProfile.EmailAddress), invitee.GuestInvitation.InviteeEmailAddress!.Address } + }); + + return invitee.ToInvitation(); + } + + public async Task> ResendGuestInvitationAsync(ICallerContext context, string token, + CancellationToken cancellationToken) + { + var retrievedInviter = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + + var retrievedGuest = await _repository.FindInvitedGuestByTokenAsync(token, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } + + if (!retrievedGuest.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var invitee = retrievedGuest.Value.Value; + + var invited = await invitee.ReInviteGuestAsync(_tokensService, inviter.Id, + async (inviterId, newToken) => + await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + var saved = await _repository.SaveAsync(invitee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Guest {Id} was re-invited", invitee.Id); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.GuestInvited, + new Dictionary + { + { nameof(EndUserRoot.Id), invitee.Id }, + { nameof(UserProfile.EmailAddress), invitee.GuestInvitation.InviteeEmailAddress!.Address } + }); + + return Result.Ok; + } + + public async Task> VerifyGuestInvitationAsync(ICallerContext context, string token, + CancellationToken cancellationToken) + { + var retrievedGuest = await _repository.FindInvitedGuestByTokenAsync(token, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } + + if (!retrievedGuest.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var invitee = retrievedGuest.Value.Value; + var verified = invitee.VerifyGuestInvitation(); + if (!verified.IsSuccessful) + { + return verified.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Guest {Id} invitation was verified", invitee.Id); + return invitee.ToInvitation(); + } + + private async Task> SendInvitationNotificationAsync(ICallerContext context, + Identifier inviterId, string token, EndUserRoot invitee, CancellationToken cancellationToken) + { + var inviterProfile = + await _userProfilesService.GetProfilePrivateAsync(context, inviterId, cancellationToken); + if (!inviterProfile.IsSuccessful) + { + return inviterProfile.Error; + } + + var inviteeEmailAddress = invitee.GuestInvitation.InviteeEmailAddress!.Address; + var inviteeName = invitee.GuessGuestInvitationName().FirstName; + var inviterName = inviterProfile.Value.DisplayName; + var notified = + await _notificationsService.NotifyGuestInvitationToPlatformAsync(context, token, inviteeEmailAddress, + inviteeName, inviterName, cancellationToken); + if (!notified.IsSuccessful) + { + return notified.Error; + } + + return Result.Ok; + } +} + +internal static class InvitationConversionExtensions +{ + public static Invitation ToInvitation(this EndUserRoot invitee) + { + var assumedName = invitee.GuessGuestInvitationName(); + return new Invitation + { + EmailAddress = invitee.GuestInvitation.InviteeEmailAddress!.Address, + FirstName = assumedName.FirstName, + LastName = assumedName.LastName.ValueOrDefault! + }; + } +} \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/IEndUserRepository.cs b/src/EndUsersApplication/Persistence/IEndUserRepository.cs index 4e74630f..d41e9f1a 100644 --- a/src/EndUsersApplication/Persistence/IEndUserRepository.cs +++ b/src/EndUsersApplication/Persistence/IEndUserRepository.cs @@ -1,16 +1,12 @@ using Application.Persistence.Interfaces; using Common; using Domain.Common.ValueObjects; -using Domain.Shared; using EndUsersDomain; namespace EndUsersApplication.Persistence; public interface IEndUserRepository : IApplicationRepository { - Task, Error>> FindInvitedGuestByEmailAddressAsync(EmailAddress emailAddress, - CancellationToken cancellationToken); - Task> LoadAsync(Identifier id, CancellationToken cancellationToken); Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/Persistence/InvitationRepository.cs b/src/EndUsersApplication/Persistence/InvitationRepository.cs new file mode 100644 index 00000000..3fd94414 --- /dev/null +++ b/src/EndUsersApplication/Persistence/InvitationRepository.cs @@ -0,0 +1,20 @@ +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using Domain.Shared; +using EndUsersDomain; + +namespace EndUsersApplication.Persistence; + +public interface IInvitationRepository : IApplicationRepository +{ + Task, Error>> FindInvitedGuestByEmailAddressAsync(EmailAddress emailAddress, + CancellationToken cancellationToken); + + Task, Error>> FindInvitedGuestByTokenAsync(string token, + CancellationToken cancellationToken); + + Task> LoadAsync(Identifier id, CancellationToken cancellationToken); + + Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs b/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs new file mode 100644 index 00000000..ce7c64c3 --- /dev/null +++ b/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs @@ -0,0 +1,21 @@ +using Application.Persistence.Common; +using Common; +using QueryAny; + +namespace EndUsersApplication.Persistence.ReadModels; + +[EntityName("Invitation")] +public class Invitation : ReadModelEntity +{ + public Optional AcceptedAtUtc { get; set; } + + public Optional AcceptedEmailAddress { get; set; } + + public Optional InvitedById { get; set; } + + public Optional InvitedEmailAddress { get; set; } + + public Optional Status { get; set; } + + public Optional Token { get; set; } +} \ No newline at end of file diff --git a/src/EndUsersApplication/Resources.Designer.cs b/src/EndUsersApplication/Resources.Designer.cs index 9beb3c5a..35ebad1a 100644 --- a/src/EndUsersApplication/Resources.Designer.cs +++ b/src/EndUsersApplication/Resources.Designer.cs @@ -59,6 +59,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to A user with this email address is already registered. + /// + internal static string EndUsersApplication_AcceptedInvitationWithExistingEmailAddress { + get { + return ResourceManager.GetString("EndUsersApplication_AcceptedInvitationWithExistingEmailAddress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This guest is already registered as a user. + /// + internal static string EndUsersApplication_GuestAlreadyRegistered { + get { + return ResourceManager.GetString("EndUsersApplication_GuestAlreadyRegistered", resourceCulture); + } + } + /// /// Looks up a localized string similar to The membership could not be found. /// diff --git a/src/EndUsersApplication/Resources.resx b/src/EndUsersApplication/Resources.resx index 74295605..9cc996c3 100644 --- a/src/EndUsersApplication/Resources.resx +++ b/src/EndUsersApplication/Resources.resx @@ -33,4 +33,10 @@ The profile for this person cannot be found + + This guest is already registered as a user + + + A user with this email address is already registered + \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index e0ac6640..59aeffd8 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -2,8 +2,10 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces; using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; using Domain.Shared; using FluentAssertions; using Moq; @@ -17,6 +19,7 @@ public class EndUserRootSpec { private readonly Mock _identifierFactory; private readonly Mock _recorder; + private readonly Mock _tokensService; private readonly EndUserRoot _user; public EndUserRootSpec() @@ -34,6 +37,10 @@ public EndUserRootSpec() return "anid".ToId(); }); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateGuestInvitationToken()) + .Returns("aninvitationtoken"); + _user = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; } @@ -45,6 +52,27 @@ public void WhenConstructed_ThenAssigned() _user.Classification.Should().Be(UserClassification.Person); _user.Roles.HasNone().Should().BeTrue(); _user.Features.HasNone().Should().BeTrue(); + _user.GuestInvitation.IsInvited.Should().BeFalse(); + } + + [Fact] + public async Task WhenRegisterAndInvitedAsGuest_ThenAcceptsInvitationAndRegistered() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, emailAddress); + + _user.Access.Should().Be(UserAccess.Enabled); + _user.Status.Should().Be(UserStatus.Registered); + _user.Classification.Should().Be(UserClassification.Person); + _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); + _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + _user.GuestInvitation.IsAccepted.Should().BeTrue(); + _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); + _user.Events[2].Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -96,6 +124,22 @@ public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_Th result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_AllPersonsMustHaveDefaultFeature); } + [Fact] + public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, + emailAddress); +#if TESTINGONLY + _user.TestingOnly_InviteGuest(emailAddress); +#endif + + var result = _user.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestAlreadyRegistered); + } + [Fact] public void WhenAddMembershipAndNotRegistered_ThenReturnsError() { @@ -357,6 +401,298 @@ public void WhenAssignPlatformRoles_ThenAssigns() } #endif +#if TESTINGONLY + [Fact] + public void WhenUnassignPlatformRolesAndAssignerNotOperator_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); + } +#endif + + [Fact] + public void WhenUnassignPlatformRolesAndRoleNotAssignable_ThenReturnsError() + { + var assigner = CreateOperator(); + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); + } + +#if TESTINGONLY + [Fact] + public void WhenUnassignPlatformRolesAndUserNotAssignedRole_ThenReturnsError() + { + var assigner = CreateOperator(); + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(PlatformRoles.TestingOnly.Name)); + } +#endif + +#if TESTINGONLY + [Fact] + public void WhenUnassignPlatformRolesAndStandardRole_ThenReturnsError() + { + var assigner = CreateOperator(); + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.Standard).Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_CannotUnassignBaselinePlatformRole.Format(PlatformRoles.Standard.Name)); + } +#endif + +#if TESTINGONLY + [Fact] + public void WhenUnassignPlatformRoles_ThenUnassigns() + { + var assigner = CreateOperator(); + _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.HasNone().Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + } +#endif + + [Fact] + public async Task WhenInviteAsGuestAndRegistered_ThenDoesNothing() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, emailAddress); + var wasCallbackCalled = false; + + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeFalse(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenInviteAsGuestAndAlreadyInvited_ThenInvitedAgain() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + var wasCallbackCalled = false; + + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenInviteAsGuestAndUnknown_ThenInvited() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + var wasCallbackCalled = false; + + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndNotInvited_ThenReturnsError() + { + var wasCallbackCalled = false; + + var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndInvitationExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); +#if TESTINGONLY + _user.TestingOnly_ExpireGuestInvitation(); +#endif + var wasCallbackCalled = false; + + var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndInvited_ThenReInvites() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + var wasCallbackCalled = false; + + await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyGuestInvitationAndAlreadyRegistered_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, emailAddress); + + var result = _user.VerifyGuestInvitation(); + + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); + } + + [Fact] + public void WhenVerifyGuestInvitationAndNotInvited_ThenReturnsError() + { + var result = _user.VerifyGuestInvitation(); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } + + [Fact] + public async Task WhenVerifyGuestInvitationAndInvitationExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); +#if TESTINGONLY + _user.TestingOnly_ExpireGuestInvitation(); +#endif + + var result = _user.VerifyGuestInvitation(); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } + + [Fact] + public async Task WhenVerifyGuestInvitationAndStillValid_ThenVerifies() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + + var result = _user.VerifyGuestInvitation(); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenAcceptGuestInvitationAndAuthenticatedUser_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + + var result = _user.AcceptGuestInvitation("auserid".ToId(), emailAddress); + + result.Should().BeError(ErrorCode.ForbiddenAccess, + Resources.EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser); + } + + [Fact] + public void WhenAcceptGuestInvitationAndRegistered_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, emailAddress); + + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); + } + + [Fact] + public void WhenAcceptGuestInvitationAndNotInvited_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } + + [Fact] + public async Task WhenAcceptGuestInvitationAndInviteExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); +#if TESTINGONLY + _user.TestingOnly_ExpireGuestInvitation(); +#endif + + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } + + [Fact] + public async Task WhenAcceptGuestInvitation_ThenAccepts() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + + result.Should().BeSuccess(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsAccepted.Should().BeTrue(); + _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); + } + private EndUserRoot CreateOrgOwner(string organizationId) { var owner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; diff --git a/src/EndUsersDomain.UnitTests/GuestInvitationSpec.cs b/src/EndUsersDomain.UnitTests/GuestInvitationSpec.cs new file mode 100644 index 00000000..5e82423c --- /dev/null +++ b/src/EndUsersDomain.UnitTests/GuestInvitationSpec.cs @@ -0,0 +1,170 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Shared; +using FluentAssertions; +using UnitTesting.Common; +using UnitTesting.Common.Validation; +using Xunit; + +namespace EndUsersDomain.UnitTests; + +[Trait("Category", "Unit")] +public class GuestInvitationSpec +{ + private readonly EmailAddress _inviteeEmailAddress; + + public GuestInvitationSpec() + { + _inviteeEmailAddress = EmailAddress.Create("auser@company.com").Value; + } + + [Fact] + public void WhenCreateEmpty_ThenAssigned() + { + var invitation = GuestInvitation.Empty; + + invitation.IsInvited.Should().BeFalse(); + invitation.IsStillOpen.Should().BeFalse(); + invitation.IsAccepted.Should().BeFalse(); + invitation.Token.Should().BeNull(); + invitation.ExpiresOnUtc.Should().BeNull(); + invitation.InvitedById.Should().BeNull(); + invitation.InviteeEmailAddress.Should().BeNull(); + invitation.InvitedAtUtc.Should().BeNull(); + invitation.AcceptedEmailAddress.Should().BeNull(); + invitation.AcceptedAtUtc.Should().BeNull(); + } + + [Fact] + public void WhenInviteAndAlreadyInvited_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + + var result = invitation.Invite("atoken", _inviteeEmailAddress, "aninviterid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_AlreadyInvited); + } + + [Fact] + public void WhenInviteAndAlreadyAccepted_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + invitation = invitation.Accept(_inviteeEmailAddress).Value; + + var result = invitation.Invite("atoken", _inviteeEmailAddress, "aninviterid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_AlreadyInvited); + } + + [Fact] + public void WhenInvite_ThenIsInvited() + { + var invitation = GuestInvitation.Empty; + + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + + invitation.IsInvited.Should().BeTrue(); + invitation.IsStillOpen.Should().BeTrue(); + invitation.IsAccepted.Should().BeFalse(); + invitation.Token.Should().Be("atoken"); + invitation.ExpiresOnUtc.Should().BeNear(DateTime.UtcNow.Add(GuestInvitation.DefaultTokenExpiry)); + invitation.InvitedById.Should().Be("aninviterid".ToId()); + invitation.InviteeEmailAddress!.Address.Should().Be("auser@company.com"); + invitation.InvitedAtUtc.Should().BeNear(DateTime.UtcNow); + invitation.AcceptedEmailAddress.Should().BeNull(); + invitation.AcceptedAtUtc.Should().BeNull(); + } + + [Fact] + public void WhenRenewAndNotInvited_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + + var result = invitation.Renew("atoken", _inviteeEmailAddress); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_NotInvited); + } + + [Fact] + public void WhenRenewAndAlreadyAccepted_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + invitation = invitation.Accept(_inviteeEmailAddress).Value; + + var result = invitation.Renew("atoken", _inviteeEmailAddress); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_AlreadyAccepted); + } + + [Fact] + public void WhenRenewAndInvited_ThenIsRenewed() + { + var invitation = GuestInvitation.Empty; + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + + invitation = invitation.Renew("atoken", _inviteeEmailAddress).Value; + + invitation.IsInvited.Should().BeTrue(); + invitation.IsStillOpen.Should().BeTrue(); + invitation.IsAccepted.Should().BeFalse(); + invitation.Token.Should().Be("atoken"); + invitation.ExpiresOnUtc.Should().BeNear(DateTime.UtcNow.Add(GuestInvitation.DefaultTokenExpiry)); + invitation.InvitedById.Should().Be("aninviterid".ToId()); + invitation.InviteeEmailAddress!.Address.Should().Be("auser@company.com"); + invitation.InvitedAtUtc.Should().BeNear(DateTime.UtcNow); + invitation.AcceptedEmailAddress.Should().BeNull(); + invitation.AcceptedAtUtc.Should().BeNull(); + } + + [Fact] + public void WhenAcceptAndNotInvited_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + + var result = invitation.Accept(_inviteeEmailAddress); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_NotInvited); + } + + [Fact] + public void WhenAcceptAndAlreadyAccepted_ThenReturnsError() + { + var invitation = GuestInvitation.Empty; + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + invitation = invitation.Accept(_inviteeEmailAddress).Value; + + var result = invitation.Accept(_inviteeEmailAddress); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.GuestInvitation_AlreadyAccepted); + } + + [Fact] + public void WhenAcceptAndInvited_ThenIsAccepted() + { + var invitation = GuestInvitation.Empty; + + invitation = invitation.Invite("atoken", _inviteeEmailAddress, + "aninviterid".ToId()).Value; + + invitation = invitation.Accept(_inviteeEmailAddress).Value; + + invitation.IsInvited.Should().BeTrue(); + invitation.IsStillOpen.Should().BeFalse(); + invitation.IsAccepted.Should().BeTrue(); + invitation.Token.Should().Be("atoken"); + invitation.ExpiresOnUtc.Should().BeNull(); + invitation.InvitedById.Should().Be("aninviterid".ToId()); + invitation.InviteeEmailAddress!.Address.Should().Be("auser@company.com"); + invitation.InvitedAtUtc.Should().BeNear(DateTime.UtcNow); + invitation.AcceptedAtUtc.Should().BeNear(DateTime.UtcNow); + } +} \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 7da251bf..12ba865f 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -7,10 +7,13 @@ using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared.DomainServices; using Domain.Shared; namespace EndUsersDomain; +public delegate Task> InvitationCallback(Identifier inviterId, string token); + public sealed class EndUserRoot : AggregateRootBase { public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, @@ -36,6 +39,8 @@ private EndUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleVal public Features Features { get; private set; } = Features.Create(); + public GuestInvitation GuestInvitation { get; private set; } = GuestInvitation.Empty; + private bool IsMachine => Classification == UserClassification.Machine; public bool IsPerson => Classification == UserClassification.Person; @@ -84,6 +89,11 @@ public override Result EnsureInvariants() { return Error.RuleViolation(Resources.EndUserRoot_AllPersonsMustHaveDefaultFeature); } + + if (GuestInvitation.IsStillOpen) + { + return Error.RuleViolation(Resources.EndUserRoot_GuestAlreadyRegistered); + } } return Result.Ok; @@ -229,6 +239,14 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case Events.PlatformRoleUnassigned added: + { + var roles = Roles.Remove(added.Role); + Roles = roles; + Recorder.TraceDebug(null, "EndUser {Id} removed role {Role}", Id, added.Role); + return Result.Ok; + } + case Events.PlatformFeatureAssigned added: { var features = Features.Add(added.Feature); @@ -242,11 +260,67 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case Events.GuestInvitationCreated added: + { + var inviteeEmailAddress = EmailAddress.Create(added.EmailAddress); + if (!inviteeEmailAddress.IsSuccessful) + { + return inviteeEmailAddress.Error; + } + + var invited = GuestInvitation.IsStillOpen + ? GuestInvitation.Renew(added.Token, inviteeEmailAddress.Value) + : GuestInvitation.Invite(added.Token, inviteeEmailAddress.Value, added.InvitedById.ToId()); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + GuestInvitation = invited.Value; + Recorder.TraceDebug(null, "EndUser {Id} invited as a guest by {InvitedBy}", Id, added.InvitedById); + return Result.Ok; + } + + case Events.GuestInvitationAccepted changed: + { + var acceptedEmailAddress = EmailAddress.Create(changed.AcceptedEmailAddress); + if (!acceptedEmailAddress.IsSuccessful) + { + return acceptedEmailAddress.Error; + } + + var accepted = GuestInvitation.Accept(acceptedEmailAddress.Value); + if (!accepted.IsSuccessful) + { + return accepted.Error; + } + + GuestInvitation = accepted.Value; + Recorder.TraceDebug(null, "EndUser {Id} accepted their guest invitation", Id); + return Result.Ok; + } + default: return HandleUnKnownStateChangedEvent(@event); } } + public Result AcceptGuestInvitation(Identifier acceptedById, EmailAddress emailAddress) + { + if (IsNotAnonymousUser(acceptedById)) + { + return Error.ForbiddenAccess(Resources.EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser); + } + + var verified = VerifyGuestInvitation(); + if (!verified.IsSuccessful) + { + return verified.Error; + } + + return RaiseChangeEvent(EndUsersDomain.Events.GuestInvitationAccepted.Create(Id, emailAddress)); + } + public Result AddMembership(Identifier organizationId, Roles tenantRoles, Features tenantFeatures) { if (!IsRegistered) @@ -461,6 +535,31 @@ public static (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles return (platformRoles, platformFeatures, tenantRoles, tenantFeatures); } + public PersonName GuessGuestInvitationName() + { + return GuestInvitation.InviteeEmailAddress!.GuessPersonFullName(); + } + + public async Task> InviteGuestAsync(ITokensService tokensService, Identifier inviterId, + EmailAddress inviteeEmailAddress, InvitationCallback onInvited) + { + if (IsRegistered) + { + return Result.Ok; + } + + var token = tokensService.CreateGuestInvitationToken(); + var raised = + RaiseChangeEvent( + EndUsersDomain.Events.GuestInvitationCreated.Create(Id, token, inviteeEmailAddress, inviterId)); + if (!raised.IsSuccessful) + { + return raised.Error; + } + + return await onInvited(inviterId, token); + } + public Result Register(Roles roles, Features levels, Optional username) { if (Status != UserStatus.Unregistered) @@ -468,10 +567,114 @@ public Result Register(Roles roles, Features levels, Optional> ReInviteGuestAsync(ITokensService tokensService, Identifier inviterId, + InvitationCallback onInvited) + { + if (!GuestInvitation.IsInvited) + { + return Error.RuleViolation(Resources.EndUserRoot_GuestInvitationNeverSent); + } + + if (!GuestInvitation.IsStillOpen) + { + return Error.RuleViolation(Resources.EndUserRoot_GuestInvitationHasExpired); + } + + return await InviteGuestAsync(tokensService, inviterId, GuestInvitation.InviteeEmailAddress!, onInvited); + } + +#if TESTINGONLY + public void TestingOnly_ExpireGuestInvitation() + { + GuestInvitation = GuestInvitation.TestingOnly_ExpireNow(); + } +#endif + +#if TESTINGONLY + public void TestingOnly_InviteGuest(EmailAddress emailAddress) + { + GuestInvitation = GuestInvitation.Invite("atoken", emailAddress, "aninviter".ToId()).Value; + } +#endif + + public Result UnassignPlatformRoles(EndUserRoot assigner, Roles platformRoles) + { + if (!IsPlatformOperator(assigner)) + { + return Error.RuleViolation(Resources.EndUserRoot_NotOperator); + } + + if (platformRoles.HasAny()) + { + foreach (var role in platformRoles.Items) + { + if (!PlatformRoles.IsPlatformAssignableRole(role.Identifier)) + { + return Error.RuleViolation(Resources.EndUserRoot_UnassignablePlatformRole.Format(role.Identifier)); + } + + if (role.Identifier == PlatformRoles.Standard.Name) + { + return Error.RuleViolation( + Resources.EndUserRoot_CannotUnassignBaselinePlatformRole + .Format(PlatformRoles.Standard.Name)); + } + + if (!Roles.HasRole(role.Identifier)) + { + return Error.RuleViolation( + Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(role.Identifier)); + } + + var removedRole = + RaiseChangeEvent( + EndUsersDomain.Events.PlatformRoleUnassigned.Create(Id, role)); + if (!removedRole.IsSuccessful) + { + return removedRole.Error; + } + } + } + + return Result.Ok; + } + + public Result VerifyGuestInvitation() + { + if (IsRegistered) + { + return Error.EntityExists(Resources.EndUserRoot_GuestAlreadyRegistered); + } + + if (!GuestInvitation.IsInvited) + { + return Error.PreconditionViolation(Resources.EndUserRoot_GuestInvitationNeverSent); + } + + if (!GuestInvitation.IsStillOpen) + { + return Error.PreconditionViolation(Resources.EndUserRoot_GuestInvitationHasExpired); + } + + return Result.Ok; + } + private static bool IsPlatformOperator(EndUserRoot assigner) { return assigner.Roles.HasRole(PlatformRoles.Operations); @@ -487,4 +690,9 @@ private static bool IsOrganizationOwner(EndUserRoot assigner, Identifier organiz return retrieved.Value.Roles.HasRole(TenantRoles.Owner); } + + private static bool IsNotAnonymousUser(Identifier userId) + { + return userId != CallerConstants.AnonymousUserId; + } } \ No newline at end of file diff --git a/src/EndUsersDomain/EndUsersDomain.csproj b/src/EndUsersDomain/EndUsersDomain.csproj index bf791e77..7d8c1a78 100644 --- a/src/EndUsersDomain/EndUsersDomain.csproj +++ b/src/EndUsersDomain/EndUsersDomain.csproj @@ -6,6 +6,7 @@ + diff --git a/src/EndUsersDomain/Events.cs b/src/EndUsersDomain/Events.cs index 78d14e69..af0b07a8 100644 --- a/src/EndUsersDomain/Events.cs +++ b/src/EndUsersDomain/Events.cs @@ -196,6 +196,25 @@ public static PlatformRoleAssigned Create(Identifier id, Role role) public required DateTime OccurredUtc { get; set; } } + public sealed class PlatformRoleUnassigned : IDomainEvent + { + public static PlatformRoleUnassigned Create(Identifier id, Role role) + { + return new PlatformRoleUnassigned + { + RootId = id, + OccurredUtc = DateTime.UtcNow, + Role = role.Identifier + }; + } + + public required string Role { get; set; } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + } + public sealed class PlatformFeatureAssigned : IDomainEvent { public static PlatformFeatureAssigned Create(Identifier id, Feature feature) @@ -214,4 +233,52 @@ public static PlatformFeatureAssigned Create(Identifier id, Feature feature) public required DateTime OccurredUtc { get; set; } } + + public sealed class GuestInvitationCreated : IDomainEvent + { + public static GuestInvitationCreated Create(Identifier id, string token, EmailAddress invitee, + Identifier invitedBy) + { + return new GuestInvitationCreated + { + RootId = id, + OccurredUtc = DateTime.UtcNow, + EmailAddress = invitee, + InvitedById = invitedBy, + Token = token + }; + } + + public required string EmailAddress { get; set; } + + public required string InvitedById { get; set; } + + public required string Token { get; set; } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + } + + public sealed class GuestInvitationAccepted : IDomainEvent + { + public static GuestInvitationAccepted Create(Identifier id, EmailAddress emailAddress) + { + return new GuestInvitationAccepted + { + RootId = id, + OccurredUtc = DateTime.UtcNow, + AcceptedEmailAddress = emailAddress, + AcceptedAtUtc = DateTime.UtcNow + }; + } + + public required DateTime AcceptedAtUtc { get; set; } + + public required string AcceptedEmailAddress { get; set; } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + } } \ No newline at end of file diff --git a/src/EndUsersDomain/GuestInvitation.cs b/src/EndUsersDomain/GuestInvitation.cs new file mode 100644 index 00000000..7a776cc9 --- /dev/null +++ b/src/EndUsersDomain/GuestInvitation.cs @@ -0,0 +1,141 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Shared; + +namespace EndUsersDomain; + +public sealed class GuestInvitation : ValueObjectBase +{ + public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(7); + public static readonly GuestInvitation Empty = new(); + + public static Result Create() + { + return new GuestInvitation(); + } + + private GuestInvitation() + { + Token = null; + InviteeEmailAddress = null; + ExpiresOnUtc = null; + InvitedById = null; + InvitedAtUtc = null; + AcceptedEmailAddress = null; + AcceptedAtUtc = null; + } + + private GuestInvitation(string? token, EmailAddress? inviteeEmailAddress, DateTime? expiresOnUtc, + Identifier? invitedById, DateTime? invitedAtUtc, EmailAddress? acceptedEmailAddress, DateTime? acceptedAtUtc) + { + Token = token; + InviteeEmailAddress = inviteeEmailAddress; + ExpiresOnUtc = expiresOnUtc; + InvitedById = invitedById; + InvitedAtUtc = invitedAtUtc; + AcceptedEmailAddress = acceptedEmailAddress; + AcceptedAtUtc = acceptedAtUtc; + } + + public DateTime? AcceptedAtUtc { get; } + + public EmailAddress? AcceptedEmailAddress { get; } + + public DateTime? ExpiresOnUtc { get; } + + public DateTime? InvitedAtUtc { get; } + + public Identifier? InvitedById { get; } + + public EmailAddress? InviteeEmailAddress { get; } + + public bool IsAccepted => IsInvited && AcceptedAtUtc.HasValue; + + public bool IsInvited => Token.HasValue() && InviteeEmailAddress.Exists(); + + public bool IsStillOpen => IsInvited && ExpiresOnUtc.HasValue() && ExpiresOnUtc > DateTime.UtcNow; + + public string? Token { get; } + + public bool CanAccept => IsInvited && !IsAccepted; + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var parts = RehydrateToList(property, false); + return new GuestInvitation(parts[0]!, + EmailAddress.Rehydrate()(parts[1]!, container), + parts[2]?.FromIso8601(), + Identifier.Rehydrate()(parts[3]!, container), + parts[4]?.FromIso8601(), + EmailAddress.Rehydrate()(parts[1]!, container), + parts[6]?.FromIso8601()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object?[] + { + Token, InviteeEmailAddress, ExpiresOnUtc, InvitedById, InvitedAtUtc, AcceptedEmailAddress, AcceptedAtUtc + }; + } + + public Result Accept(EmailAddress acceptedWithEmail) + { + if (!IsInvited) + { + return Error.RuleViolation(Resources.GuestInvitation_NotInvited); + } + + if (IsAccepted) + { + return Error.RuleViolation(Resources.GuestInvitation_AlreadyAccepted); + } + + return new GuestInvitation(Token, InviteeEmailAddress, null, InvitedById, InvitedAtUtc, acceptedWithEmail, + DateTime.UtcNow); + } + + public Result Invite(string token, EmailAddress inviteeEmailAddress, Identifier invitedById) + { + if (IsInvited) + { + return Error.RuleViolation(Resources.GuestInvitation_AlreadyInvited); + } + + if (IsAccepted) + { + return Error.RuleViolation(Resources.GuestInvitation_AlreadyAccepted); + } + + return new GuestInvitation(token, inviteeEmailAddress, DateTime.UtcNow.Add(DefaultTokenExpiry), invitedById, + DateTime.UtcNow, null, null); + } + + public Result Renew(string token, EmailAddress inviteeEmailAddress) + { + if (!IsInvited) + { + return Error.RuleViolation(Resources.GuestInvitation_NotInvited); + } + + if (IsAccepted) + { + return Error.RuleViolation(Resources.GuestInvitation_AlreadyAccepted); + } + + return new GuestInvitation(token, inviteeEmailAddress, DateTime.UtcNow.Add(DefaultTokenExpiry), InvitedById, + InvitedAtUtc, null, null); + } + +#if TESTINGONLY + public GuestInvitation TestingOnly_ExpireNow() + { + return new GuestInvitation(Token, InviteeEmailAddress, DateTime.UtcNow, InvitedById, InvitedAtUtc, null, null); + } +#endif +} \ No newline at end of file diff --git a/src/EndUsersDomain/Resources.Designer.cs b/src/EndUsersDomain/Resources.Designer.cs index d990c36d..f9a52617 100644 --- a/src/EndUsersDomain/Resources.Designer.cs +++ b/src/EndUsersDomain/Resources.Designer.cs @@ -86,6 +86,60 @@ internal static string EndUserRoot_AlreadyRegistered { } } + /// + /// Looks up a localized string similar to The platform role '{0}' must always exist. + /// + internal static string EndUserRoot_CannotUnassignBaselinePlatformRole { + get { + return ResourceManager.GetString("EndUserRoot_CannotUnassignBaselinePlatformRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The platform role '{0}' cannot be removed, since it does not exist for this user. + /// + internal static string EndUserRoot_CannotUnassignUnassignedRole { + get { + return ResourceManager.GetString("EndUserRoot_CannotUnassignUnassignedRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This guest has already been invited, and has now registered. + /// + internal static string EndUserRoot_GuestAlreadyRegistered { + get { + return ResourceManager.GetString("EndUserRoot_GuestAlreadyRegistered", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This guest invitation cannot be accepted by an authenticated user. + /// + internal static string EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser { + get { + return ResourceManager.GetString("EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This guest invitation has expired, and the guest needs to invited again. + /// + internal static string EndUserRoot_GuestInvitationHasExpired { + get { + return ResourceManager.GetString("EndUserRoot_GuestInvitationHasExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A guest invitation has not been sent yet. + /// + internal static string EndUserRoot_GuestInvitationNeverSent { + get { + return ResourceManager.GetString("EndUserRoot_GuestInvitationNeverSent", resourceCulture); + } + } + /// /// Looks up a localized string similar to The machine user cannot be unregistered. /// @@ -176,6 +230,33 @@ internal static string EndUserRoot_UnassignableTenantRole { } } + /// + /// Looks up a localized string similar to This invitation has already been accepted. + /// + internal static string GuestInvitation_AlreadyAccepted { + get { + return ResourceManager.GetString("GuestInvitation_AlreadyAccepted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This invitation has already been sent. + /// + internal static string GuestInvitation_AlreadyInvited { + get { + return ResourceManager.GetString("GuestInvitation_AlreadyInvited", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This invitation has not been sent yet. + /// + internal static string GuestInvitation_NotInvited { + get { + return ResourceManager.GetString("GuestInvitation_NotInvited", resourceCulture); + } + } + /// /// Looks up a localized string similar to A membership must always have the default feature set. /// diff --git a/src/EndUsersDomain/Resources.resx b/src/EndUsersDomain/Resources.resx index d01b9054..540872c0 100644 --- a/src/EndUsersDomain/Resources.resx +++ b/src/EndUsersDomain/Resources.resx @@ -48,6 +48,12 @@ The feature '{0}' is not a supported tenant feature + + The platform role '{0}' must always exist + + + The platform role '{0}' cannot be removed, since it does not exist for this user + At least one of the memberships must be the default membership @@ -75,4 +81,25 @@ The assigner is not a member of the operations team + + This invitation has not been sent yet + + + This invitation has already been sent + + + This invitation has already been accepted + + + A guest invitation has not been sent yet + + + This guest has already been invited, and has now registered + + + This guest invitation has expired, and the guest needs to invited again + + + This guest invitation cannot be accepted by an authenticated user + \ No newline at end of file diff --git a/src/EndUsersDomain/Validations.cs b/src/EndUsersDomain/Validations.cs index e90e1dbd..bc4f174c 100644 --- a/src/EndUsersDomain/Validations.cs +++ b/src/EndUsersDomain/Validations.cs @@ -5,4 +5,9 @@ namespace EndUsersDomain; public static class Validations { public static readonly Validation Role = CommonValidations.RoleLevel; + + public static class Invitation + { + public static readonly Validation Token = CommonValidations.RandomToken(); + } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs index 44102aeb..39fcd918 100644 --- a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs +++ b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs @@ -34,7 +34,30 @@ public async Task WhenAssignPlatformRoles_ThenAssignsRoles() #endif } + [Fact] + public async Task WhenUnassignPlatformRoles_ThenAssignsRoles() + { + var login = await LoginUserAsync(LoginUser.Operator); +#if TESTINGONLY + await Api.PostAsync(new AssignPlatformRolesRequest + { + Id = login.User.Id, + Roles = new List { PlatformRoles.TestingOnly.Name } + }, req => req.SetJWTBearerToken(login.AccessToken)); + + var result = await Api.PatchAsync(new UnassignPlatformRolesRequest + { + Id = login.User.Id, + Roles = new List { PlatformRoles.TestingOnly.Name } + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.User!.Roles.Should() + .ContainInOrder(PlatformRoles.Standard.Name, PlatformRoles.Operations.Name); +#endif + } + private static void OverrideDependencies(IServiceCollection services) { + // Override dependencies here } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs new file mode 100644 index 00000000..044fa8c5 --- /dev/null +++ b/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs @@ -0,0 +1,338 @@ +using System.Net; +using ApiHost1; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Interfaces.Authorization; +using FluentAssertions; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using IntegrationTesting.WebApi.Common; +using IntegrationTesting.WebApi.Common.Stubs; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace EndUsersInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class InvitationsApiSpec : WebApiSpec +{ + private static int _invitationCount; + private readonly StubNotificationsService _notificationService; + + public InvitationsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _notificationService = setup.GetRequiredService().As(); + _notificationService.Reset(); + } + + [Fact] + public async Task WhenInviteGuestAndNotYetInvited_ThenInvites() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + var result = await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Invitation!.EmailAddress.Should().Be(emailAddress); + result.Content.Value.Invitation!.FirstName.Should().Be("Aninvitee"); + result.Content.Value.Invitation!.LastName.Should().BeNull(); + _notificationService.LastGuestInvitationEmailRecipient.Should().Be(emailAddress); + } + + [Fact] + public async Task WhenInviteGuestAndAlreadyInvited_ThenReInvites() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + + // Delay to allow for relevant timestamp checks + await Task.Delay(TimeSpan.FromSeconds(2)); + _notificationService.Reset(); + + var result = await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Invitation!.EmailAddress.Should().Be(emailAddress); + result.Content.Value.Invitation!.FirstName.Should().Be("Aninvitee"); + result.Content.Value.Invitation!.LastName.Should().BeNull(); + _notificationService.LastGuestInvitationEmailRecipient.Should().Be(emailAddress); + } + + [Fact] + public async Task WhenInviteUserAsGuestAndAlreadyRegistered_ThenReturnsError() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + + await RegisterUserAsync(emailAddress); + + // Delay to allow for relevant timestamp checks + await Task.Delay(TimeSpan.FromSeconds(2)); + _notificationService.Reset(); + + var result = await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.Conflict); + _notificationService.LastGuestInvitationEmailRecipient.Should().BeNull(); + } + + [Fact] + public async Task WhenVerifyInvitationAndRegistered_ThenReturnsError() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + await RegisterUserAsync(emailAddress); + + var result = await Api.GetAsync(new VerifyGuestInvitationRequest + { + Token = token + }); + + result.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task WhenVerifyInvitationAndInvited_ThenVerifies() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + var result = await Api.GetAsync(new VerifyGuestInvitationRequest + { + Token = token + }); + + result.Content.Value.Invitation!.EmailAddress.Should().Be(emailAddress); + result.Content.Value.Invitation.FirstName.Should().Be("Aninvitee"); + result.Content.Value.Invitation.LastName.Should().BeNull(); + } + + [Fact] + public async Task WhenResendInvitationAndRegistered_ThenReturnsError() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + await RegisterUserAsync(emailAddress); + + var result = await Api.PostAsync(new ResendGuestInvitationRequest + { + Token = token + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task WhenResendInvitationAndInvited_ThenResends() + { + var login = await LoginUserAsync(); + var emailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = emailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + _notificationService.Reset(); + + await Api.PostAsync(new ResendGuestInvitationRequest + { + Token = token + }, req => req.SetJWTBearerToken(login.AccessToken)); + + _notificationService.LastGuestInvitationEmailRecipient.Should().Be(emailAddress); + } + + [Fact] + public async Task WhenAcceptInvitationAndNotInvited_ThenRegistersUser() + { + var acceptedEmailAddress = CreateRandomEmailAddress(); + + var result = await Api.PostAsync(new RegisterPersonPasswordRequest + { + InvitationToken = new TokensService().CreateGuestInvitationToken(), + EmailAddress = acceptedEmailAddress, + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + result.Content.Value.Credential!.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Access.Should().Be(EndUserAccess.Enabled); + result.Content.Value.Credential.User.Status.Should().Be(EndUserStatus.Registered); + result.Content.Value.Credential.User.Classification.Should().Be(EndUserClassification.Person); + result.Content.Value.Credential.User.Roles.Should().ContainSingle(rol => rol == PlatformRoles.Standard.Name); + result.Content.Value.Credential.User.Features.Should() + .ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); + result.Content.Value.Credential.User.Profile!.UserId.Should().Be(result.Content.Value.Credential.User.Id); + result.Content.Value.Credential.User.Profile!.DefaultOrganizationId.Should().NotBeNullOrEmpty(); + result.Content.Value.Credential.User.Profile!.Name.FirstName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.Name.LastName.Should().Be("alastname"); + result.Content.Value.Credential.User.Profile!.DisplayName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.EmailAddress.Should().Be(acceptedEmailAddress); + result.Content.Value.Credential.User.Profile!.Timezone.Should().Be(Timezones.Default.ToString()); + result.Content.Value.Credential.User.Profile!.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + } + + [Fact] + public async Task WhenAcceptInvitationAndInvitedOnSameEmailAddress_ThenRegistersUser() + { + var login = await LoginUserAsync(); + var invitedEmailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = invitedEmailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + var result = await Api.PostAsync(new RegisterPersonPasswordRequest + { + InvitationToken = token, + EmailAddress = invitedEmailAddress, + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + result.Content.Value.Credential!.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Access.Should().Be(EndUserAccess.Enabled); + result.Content.Value.Credential.User.Status.Should().Be(EndUserStatus.Registered); + result.Content.Value.Credential.User.Classification.Should().Be(EndUserClassification.Person); + result.Content.Value.Credential.User.Roles.Should().ContainSingle(rol => rol == PlatformRoles.Standard.Name); + result.Content.Value.Credential.User.Features.Should() + .ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); + result.Content.Value.Credential.User.Profile!.UserId.Should().Be(result.Content.Value.Credential.User.Id); + result.Content.Value.Credential.User.Profile!.DefaultOrganizationId.Should().NotBeNullOrEmpty(); + result.Content.Value.Credential.User.Profile!.Name.FirstName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.Name.LastName.Should().Be("alastname"); + result.Content.Value.Credential.User.Profile!.DisplayName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.EmailAddress.Should().Be(invitedEmailAddress); + result.Content.Value.Credential.User.Profile!.Timezone.Should().Be(Timezones.Default.ToString()); + result.Content.Value.Credential.User.Profile!.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + } + + [Fact] + public async Task WhenAcceptInvitationAndInvitedOnDifferentEmailAddress_ThenRegistersUser() + { + var login = await LoginUserAsync(); + var invitedEmailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = invitedEmailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + var registeredEmailAddress = CreateRandomEmailAddress(); + var result = await Api.PostAsync(new RegisterPersonPasswordRequest + { + InvitationToken = token, + EmailAddress = registeredEmailAddress, + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + result.Content.Value.Credential!.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.User.Access.Should().Be(EndUserAccess.Enabled); + result.Content.Value.Credential.User.Status.Should().Be(EndUserStatus.Registered); + result.Content.Value.Credential.User.Classification.Should().Be(EndUserClassification.Person); + result.Content.Value.Credential.User.Roles.Should().ContainSingle(rol => rol == PlatformRoles.Standard.Name); + result.Content.Value.Credential.User.Features.Should() + .ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); + result.Content.Value.Credential.User.Profile!.UserId.Should().Be(result.Content.Value.Credential.User.Id); + result.Content.Value.Credential.User.Profile!.DefaultOrganizationId.Should().NotBeNullOrEmpty(); + result.Content.Value.Credential.User.Profile!.Name.FirstName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.Name.LastName.Should().Be("alastname"); + result.Content.Value.Credential.User.Profile!.DisplayName.Should().Be("afirstname"); + result.Content.Value.Credential.User.Profile!.EmailAddress.Should().Be(registeredEmailAddress); + result.Content.Value.Credential.User.Profile!.Timezone.Should().Be(Timezones.Default.ToString()); + result.Content.Value.Credential.User.Profile!.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + } + + [Fact] + public async Task WhenAcceptInvitationOnAnExistingEmailAddress_ThenReturnsError() + { + var login = await LoginUserAsync(); + var invitedEmailAddress = CreateRandomEmailAddress(); + + await Api.PostAsync(new InviteGuestRequest + { + Email = invitedEmailAddress + }, req => req.SetJWTBearerToken(login.AccessToken)); + var token = _notificationService.LastGuestInvitationToken!; + + var existingEmailAddress = login.User.Profile!.EmailAddress!; + var result = await Api.PostAsync(new RegisterPersonPasswordRequest + { + InvitationToken = token, + EmailAddress = existingEmailAddress, + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + result.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + private static string CreateRandomEmailAddress() + { + return $"aninvitee{++_invitationCount}@company.com"; + } + + private static void OverrideDependencies(IServiceCollection services) + { + // Override dependencies here + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/UnassignPlatformRolesRequestValidatorSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/UnassignPlatformRolesRequestValidatorSpec.cs new file mode 100644 index 00000000..df05f4da --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/UnassignPlatformRolesRequestValidatorSpec.cs @@ -0,0 +1,65 @@ +using Domain.Common.Identity; +using EndUsersInfrastructure.Api.EndUsers; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using UnitTesting.Common.Validation; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.EndUsers; + +[Trait("Category", "Unit")] +public class UnassignPlatformRolesRequestValidatorSpec +{ + private readonly UnassignPlatformRolesRequest _dto; + private readonly UnassignPlatformRolesRequestValidator _validator; + + public UnassignPlatformRolesRequestValidatorSpec() + { + _validator = new UnassignPlatformRolesRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new UnassignPlatformRolesRequest + { + Id = "anid", + Roles = new List { "arole" } + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenRolesIsNull_ThenThrows() + { + _dto.Roles = null; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignPlatformRolesRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenRolesIsEmpty_ThenThrows() + { + _dto.Roles = new List(); + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignPlatformRolesRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenRoleIsInvalid_ThenThrows() + { + _dto.Roles = new List { "aninvalidrole^" }; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignPlatformRolesRequestValidator_InvalidRole); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.UnitTests/Api/Invitations/AcceptGuestInvitationRequestSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/AcceptGuestInvitationRequestSpec.cs new file mode 100644 index 00000000..7ee1297b --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/AcceptGuestInvitationRequestSpec.cs @@ -0,0 +1,53 @@ +using EndUsersInfrastructure.Api.Invitations; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using UnitTesting.Common.Validation; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.Invitations; + +[Trait("Category", "Unit")] +public class VerifyGuestInvitationRequestValidatorSpec +{ + private readonly VerifyGuestInvitationRequest _dto; + private readonly VerifyGuestInvitationRequestValidator _validator; + + public VerifyGuestInvitationRequestValidatorSpec() + { + _validator = new VerifyGuestInvitationRequestValidator(); + _dto = new VerifyGuestInvitationRequest + { + Token = new TokensService().CreateRegistrationVerificationToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTokenIsNull_ThenThrows() + { + _dto.Token = null!; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.VerifyGuestInvitationRequestValidator_InvalidToken); + } + + [Fact] + public void WhenTokenIsInvalid_ThenThrows() + { + _dto.Token = "aninvalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.VerifyGuestInvitationRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.UnitTests/Api/Invitations/InviteGuestRequestValidatorSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/InviteGuestRequestValidatorSpec.cs new file mode 100644 index 00000000..9c9e59c3 --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/InviteGuestRequestValidatorSpec.cs @@ -0,0 +1,41 @@ +using EndUsersInfrastructure.Api.Invitations; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using UnitTesting.Common.Validation; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.Invitations; + +[Trait("Category", "Unit")] +public class InviteGuestRequestValidatorSpec +{ + private readonly InviteGuestRequest _dto; + private readonly InviteGuestRequestValidator _validator; + + public InviteGuestRequestValidatorSpec() + { + _validator = new InviteGuestRequestValidator(); + _dto = new InviteGuestRequest + { + Email = "auser@company.com" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenEmailIsInvalid_ThenThrows() + { + _dto.Email = "aninvalidemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InviteGuestRequestValidator_InvalidEmail); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.UnitTests/Api/Invitations/ResendGuestInvitationRequestSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/ResendGuestInvitationRequestSpec.cs new file mode 100644 index 00000000..a3d0bf1e --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/Invitations/ResendGuestInvitationRequestSpec.cs @@ -0,0 +1,53 @@ +using EndUsersInfrastructure.Api.Invitations; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using UnitTesting.Common.Validation; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.Invitations; + +[Trait("Category", "Unit")] +public class ResendGuestInvitationRequestValidatorSpec +{ + private readonly ResendGuestInvitationRequest _dto; + private readonly ResendGuestInvitationRequestValidator _validator; + + public ResendGuestInvitationRequestValidatorSpec() + { + _validator = new ResendGuestInvitationRequestValidator(); + _dto = new ResendGuestInvitationRequest + { + Token = new TokensService().CreateRegistrationVerificationToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTokenIsNull_ThenThrows() + { + _dto.Token = null!; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.VerifyGuestInvitationRequestValidator_InvalidToken); + } + + [Fact] + public void WhenTokenIsInvalid_ThenThrows() + { + _dto.Token = "aninvalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.VerifyGuestInvitationRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs index b6d8e383..753f4f94 100644 --- a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -19,15 +19,25 @@ public EndUsersApi(ICallerContextFactory contextFactory, IEndUsersApplication en } public async Task> AssignPlatformRoles( - AssignPlatformRolesRequest request, - CancellationToken cancellationToken) + AssignPlatformRolesRequest request, CancellationToken cancellationToken) { var user = await _endUsersApplication.AssignPlatformRolesAsync(_contextFactory.Create(), request.Id, - request.Roles ?? new List(), - cancellationToken); + request.Roles ?? new List(), cancellationToken); return () => user.HandleApplicationResult(usr => new PostResult(new AssignPlatformRolesResponse { User = usr })); } + + public async Task> UnassignPlatformRoles( + UnassignPlatformRolesRequest request, CancellationToken cancellationToken) + { + var user = + await _endUsersApplication.UnassignPlatformRolesAsync(_contextFactory.Create(), request.Id, + request.Roles ?? new List(), cancellationToken); + + return () => + user.HandleApplicationResult(usr => new AssignPlatformRolesResponse + { User = usr }); + } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/UnassignPlatformRolesRequestValidator.cs b/src/EndUsersInfrastructure/Api/EndUsers/UnassignPlatformRolesRequestValidator.cs new file mode 100644 index 00000000..3bd12ebe --- /dev/null +++ b/src/EndUsersInfrastructure/Api/EndUsers/UnassignPlatformRolesRequestValidator.cs @@ -0,0 +1,24 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using EndUsersDomain; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.EndUsers; + +public class UnassignPlatformRolesRequestValidator : AbstractValidator +{ + public UnassignPlatformRolesRequestValidator(IIdentifierFactory idFactory) + { + RuleFor(req => req.Id) + .IsEntityId(idFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.Roles) + .NotEmpty() + .WithMessage(Resources.AssignPlatformRolesRequestValidator_InvalidRoles); + RuleFor(req => req.Roles) + .ForEach(dto => dto.Matches(Validations.Role) + .WithMessage(Resources.AssignPlatformRolesRequestValidator_InvalidRole)); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs b/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs new file mode 100644 index 00000000..5b8fb4c0 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs @@ -0,0 +1,52 @@ +using Application.Resources.Shared; +using EndUsersApplication; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Invitations; + +public class InvitationsApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly IInvitationsApplication _invitationsApplication; + + public InvitationsApi(ICallerContextFactory contextFactory, IInvitationsApplication invitationsApplication) + { + _contextFactory = contextFactory; + _invitationsApplication = invitationsApplication; + } + + public async Task> AcceptGuestInvitation( + VerifyGuestInvitationRequest request, CancellationToken cancellationToken) + { + var invitation = + await _invitationsApplication.VerifyGuestInvitationAsync(_contextFactory.Create(), request.Token, + cancellationToken); + + return () => invitation.HandleApplicationResult(invite => + new VerifyGuestInvitationResponse { Invitation = invite }); + } + + public async Task> InviteGuest( + InviteGuestRequest request, CancellationToken cancellationToken) + { + var invitation = + await _invitationsApplication.InviteGuestAsync(_contextFactory.Create(), request.Email, + cancellationToken); + + return () => invitation.HandleApplicationResult(invite => + new PostResult(new InviteGuestResponse { Invitation = invite })); + } + + public async Task ResendGuestInvitation( + ResendGuestInvitationRequest request, CancellationToken cancellationToken) + { + var invitation = + await _invitationsApplication.ResendGuestInvitationAsync(_contextFactory.Create(), request.Token, + cancellationToken); + + return () => invitation.HandleApplicationResult(); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Invitations/InviteGuestRequestValidator.cs b/src/EndUsersInfrastructure/Api/Invitations/InviteGuestRequestValidator.cs new file mode 100644 index 00000000..c00082dd --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Invitations/InviteGuestRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Invitations; + +public class InviteGuestRequestValidator : AbstractValidator +{ + public InviteGuestRequestValidator() + { + RuleFor(req => req.Email) + .IsEmailAddress() + .WithMessage(Resources.InviteGuestRequestValidator_InvalidEmail); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Invitations/ResendGuestInvitationRequestValidator.cs b/src/EndUsersInfrastructure/Api/Invitations/ResendGuestInvitationRequestValidator.cs new file mode 100644 index 00000000..fe1a1b69 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Invitations/ResendGuestInvitationRequestValidator.cs @@ -0,0 +1,16 @@ +using EndUsersDomain; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Invitations; + +public class ResendGuestInvitationRequestValidator : AbstractValidator +{ + public ResendGuestInvitationRequestValidator() + { + RuleFor(dto => dto.Token) + .Matches(Validations.Invitation.Token) + .WithMessage(Resources.ResendGuestInvitationRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Invitations/VerifyGuestInvitationRequestValidator.cs b/src/EndUsersInfrastructure/Api/Invitations/VerifyGuestInvitationRequestValidator.cs new file mode 100644 index 00000000..25959cb5 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Invitations/VerifyGuestInvitationRequestValidator.cs @@ -0,0 +1,16 @@ +using EndUsersDomain; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Invitations; + +public class VerifyGuestInvitationRequestValidator : AbstractValidator +{ + public VerifyGuestInvitationRequestValidator() + { + RuleFor(dto => dto.Token) + .Matches(Validations.Invitation.Token) + .WithMessage(Resources.VerifyGuestInvitationRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index 1127f9df..c585dee1 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -40,11 +40,11 @@ public async Task> GetMembershipsPrivateAs } public async Task> RegisterPersonPrivateAsync(ICallerContext caller, - string emailAddress, - string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, - CancellationToken cancellationToken) + string? invitationToken, string emailAddress, string firstName, string? lastName, string? timezone, + string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { - return await _endUsersApplication.RegisterPersonAsync(caller, emailAddress, firstName, lastName, timezone, + return await _endUsersApplication.RegisterPersonAsync(caller, invitationToken, emailAddress, firstName, + lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken); } diff --git a/src/EndUsersInfrastructure/EndUsersModule.cs b/src/EndUsersInfrastructure/EndUsersModule.cs index 3c6c56c6..e69a8bb6 100644 --- a/src/EndUsersInfrastructure/EndUsersModule.cs +++ b/src/EndUsersInfrastructure/EndUsersModule.cs @@ -5,6 +5,7 @@ using Common.Configuration; using Domain.Common.Identity; using Domain.Interfaces; +using Domain.Services.Shared.DomainServices; using EndUsersApplication; using EndUsersApplication.Persistence; using EndUsersDomain; @@ -51,12 +52,25 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), c.GetRequiredService())); + services.AddSingleton(c => + new InvitationsApplication(c.GetRequiredService(), + c.GetRequiredService(), + c.GetRequiredService(), + c.GetRequiredService(), + c.GetRequiredService(), + c.GetRequiredService())); services.AddSingleton(c => new EndUserRepository( c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService>(), c.GetRequiredServiceForPlatform())); + services.AddSingleton(c => new InvitationRepository( + c.GetRequiredService(), + c.GetRequiredService(), + c.GetRequiredService>(), + c.GetRequiredServiceForPlatform())); services.RegisterUnTenantedEventing( c => new EndUserProjection(c.GetRequiredService(), c.GetRequiredService(), diff --git a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs index f5077d99..e1206c77 100644 --- a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs @@ -3,13 +3,11 @@ using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; -using Domain.Shared; using EndUsersApplication.Persistence; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; -using QueryAny; namespace EndUsersInfrastructure.Persistence; @@ -53,37 +51,4 @@ public async Task> SaveAsync(EndUserRoot user, Cancel return user; } - - public async Task, Error>> FindInvitedGuestByEmailAddressAsync( - EmailAddress emailAddress, - CancellationToken cancellationToken) - { - var query = Query.From() - .Where(at => at.Username, ConditionOperator.EqualTo, emailAddress.Address); - return await FindFirstByQueryAsync(query, cancellationToken); - } - - private async Task, Error>> FindFirstByQueryAsync(QueryClause query, - CancellationToken cancellationToken) - { - var queried = await _userQueries.QueryAsync(query, false, cancellationToken); - if (!queried.IsSuccessful) - { - return queried.Error; - } - - var matching = queried.Value.Results.FirstOrDefault(); - if (matching.NotExists()) - { - return Optional.None; - } - - var users = await _users.LoadAsync(matching.Id.Value.ToId(), cancellationToken); - if (!users.IsSuccessful) - { - return users.Error; - } - - return users.Value.ToOptional(); - } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs b/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs new file mode 100644 index 00000000..cf36e0e9 --- /dev/null +++ b/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs @@ -0,0 +1,98 @@ +using Application.Persistence.Interfaces; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Shared; +using EndUsersApplication.Persistence; +using EndUsersApplication.Persistence.ReadModels; +using EndUsersDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using QueryAny; + +namespace EndUsersInfrastructure.Persistence; + +public class InvitationRepository : IInvitationRepository +{ + private readonly ISnapshottingQueryStore _invitationQueries; + private readonly IEventSourcingDddCommandStore _users; + + public InvitationRepository(IRecorder recorder, IDomainFactory domainFactory, + IEventSourcingDddCommandStore usersStore, IDataStore store) + { + _invitationQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _users = usersStore; + } + + public async Task> DestroyAllAsync(CancellationToken cancellationToken) + { + return await Tasks.WhenAllAsync( + _invitationQueries.DestroyAllAsync(cancellationToken), + _users.DestroyAllAsync(cancellationToken)); + } + + public async Task, Error>> FindInvitedGuestByEmailAddressAsync( + EmailAddress emailAddress, CancellationToken cancellationToken) + { + var query = Query.From() + .Where(eu => eu.InvitedEmailAddress, ConditionOperator.EqualTo, emailAddress.Address) + .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered.ToString()); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task, Error>> FindInvitedGuestByTokenAsync(string token, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(eu => eu.Token, ConditionOperator.EqualTo, token) + .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered.ToString()); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task> LoadAsync(Identifier id, CancellationToken cancellationToken) + { + var user = await _users.LoadAsync(id, cancellationToken); + if (!user.IsSuccessful) + { + return user.Error; + } + + return user; + } + + public async Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken) + { + var saved = await _users.SaveAsync(user, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + return user; + } + + private async Task, Error>> FindFirstByQueryAsync(QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _invitationQueries.QueryAsync(query, false, cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var users = await _users.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (!users.IsSuccessful) + { + return users.Error; + } + + return users.Value.ToOptional(); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs index 2bedbc52..0188587d 100644 --- a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs +++ b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs @@ -5,22 +5,26 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Shared; +using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using EndUser = EndUsersApplication.Persistence.ReadModels.EndUser; using Membership = EndUsersApplication.Persistence.ReadModels.Membership; +using Tasks = Application.Persistence.Common.Extensions.Tasks; namespace EndUsersInfrastructure.Persistence.ReadModels; public class EndUserProjection : IReadModelProjection { + private readonly IReadModelProjectionStore _invitations; private readonly IReadModelProjectionStore _memberships; private readonly IReadModelProjectionStore _users; public EndUserProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) { _users = new ReadModelProjectionStore(recorder, domainFactory, store); + _invitations = new ReadModelProjectionStore(recorder, domainFactory, store); _memberships = new ReadModelProjectionStore(recorder, domainFactory, store); } @@ -32,23 +36,30 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Events.Created e: - return await _users.HandleCreateAsync(e.RootId.ToId(), dto => - { - dto.Classification = e.Classification; - dto.Access = e.Access; - dto.Status = e.Status; - }, cancellationToken); + return await Tasks.WhenAllAsync(_users.HandleCreateAsync(e.RootId.ToId(), dto => + { + dto.Classification = e.Classification; + dto.Access = e.Access; + dto.Status = e.Status; + }, cancellationToken), + _invitations.HandleCreateAsync(e.RootId.ToId(), dto => + { + dto.Status = e.Status; + }, + cancellationToken)); case Events.Registered e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => - { - dto.Classification = e.Classification; - dto.Access = e.Access; - dto.Status = e.Status; - dto.Username = e.Username; - dto.Roles = Roles.Create(e.Roles.ToArray()).Value; - dto.Features = Features.Create(e.Features.ToArray()).Value; - }, cancellationToken); + return await Tasks.WhenAllAsync(_users.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.Classification = e.Classification; + dto.Access = e.Access; + dto.Status = e.Status; + dto.Username = e.Username; + dto.Roles = Roles.Create(e.Roles.ToArray()).Value; + dto.Features = Features.Create(e.Features.ToArray()).Value; + }, cancellationToken), + _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Status = e.Status; }, + cancellationToken)); case Events.MembershipAdded e: return await _memberships.HandleCreateAsync(e.MembershipId.ToId(), dto => @@ -121,6 +132,20 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.Roles = roles.Value; }, cancellationToken); + case Events.PlatformRoleUnassigned e: + return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + { + var roles = dto.Roles.HasValue + ? dto.Roles.Value.Remove(e.Role) + : Roles.Create(e.Role); + if (!roles.IsSuccessful) + { + return; + } + + dto.Roles = roles.Value; + }, cancellationToken); + case Events.PlatformFeatureAssigned e: return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { @@ -135,6 +160,23 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.Features = features.Value; }, cancellationToken); + case Events.GuestInvitationCreated e: + return await _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.InvitedEmailAddress = e.EmailAddress; + dto.Token = e.Token; + dto.InvitedById = e.InvitedById; + }, cancellationToken); + + case Events.GuestInvitationAccepted e: + return await _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.Token = Optional.None; + dto.Status = UserStatus.Registered.ToString(); + dto.AcceptedAtUtc = e.AcceptedAtUtc; + dto.AcceptedEmailAddress = e.AcceptedEmailAddress; + }, cancellationToken); + default: return false; } diff --git a/src/EndUsersInfrastructure/Resources.Designer.cs b/src/EndUsersInfrastructure/Resources.Designer.cs index 1cb55b58..499b2d38 100644 --- a/src/EndUsersInfrastructure/Resources.Designer.cs +++ b/src/EndUsersInfrastructure/Resources.Designer.cs @@ -76,5 +76,32 @@ internal static string AssignPlatformRolesRequestValidator_InvalidRoles { return ResourceManager.GetString("AssignPlatformRolesRequestValidator_InvalidRoles", resourceCulture); } } + + /// + /// Looks up a localized string similar to The 'Email' is either missing or invalid. + /// + internal static string InviteGuestRequestValidator_InvalidEmail { + get { + return ResourceManager.GetString("InviteGuestRequestValidator_InvalidEmail", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Token' is either missing or invalid. + /// + internal static string ResendGuestInvitationRequestValidator_InvalidToken { + get { + return ResourceManager.GetString("ResendGuestInvitationRequestValidator_InvalidToken", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Token' is either missing or invalid. + /// + internal static string VerifyGuestInvitationRequestValidator_InvalidToken { + get { + return ResourceManager.GetString("VerifyGuestInvitationRequestValidator_InvalidToken", resourceCulture); + } + } } } diff --git a/src/EndUsersInfrastructure/Resources.resx b/src/EndUsersInfrastructure/Resources.resx index 75e74559..4bc2d41e 100644 --- a/src/EndUsersInfrastructure/Resources.resx +++ b/src/EndUsersInfrastructure/Resources.resx @@ -30,4 +30,13 @@ A Role is either missing or invalid + + The 'Email' is either missing or invalid + + + The 'Token' is either missing or invalid + + + The 'Token' is either missing or invalid + \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index d7aabe1c..79433d1e 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -286,7 +286,7 @@ public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenReturnsError() } [Fact] - public async Task WhenRegisterPersonUserAccountAndAlreadyExists_ThenDoesNothing() + public async Task WhenRegisterPersonAsyncAndAlreadyExists_ThenDoesNothing() { var endUser = new RegisteredEndUser { @@ -294,25 +294,26 @@ public async Task WhenRegisterPersonUserAccountAndAlreadyExists_ThenDoesNothing( }; _endUsersService.Setup(uas => uas.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult>(endUser)); var credential = CreateUnVerifiedCredential(); _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); - var result = await _application.RegisterPersonAsync(_caller.Object, "afirstname", + var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "afirstname", "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); result.Value.User.Should().Be(endUser); _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); - _endUsersService.Verify(uas => uas.RegisterPersonPrivateAsync(_caller.Object, + _endUsersService.Verify(uas => uas.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } [Fact] - public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsConfirmation() + public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirmation() { var registeredAccount = new RegisteredEndUser { @@ -332,13 +333,14 @@ public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsC }; _endUsersService.Setup(uas => uas.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult>(registeredAccount)); _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(Optional .None)); - var result = await _application.RegisterPersonAsync(_caller.Object, "afirstname", + var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "afirstname", "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); result.Value.User.Should().Be(registeredAccount); @@ -354,7 +356,7 @@ public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsC _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "adisplayname", "averificationtoken", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } diff --git a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs index 0e6829c2..d611ca5d 100644 --- a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs @@ -43,7 +43,8 @@ public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() _ssoProvidersService.Setup(sp => sp.FindByNameAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeError(ErrorCode.NotAuthenticated); @@ -60,7 +61,8 @@ public async Task WhenAuthenticateAndProviderErrors_ThenReturnsError() It.IsAny())) .ReturnsAsync(Error.Unexpected("amessage")); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeError(ErrorCode.Unexpected, "amessage"); @@ -101,15 +103,17 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT It.IsAny(), It.IsAny())) .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeError(ErrorCode.NotAuthenticated); _endUsersService.Verify(eus => eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify( + eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -152,15 +156,17 @@ public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() It.IsAny(), It.IsAny())) .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeError(ErrorCode.EntityExists, Resources.SingleSignOnApplication_AccountSuspended); _endUsersService.Verify(eus => eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify( + eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -174,9 +180,8 @@ public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() [Fact] public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssuesToken() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, - Timezones.Default, - CountryCodes.Default); + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, Timezones.Sydney, + CountryCodes.Australia); _ssoProvider.Setup(sp => sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -186,8 +191,8 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue It.IsAny())) .ReturnsAsync(Optional.None()); _endUsersService.Setup(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new RegisteredEndUser { Id = "aregistereduserid" @@ -206,8 +211,8 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue It.IsAny(), It.IsAny())) .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, - CancellationToken.None); + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeSuccess(); result.Value.AccessToken.Value.Should().Be("anaccesstoken"); @@ -216,9 +221,9 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); _endUsersService.Verify(eus => eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "auser@company.com", "afirstname", - null, - Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", + "auser@company.com", "afirstname", null, Timezones.Sydney.ToString(), CountryCodes.Australia.ToString(), + true, It.IsAny())); _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "aregistereduserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => @@ -260,7 +265,8 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() It.IsAny(), It.IsAny())) .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); - var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", + "anauthcode", null, CancellationToken.None); result.Should().BeSuccess(); @@ -270,9 +276,10 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); _endUsersService.Verify(eus => eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify( + eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "anexistinguserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.cs b/src/IdentityApplication/IPasswordCredentialsApplication.cs index b053d0b5..eec814dc 100644 --- a/src/IdentityApplication/IPasswordCredentialsApplication.cs +++ b/src/IdentityApplication/IPasswordCredentialsApplication.cs @@ -17,8 +17,8 @@ Task> GetPersonRegistrationConfirm string userId, CancellationToken cancellationToken); #endif - Task> RegisterPersonAsync(ICallerContext context, string firstName, - string lastName, - string emailAddress, string password, string? timezone, string? countryCode, bool termsAndConditionsAccepted, - CancellationToken cancellationToken); + Task> RegisterPersonAsync(ICallerContext context, string? invitationToken, + string firstName, + string lastName, string emailAddress, string password, string? timezone, string? countryCode, + bool termsAndConditionsAccepted, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/ISingleSignOnApplication.cs b/src/IdentityApplication/ISingleSignOnApplication.cs index aa81a6d8..5f1818d5 100644 --- a/src/IdentityApplication/ISingleSignOnApplication.cs +++ b/src/IdentityApplication/ISingleSignOnApplication.cs @@ -6,6 +6,7 @@ namespace IdentityApplication; public interface ISingleSignOnApplication { - Task> AuthenticateAsync(ICallerContext context, string providerName, + Task> AuthenticateAsync(ICallerContext context, string? invitationToken, + string providerName, string authCode, string? username, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 2bffadf8..88afad8f 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -188,12 +188,14 @@ async Task> VerifyPasswordAsync() } } - public async Task> RegisterPersonAsync(ICallerContext context, string firstName, + public async Task> RegisterPersonAsync(ICallerContext context, + string? invitationToken, string firstName, string lastName, string emailAddress, string password, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { - var registered = await _endUsersService.RegisterPersonPrivateAsync(context, emailAddress, firstName, lastName, + var registered = await _endUsersService.RegisterPersonPrivateAsync(context, invitationToken, emailAddress, + firstName, lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken); if (!registered.IsSuccessful) { diff --git a/src/IdentityApplication/SingleSignOnApplication.cs b/src/IdentityApplication/SingleSignOnApplication.cs index 68ca7639..a4956ebd 100644 --- a/src/IdentityApplication/SingleSignOnApplication.cs +++ b/src/IdentityApplication/SingleSignOnApplication.cs @@ -24,7 +24,8 @@ public SingleSignOnApplication(IRecorder recorder, IEndUsersService endUsersServ _authTokensService = authTokensService; } - public async Task> AuthenticateAsync(ICallerContext context, string providerName, + public async Task> AuthenticateAsync(ICallerContext context, + string? invitationToken, string providerName, string authCode, string? username, CancellationToken cancellationToken) { var retrieved = await _ssoProvidersService.FindByNameAsync(providerName, cancellationToken); @@ -56,7 +57,8 @@ public async Task> AuthenticateAsync(ICallerCo string registeredUserId; if (!userExists.Value.HasValue) { - var autoRegistered = await _endUsersService.RegisterPersonPrivateAsync(context, userInfo.EmailAddress, + var autoRegistered = await _endUsersService.RegisterPersonPrivateAsync(context, invitationToken, + userInfo.EmailAddress, userInfo.FirstName, userInfo.LastName, userInfo.Timezone.ToString(), userInfo.CountryCode.ToString(), true, cancellationToken); diff --git a/src/IdentityDomain/Validations.cs b/src/IdentityDomain/Validations.cs index f301d2ba..0c047f31 100644 --- a/src/IdentityDomain/Validations.cs +++ b/src/IdentityDomain/Validations.cs @@ -17,6 +17,7 @@ public static class Machine public static class Credentials { public static readonly Validation VerificationToken = CommonValidations.RandomToken(); + public static readonly Validation InvitationToken = CommonValidations.RandomToken(); public static class Person { diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonPasswordRequestValidatorSpec.cs similarity index 70% rename from src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs rename to src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonPasswordRequestValidatorSpec.cs index 49c3e6a7..af2247ff 100644 --- a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonRequestValidatorSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/RegisterPersonPasswordRequestValidatorSpec.cs @@ -9,14 +9,14 @@ namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; [Trait("Category", "Unit")] -public class RegisterPersonRequestValidatorSpec +public class RegisterPersonPasswordRequestValidatorSpec { private readonly RegisterPersonPasswordRequest _dto; - private readonly RegisterPersonRequestValidator _validator; + private readonly RegisterPersonPasswordRequestValidator _validator; - public RegisterPersonRequestValidatorSpec() + public RegisterPersonPasswordRequestValidatorSpec() { - _validator = new RegisterPersonRequestValidator(); + _validator = new RegisterPersonPasswordRequestValidator(); _dto = new RegisterPersonPasswordRequest { FirstName = "afirstname", @@ -35,6 +35,25 @@ public void WhenAllProperties_ThenSucceeds() _validator.ValidateAndThrow(_dto); } + [Fact] + public void WhenInvitationTokenIsEmpty_ThenSucceeds() + { + _dto.InvitationToken = string.Empty; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenInvitationTokenIsInvalid_ThenThrows() + { + _dto.InvitationToken = "aninvalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidInvitationToken); + } + [Fact] public void WhenEmailIsEmpty_ThenThrows() { @@ -43,7 +62,7 @@ public void WhenEmailIsEmpty_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidEmail); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidEmail); } [Fact] @@ -54,7 +73,7 @@ public void WhenEmailIsNotEmail_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidEmail); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidEmail); } [Fact] @@ -65,7 +84,7 @@ public void WhenPasswordIsEmpty_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidPassword); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidPassword); } [Fact] @@ -76,7 +95,7 @@ public void WhenFirstNameIsEmpty_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidFirstName); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidFirstName); } [Fact] @@ -87,7 +106,7 @@ public void WhenFirstNameIsInvalid_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidFirstName); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidFirstName); } [Fact] @@ -98,7 +117,7 @@ public void WhenLastNameIsEmpty_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidLastName); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidLastName); } [Fact] @@ -109,7 +128,7 @@ public void WhenLastNameIsInvalid_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidLastName); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidLastName); } [Fact] @@ -158,6 +177,6 @@ public void WhenTermsAndConditionsAcceptedIsFalse_ThenThrows() _validator .Invoking(x => x.ValidateAndThrow(_dto)) .Should().Throw() - .WithMessageLike(Resources.RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted); + .WithMessageLike(Resources.RegisterPersonPasswordRequestValidator_InvalidTermsAndConditionsAccepted); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/SSO/AuthenticateSingleSignOnRequestValidatorSpec.cs similarity index 77% rename from src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs rename to src/IdentityInfrastructure.UnitTests/Api/SSO/AuthenticateSingleSignOnRequestValidatorSpec.cs index 90bb9062..d257b58d 100644 --- a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/Api/SSO/AuthenticateSingleSignOnRequestValidatorSpec.cs @@ -1,11 +1,11 @@ using FluentAssertions; using FluentValidation; -using IdentityInfrastructure.Api.PasswordCredentials; +using IdentityInfrastructure.Api.SSO; using Infrastructure.Web.Api.Operations.Shared.Identities; using UnitTesting.Common.Validation; using Xunit; -namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; +namespace IdentityInfrastructure.UnitTests.Api.SSO; [Trait("Category", "Unit")] public class AuthenticateSingleSignOnRequestValidatorSpec @@ -30,6 +30,25 @@ public void WhenAllProperties_ThenSucceeds() _validator.ValidateAndThrow(_dto); } + [Fact] + public void WhenInvitationTokenIsEmpty_ThenSucceeds() + { + _dto.InvitationToken = string.Empty; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenInvitationTokenIsInvalid_ThenThrows() + { + _dto.InvitationToken = "aninvalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateSingleSignOnRequestValidator_InvalidInvitationToken); + } + [Fact] public void WhenProviderIsEmpty_ThenThrows() { diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs index 1c18b98e..91c8a633 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -68,9 +68,9 @@ public async Task credential.HandleApplicationResult(creds => new PostResult(new RegisterPersonPasswordResponse { Credential = creds })); diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonPasswordRequestValidator.cs similarity index 60% rename from src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs rename to src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonPasswordRequestValidator.cs index adba8497..9a7aa844 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonRequestValidator.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/RegisterPersonPasswordRequestValidator.cs @@ -7,26 +7,30 @@ namespace IdentityInfrastructure.Api.PasswordCredentials; -public class RegisterPersonRequestValidator : AbstractValidator +public class RegisterPersonPasswordRequestValidator : AbstractValidator { - public RegisterPersonRequestValidator() + public RegisterPersonPasswordRequestValidator() { + RuleFor(req => req.InvitationToken) + .Matches(Validations.Credentials.InvitationToken) + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidInvitationToken) + .When(req => req.InvitationToken.HasValue()); RuleFor(req => req.FirstName) .NotEmpty() .Matches(Validations.Credentials.Person.Name) - .WithMessage(Resources.RegisterPersonRequestValidator_InvalidFirstName); + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidFirstName); RuleFor(req => req.LastName) .NotEmpty() .Matches(Validations.Credentials.Person.Name) - .WithMessage(Resources.RegisterPersonRequestValidator_InvalidLastName); + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidLastName); RuleFor(req => req.EmailAddress) .NotEmpty() .IsEmailAddress() - .WithMessage(Resources.RegisterPersonRequestValidator_InvalidEmail); + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidEmail); RuleFor(req => req.Password) .NotEmpty() .Matches(CommonValidations.Passwords.Password.Strict) - .WithMessage(Resources.RegisterPersonRequestValidator_InvalidPassword); + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidPassword); RuleFor(req => req.Timezone) .NotEmpty() .Matches(CommonValidations.Timezone) @@ -40,6 +44,6 @@ public RegisterPersonRequestValidator() RuleFor(req => req.TermsAndConditionsAccepted) .NotEmpty() .Must(req => req) - .WithMessage(Resources.RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted); + .WithMessage(Resources.RegisterPersonPasswordRequestValidator_InvalidTermsAndConditionsAccepted); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs b/src/IdentityInfrastructure/Api/SSO/AuthenticateSingleSignOnRequestValidator.cs similarity index 72% rename from src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs rename to src/IdentityInfrastructure/Api/SSO/AuthenticateSingleSignOnRequestValidator.cs index c8419906..4751624b 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs +++ b/src/IdentityInfrastructure/Api/SSO/AuthenticateSingleSignOnRequestValidator.cs @@ -1,14 +1,19 @@ using Common.Extensions; using FluentValidation; +using IdentityDomain; using Infrastructure.Web.Api.Common.Validation; using Infrastructure.Web.Api.Operations.Shared.Identities; -namespace IdentityInfrastructure.Api.PasswordCredentials; +namespace IdentityInfrastructure.Api.SSO; public class AuthenticateSingleSignOnRequestValidator : AbstractValidator { public AuthenticateSingleSignOnRequestValidator() { + RuleFor(req => req.InvitationToken) + .Matches(Validations.Credentials.InvitationToken) + .WithMessage(Resources.AuthenticateSingleSignOnRequestValidator_InvalidInvitationToken) + .When(req => req.InvitationToken.HasValue()); RuleFor(req => req.Provider) .NotEmpty() .WithMessage(Resources.AuthenticateSingleSignOnRequestValidator_InvalidProvider); diff --git a/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs index a6b6498b..354276ad 100644 --- a/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs +++ b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs @@ -23,7 +23,8 @@ public async Task> Authe AuthenticateSingleSignOnRequest request, CancellationToken cancellationToken) { var authenticated = - await _singleSignOnApplication.AuthenticateAsync(_contextFactory.Create(), request.Provider, + await _singleSignOnApplication.AuthenticateAsync(_contextFactory.Create(), request.InvitationToken, + request.Provider, request.AuthCode, request.Username, cancellationToken); return () => authenticated.HandleApplicationResult(tok => diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs index 3ce49531..40cce192 100644 --- a/src/IdentityInfrastructure/Resources.Designer.cs +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -86,6 +86,15 @@ internal static string AuthenticateSingleSignOnRequestValidator_InvalidAuthCode } } + /// + /// Looks up a localized string similar to The 'InvitationToken' is either missing or invalid. + /// + internal static string AuthenticateSingleSignOnRequestValidator_InvalidInvitationToken { + get { + return ResourceManager.GetString("AuthenticateSingleSignOnRequestValidator_InvalidInvitationToken", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Provider' is invalid or missing. /// @@ -170,45 +179,54 @@ internal static string RegisterMachineRequestValidator_InvalidName { /// /// Looks up a localized string similar to The 'Email' is either missing or is an invalid email address. /// - internal static string RegisterPersonRequestValidator_InvalidEmail { + internal static string RegisterPersonPasswordRequestValidator_InvalidEmail { get { - return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidEmail", resourceCulture); + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidEmail", resourceCulture); } } /// /// Looks up a localized string similar to The 'FirstName' was either missing or is invalid. /// - internal static string RegisterPersonRequestValidator_InvalidFirstName { + internal static string RegisterPersonPasswordRequestValidator_InvalidFirstName { + get { + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidFirstName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'InvitationToken' is either missing or invalid. + /// + internal static string RegisterPersonPasswordRequestValidator_InvalidInvitationToken { get { - return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidFirstName", resourceCulture); + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidInvitationToken", resourceCulture); } } /// /// Looks up a localized string similar to The 'LastName' was either missing or is invalid. /// - internal static string RegisterPersonRequestValidator_InvalidLastName { + internal static string RegisterPersonPasswordRequestValidator_InvalidLastName { get { - return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidLastName", resourceCulture); + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidLastName", resourceCulture); } } /// /// Looks up a localized string similar to The 'Password' is either missing or invalid. /// - internal static string RegisterPersonRequestValidator_InvalidPassword { + internal static string RegisterPersonPasswordRequestValidator_InvalidPassword { get { - return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidPassword", resourceCulture); + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidPassword", resourceCulture); } } /// /// Looks up a localized string similar to The 'TermsAndConditionsAccepted' must be True. /// - internal static string RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted { + internal static string RegisterPersonPasswordRequestValidator_InvalidTermsAndConditionsAccepted { get { - return ResourceManager.GetString("RegisterPersonRequestValidator_InvalidTermsAndConditionsAccepted", resourceCulture); + return ResourceManager.GetString("RegisterPersonPasswordRequestValidator_InvalidTermsAndConditionsAccepted", resourceCulture); } } diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx index 6d972222..ac02ac1f 100644 --- a/src/IdentityInfrastructure/Resources.resx +++ b/src/IdentityInfrastructure/Resources.resx @@ -24,16 +24,16 @@ PublicKeyToken=b77a5c561934e089 - + The 'FirstName' was either missing or is invalid - + The 'LastName' was either missing or is invalid - + The 'Email' is either missing or is an invalid email address - + The 'Password' is either missing or invalid @@ -42,7 +42,7 @@ The 'CountryCode' is not a valid ISO3166 alpha-2 or alpha-3 code or numeric - + The 'TermsAndConditionsAccepted' must be True @@ -81,4 +81,10 @@ The 'Username' must be provided for this authentication attempt + + The 'InvitationToken' is either missing or invalid + + + The 'InvitationToken' is either missing or invalid + \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs index 847dc516..e31014d8 100644 --- a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs @@ -13,9 +13,9 @@ namespace Infrastructure.Shared.ApplicationServices; /// public class EmailNotificationsService : INotificationsService { - public const string ProductNameSettingName = "ApplicationServices:Notifications:SenderProductName"; - public const string SenderDisplayNameSettingName = "ApplicationServices:Notifications:SenderDisplayName"; - public const string SenderEmailAddressSettingName = "ApplicationServices:Notifications:SenderEmailAddress"; + private const string ProductNameSettingName = "ApplicationServices:Notifications:SenderProductName"; + private const string SenderDisplayNameSettingName = "ApplicationServices:Notifications:SenderDisplayName"; + private const string SenderEmailAddressSettingName = "ApplicationServices:Notifications:SenderEmailAddress"; private readonly IEmailSchedulingService _emailSchedulingService; private readonly IHostSettings _hostSettings; private readonly string _productName; @@ -35,6 +35,32 @@ public EmailNotificationsService(IConfigurationSettings settings, IHostSettings _senderName = settings.Platform.GetString(SenderDisplayNameSettingName, nameof(EmailNotificationsService)); } + public async Task> NotifyGuestInvitationToPlatformAsync(ICallerContext caller, string token, + string inviteeEmailAddress, + string inviteeName, string inviterName, CancellationToken cancellationToken) + { + var webSiteUrl = _hostSettings.GetWebsiteHostBaseUrl(); + var webSiteRoute = _websiteUiService.CreateRegistrationPageUrl(token); + var link = webSiteUrl.WithoutTrailingSlash() + webSiteRoute; + var htmlBody = + $""" +

Hello,

+

You have been invited by {inviterName} to {_productName}.

+

Please click this link to sign up

+

This is an automated email from the support team at {_productName}

+ """; + + return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail + { + Subject = $"Welcome to {_productName}", + Body = htmlBody, + FromEmailAddress = _senderEmailAddress, + FromDisplayName = _senderName, + ToEmailAddress = inviteeEmailAddress, + ToDisplayName = inviteeName + }, cancellationToken); + } + public async Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, string emailAddress, string name, string token, CancellationToken cancellationToken) @@ -43,10 +69,12 @@ public async Task> NotifyPasswordRegistrationConfirmationAsync(ICa var webSiteRoute = _websiteUiService.ConstructPasswordRegistrationConfirmationPageUrl(token); var link = webSiteUrl.WithoutTrailingSlash() + webSiteRoute; var htmlBody = - $"

Hello {name},

" + - $"

Thank you for signing up at {_productName}.

" + - $"

Please click this link to confirm your email address

" + - $"

This is an automated email from the support team at {_productName}

"; + $""" +

Hello {name},

+

Thank you for signing up at {_productName}.

+

Please click this link to confirm your email address

+

This is an automated email from the support team at {_productName}

+ """; return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail { @@ -64,18 +92,16 @@ public async Task> NotifyReRegistrationCourtesyAsync(ICallerContex string? timezone, string? countryCode, CancellationToken cancellationToken) { var htmlBody = - $"

Hello {name},

" + - $"

We have received a request to register a person using your email address at our web site {_productName}.

" - + - $"

Of course, your email address ('{emailAddress}') has already been registered at our site.

" + - "

If you are already aware of this, then there is nothing more to do.

" + - "

It is possible that some unknown party is trying to find out if your email address is already registered on this site, byt trying to re-register it.

" - + - "

We have blocked this attempt from succeeding, and no new account has been created. Your account is still safe.

" - + - "

We just thought you would like to know, that this is going on. There is nothing more you need to do.

" - + - $"

This is an automated email from the support team at {_productName}

"; + $""" +

Hello {name},

+

We have received a request to register a person using your email address at our web site {_productName}.

+

Of course, your email address ('{emailAddress}') has already been registered at our site.

+

If you are already aware of this activity, then there is nothing more to do.

+

It is possible that some unknown party is trying to find out if your email address is already registered on this site, by trying to re-register it.

+

We have blocked this attempt from succeeding, and no new account has been created. Your account is still safe.

+

We just thought you would like to know, that this is going on. There is nothing more you need to do.

+

This is an automated email from the support team at {_productName}

+ """; return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail { diff --git a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs index 0cd000af..d290e654 100644 --- a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs @@ -7,11 +7,19 @@ namespace Infrastructure.Shared.ApplicationServices; /// public sealed class WebsiteUiService : IWebsiteUiService { - private const string RegistrationConfirmationPageRoute = "/confirm-registeration"; + //EXTEND: these URLs must reflect those used by the website that handles UI + private const string PasswordRegistrationConfirmationPageRoute = "/confirm-password-registration"; + private const string RegistrationPageRoute = "/register"; public string ConstructPasswordRegistrationConfirmationPageUrl(string token) { var escapedToken = Uri.EscapeDataString(token); - return $"{RegistrationConfirmationPageRoute}?token={escapedToken}"; + return $"{PasswordRegistrationConfirmationPageRoute}?token={escapedToken}"; + } + + public string CreateRegistrationPageUrl(string token) + { + var escapedToken = Uri.EscapeDataString(token); + return $"{RegistrationPageRoute}?token={escapedToken}"; } } \ No newline at end of file diff --git a/src/Infrastructure.Shared/DomainServices/TokensService.cs b/src/Infrastructure.Shared/DomainServices/TokensService.cs index 41dfa604..e1cfffd9 100644 --- a/src/Infrastructure.Shared/DomainServices/TokensService.cs +++ b/src/Infrastructure.Shared/DomainServices/TokensService.cs @@ -10,11 +10,6 @@ public sealed class TokensService : ITokensService { private const int DefaultTokenSizeInBytes = 32; - public string CreatePasswordResetToken() - { - return GenerateRandomTokenSafeForUrl(); - } - public APIKeyToken CreateAPIKey() { var token = GenerateRandomTokenSafeForUrl(CommonValidations.APIKeys.ApiKeyTokenSize, @@ -31,11 +26,21 @@ public APIKeyToken CreateAPIKey() }; } + public string CreateGuestInvitationToken() + { + return GenerateRandomTokenSafeForUrl(); + } + public string CreateJWTRefreshToken() { return GenerateRandomTokenSafeForUrl(); } + public string CreatePasswordResetToken() + { + return GenerateRandomTokenSafeForUrl(); + } + public string CreateRegistrationVerificationToken() { return GenerateRandomTokenSafeForUrl(); diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestRequest.cs new file mode 100644 index 00000000..857bcce1 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/invitations", ServiceOperation.Post, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_Basic)] +public class InviteGuestRequest : UnTenantedRequest +{ + public required string Email { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestResponse.cs new file mode 100644 index 00000000..a6799c6c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/InviteGuestResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +public class InviteGuestResponse : IWebResponse +{ + public Invitation? Invitation { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ResendGuestInvitationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ResendGuestInvitationRequest.cs new file mode 100644 index 00000000..a1eb2481 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ResendGuestInvitationRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/invitations/{Token}/resend", ServiceOperation.Post, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_Basic)] +public class ResendGuestInvitationRequest : UnTenantedEmptyRequest +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs new file mode 100644 index 00000000..c8f5642a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs @@ -0,0 +1,12 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/users/{id}/roles", ServiceOperation.PutPatch, AccessType.Token)] +[Authorize(Interfaces.Roles.Platform_Operations)] +public class UnassignPlatformRolesRequest : UnTenantedRequest +{ + public required string Id { get; set; } + + public List? Roles { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationRequest.cs new file mode 100644 index 00000000..7429b60c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/invitations/{Token}/verify", ServiceOperation.Get)] +public class VerifyGuestInvitationRequest : UnTenantedRequest +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationResponse.cs new file mode 100644 index 00000000..a5fb6d58 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/VerifyGuestInvitationResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +public class VerifyGuestInvitationResponse : IWebResponse +{ + public Invitation? Invitation { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs index 0e250c93..ece51c51 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs @@ -7,6 +7,8 @@ public class AuthenticateSingleSignOnRequest : UnTenantedRequest> NotifyGuestInvitationToPlatformAsync(ICallerContext caller, string token, + string inviteeEmailAddress, + string inviteeName, string inviterName, CancellationToken cancellationToken) + { + LastGuestInvitationEmailRecipient = inviteeEmailAddress; + LastGuestInvitationToken = token; + return Task.FromResult(Result.Ok); + } + public Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, string emailAddress, string name, string token, CancellationToken cancellationToken) { @@ -52,6 +63,7 @@ public void Reset() LastReRegistrationCourtesyEmailRecipient = null; LastRegistrationConfirmationToken = null; LastEmailChangeConfirmationToken = null; + LastGuestInvitationEmailRecipient = null; LastGuestInvitationToken = null; LastPasswordResetToken = null; } diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 0b1c34d4..d4d981de 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -186,22 +186,9 @@ protected async Task LoginUserAsync(LoginUser who = LoginUser.Pers _ => throw new ArgumentOutOfRangeException(nameof(who), who, null) }; - var person = await Api.PostAsync(new RegisterPersonPasswordRequest - { - EmailAddress = emailAddress, - FirstName = firstName, - LastName = "alastname", - Password = PasswordForPerson, - TermsAndConditionsAccepted = true - }); + var person = await RegisterUserAsync(emailAddress, firstName); - var token = NotificationsService.LastRegistrationConfirmationToken; - await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest - { - Token = token! - }); - - return await ReAuthenticateUserAsync(person.Content.Value.Credential!.User, who); + return await ReAuthenticateUserAsync(person.Credential!.User, who); } protected async Task ReAuthenticateUserAsync(RegisteredEndUser user, @@ -221,6 +208,27 @@ protected async Task ReAuthenticateUserAsync(RegisteredEndUser use return new LoginDetails(accessToken, refreshToken, user); } + protected async Task RegisterUserAsync(string emailAddress, + string firstName = "afirstname", string lastName = "alastname") + { + var person = await Api.PostAsync(new RegisterPersonPasswordRequest + { + EmailAddress = emailAddress, + FirstName = firstName, + LastName = lastName, + Password = PasswordForPerson, + TermsAndConditionsAccepted = true + }); + + var token = NotificationsService.LastRegistrationConfirmationToken; + await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest + { + Token = token! + }); + + return person.Content.Value; + } + protected void StartupServer() where TAnotherHost : class { diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index dafa8941..18ec4ccc 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -912,6 +912,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -927,6 +928,12 @@ public void When$condition$_Then$outcome$() True True True + True + True + True + True + True + True True True True @@ -953,6 +960,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1110,6 +1118,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs index 2754cf7c..f854e5f5 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs @@ -175,7 +175,7 @@ public async Task WhenChangeProfileAsyncAndNotOwner_ThenReturnsError() result.Should().BeError(ErrorCode.ForbiddenAccess); } - + [Fact] public async Task WhenChangeProfileAsyncAndNotExists_ThenReturnsError() { @@ -188,10 +188,9 @@ public async Task WhenChangeProfileAsyncAndNotExists_ThenReturnsError() result.Should().BeError(ErrorCode.EntityNotFound); } - [Fact] - public async Task WhenChangeProfileAsync_ThenReturnsError() + public async Task WhenChangeProfileAsync_ThenChangesProfile() { _caller.Setup(cc => cc.CallerId) .Returns("auserid"); @@ -224,12 +223,13 @@ public async Task WhenChangeContactAddressAsyncAndNotOwner_ThenReturnsError() result.Should().BeError(ErrorCode.ForbiddenAccess); } + [Fact] public async Task WhenChangeContactAddressAsyncAndNotExists_ThenReturnsError() { _caller.Setup(cc => cc.CallerId) .Returns("auserid"); - + var result = await _application.ChangeContactAddressAsync(_caller.Object, "auserid", "anewline1", "anewline2", "anewline3", "anewcity", "anewstate", CountryCodes.Australia.ToString(), "anewzipcode", CancellationToken.None); @@ -260,4 +260,53 @@ public async Task WhenChangeContactAddressAsync_ThenReturnsError() result.Value.Address.CountryCode.Should().Be(CountryCodes.Australia.ToString()); result.Value.Address.Zip.Should().Be("anewzipcode"); } + + [Fact] + public async Task WhenGetProfileAsyncAndNotOwner_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _application.GetProfileAsync(_caller.Object, "anotheruserid", CancellationToken.None); + + result.Should().BeError(ErrorCode.ForbiddenAccess); + } + + [Fact] + public async Task WhenGetProfileAsyncAndNotExists_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _application.GetProfileAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenGetProfileAsync_ThenReturnsProfile() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + + var profile = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), + PersonName.Create("afirstname", "alastname").Value).Value; + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(profile.ToOptional()); + + var result = await _application.GetProfileAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.FirstName.Should().Be("afirstname"); + result.Value.Name.LastName.Should().Be("alastname"); + result.Value.DisplayName.Should().Be("afirstname"); + result.Value.Timezone.Should().Be(Timezones.Default.ToString()); + result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + } } \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.cs b/src/UserProfilesApplication/IUserProfilesApplication.cs index 089771cf..c9b9372b 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.cs @@ -21,4 +21,7 @@ Task> CreateProfileAsync(ICallerContext caller, UserP Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); + + Task> GetProfileAsync(ICallerContext caller, string userId, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/UserProfilesApplication.cs b/src/UserProfilesApplication/UserProfilesApplication.cs index 42c336c0..cfde1a97 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -27,8 +27,8 @@ public UserProfilesApplication(IRecorder recorder, IIdentifierFactory identifier } public async Task> CreateProfileAsync(ICallerContext caller, UserProfileType type, - string userId, string? emailAddress, - string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken) + string userId, string? emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, + CancellationToken cancellationToken) { if (type == UserProfileType.Person && emailAddress.HasNoValue()) { @@ -156,6 +156,31 @@ public async Task, Error>> FindPersonByEmailAddress return Optional.None; } + public async Task> GetProfileAsync(ICallerContext caller, string userId, + CancellationToken cancellationToken) + { + if (userId != caller.CallerId) + { + return Error.ForbiddenAccess(); + } + + var retrieved = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var profile = retrieved.Value.Value; + + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was retrieved for user {userId}", profile.Id, userId); + return profile.ToProfile(); + } + public async Task> ChangeProfileAsync(ICallerContext caller, string userId, string? firstName, string? lastName, string? displayName, string? phoneNumber, string? timezone, CancellationToken cancellationToken) diff --git a/src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileContactAddressRequestValidatorSpec.cs b/src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileContactAddressRequestValidatorSpec.cs similarity index 96% rename from src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileContactAddressRequestValidatorSpec.cs rename to src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileContactAddressRequestValidatorSpec.cs index d6a654d4..3240099e 100644 --- a/src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileContactAddressRequestValidatorSpec.cs +++ b/src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileContactAddressRequestValidatorSpec.cs @@ -3,10 +3,10 @@ using FluentValidation; using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using UnitTesting.Common.Validation; -using UserProfilesInfrastructure.Api; +using UserProfilesInfrastructure.Api.Profiles; using Xunit; -namespace UserProfilesInfrastructure.UnitTests.Api; +namespace UserProfilesInfrastructure.UnitTests.Api.Profiles; [Trait("Category", "Unit")] public class ChangeProfileContactAddressRequestValidatorSpec diff --git a/src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileRequestValidatorSpec.cs b/src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileRequestValidatorSpec.cs similarity index 95% rename from src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileRequestValidatorSpec.cs rename to src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileRequestValidatorSpec.cs index 5c6f8654..5bb6d0f2 100644 --- a/src/UserProfilesInfrastructure.UnitTests/Api/ChangeProfileRequestValidatorSpec.cs +++ b/src/UserProfilesInfrastructure.UnitTests/Api/Profiles/ChangeProfileRequestValidatorSpec.cs @@ -3,10 +3,10 @@ using FluentValidation; using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using UnitTesting.Common.Validation; -using UserProfilesInfrastructure.Api; +using UserProfilesInfrastructure.Api.Profiles; using Xunit; -namespace UserProfilesInfrastructure.UnitTests.Api; +namespace UserProfilesInfrastructure.UnitTests.Api.Profiles; [Trait("Category", "Unit")] public class ChangeProfileRequestValidatorSpec diff --git a/src/UserProfilesInfrastructure/Api/ChangeProfileContactAddressRequestValidator.cs b/src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileContactAddressRequestValidator.cs similarity index 97% rename from src/UserProfilesInfrastructure/Api/ChangeProfileContactAddressRequestValidator.cs rename to src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileContactAddressRequestValidator.cs index 98b1fdad..a4803b4a 100644 --- a/src/UserProfilesInfrastructure/Api/ChangeProfileContactAddressRequestValidator.cs +++ b/src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileContactAddressRequestValidator.cs @@ -6,7 +6,7 @@ using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using UserProfilesDomain; -namespace UserProfilesInfrastructure.Api; +namespace UserProfilesInfrastructure.Api.Profiles; public class ChangeProfileContactAddressRequestValidator : AbstractValidator { diff --git a/src/UserProfilesInfrastructure/Api/ChangeProfileRequestValidator.cs b/src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileRequestValidator.cs similarity index 97% rename from src/UserProfilesInfrastructure/Api/ChangeProfileRequestValidator.cs rename to src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileRequestValidator.cs index 5a761de8..77afbebe 100644 --- a/src/UserProfilesInfrastructure/Api/ChangeProfileRequestValidator.cs +++ b/src/UserProfilesInfrastructure/Api/Profiles/ChangeProfileRequestValidator.cs @@ -6,7 +6,7 @@ using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using UserProfilesDomain; -namespace UserProfilesInfrastructure.Api; +namespace UserProfilesInfrastructure.Api.Profiles; public class ChangeProfileRequestValidator : AbstractValidator { diff --git a/src/UserProfilesInfrastructure/Api/UserProfilesApi.cs b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs similarity index 97% rename from src/UserProfilesInfrastructure/Api/UserProfilesApi.cs rename to src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs index b638d87e..f37e10fd 100644 --- a/src/UserProfilesInfrastructure/Api/UserProfilesApi.cs +++ b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs @@ -5,7 +5,7 @@ using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using UserProfilesApplication; -namespace UserProfilesInfrastructure.Api; +namespace UserProfilesInfrastructure.Api.Profiles; public class UserProfilesApi : IWebApiService { diff --git a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs index 53160e7c..b2dc9d66 100644 --- a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs +++ b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs @@ -36,4 +36,10 @@ public async Task, Error>> FindPersonByEmailAddress { return await _userProfilesApplication.FindPersonByEmailAddressAsync(caller, emailAddress, cancellationToken); } + + public async Task> GetProfilePrivateAsync(ICallerContext caller, string userId, + CancellationToken cancellationToken) + { + return await _userProfilesApplication.GetProfileAsync(caller, userId, cancellationToken); + } } \ No newline at end of file diff --git a/src/UserProfilesInfrastructure/UserProfilesModule.cs b/src/UserProfilesInfrastructure/UserProfilesModule.cs index eba50e22..0557754a 100644 --- a/src/UserProfilesInfrastructure/UserProfilesModule.cs +++ b/src/UserProfilesInfrastructure/UserProfilesModule.cs @@ -13,7 +13,7 @@ using UserProfilesApplication; using UserProfilesApplication.Persistence; using UserProfilesDomain; -using UserProfilesInfrastructure.Api; +using UserProfilesInfrastructure.Api.Profiles; using UserProfilesInfrastructure.ApplicationServices; using UserProfilesInfrastructure.Persistence; using UserProfilesInfrastructure.Persistence.ReadModels;