diff --git a/docs/design-principles/0150-all-use-cases.md b/docs/design-principles/0150-all-use-cases.md index 55dbca3a..22738eb5 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,6 @@ TBD 1. Create a new organization for the current user 2. Inspect a specific organization -3. ### Subscriptions @@ -104,9 +107,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..d0d5e9ff --- /dev/null +++ b/docs/design-principles/0160-user-lifecycle.md @@ -0,0 +1,86 @@ +# 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 later 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 and for persons and machines. +* `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 into 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 includes the referral `InvitationToken`. +* In the server, the `InvitationToken` is used to "accept" the "guest invitation" before registering the user. +* The user is registered with their own `Personal` organization and has no other memberships with any other organizations. + +#### To An Organization + +TBD + 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 9440ed0a..bcda6642 100644 Binary files a/docs/images/EndUser-Lifecycle.png and b/docs/images/EndUser-Lifecycle.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 771dc818..531fe4df 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs index af3cc886..abaa3682 100644 --- a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs +++ b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs @@ -5,12 +5,12 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; +using Tasks = Common.Extensions.Tasks; namespace AncillaryInfrastructure.Persistence; diff --git a/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs b/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs index e3efff89..7865a180 100644 --- a/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs +++ b/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs @@ -11,6 +11,7 @@ using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; +using Tasks = Common.Extensions.Tasks; namespace AncillaryInfrastructure.Persistence; diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs index dbfd696d..feb009c3 100644 --- a/src/Application.Interfaces/Audits.Designer.cs +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -68,6 +68,15 @@ public static string EndUserApplication_PlatformRolesAssigned { } } + /// + /// 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..2cb74229 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,40 @@ 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(), 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 +218,9 @@ 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 +237,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 +253,79 @@ 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 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 +351,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 +360,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 +379,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 +393,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 +508,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 +573,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 +589,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 +626,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 +653,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 +673,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 +689,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 +712,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 +734,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 +750,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..20cacacf 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,133 +147,48 @@ 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) { return username.Error; } - var existingUser = await FindPersonByEmailAddressInternalAsync(context, username.Value, cancellationToken); - if (!existingUser.IsSuccessful) + if (invitationToken.HasValue()) { - return existingUser.Error; - } + var retrievedGuest = + await _invitationRepository.FindInvitedGuestByTokenAsync(invitationToken, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } - EndUserRoot user; - UserProfile? profile; - if (existingUser.Value.HasValue) - { - user = existingUser.Value.Value.User; - if (user.Status == UserStatus.Registered) + if (retrievedGuest.Value.HasValue) { - profile = existingUser.Value.Value.Profile; - if (profile.NotExists() - || profile.Type != UserProfileType.Person - || profile.EmailAddress.HasNoValue()) - { - return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); - } + var invitee = retrievedGuest.Value.Value; - var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, user.Id, - profile.EmailAddress, profile.DisplayName, profile.Timezone, profile.Address.CountryCode, - cancellationToken); - if (!notified.IsSuccessful) + var acceptedById = context.ToCallerId(); + var accepted = invitee.AcceptGuestInvitation(acceptedById, username.Value); + if (!accepted.IsSuccessful) { - return notified.Error; + return accepted.Error; } - _recorder.TraceInformation(context.ToCall(), - "Attempted re-registration of user: {Id}, with email {EmailAddress}", user.Id, emailAddress); - _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonReRegistered, - new Dictionary - { - { UsageConstants.Properties.Id, user.Id }, - { UsageConstants.Properties.EmailAddress, emailAddress } - }); - - return user.ToRegisteredUser(user.Memberships.DefaultMembership.Id, profile); - } - } - else - { - var created = EndUserRoot.Create(_recorder, _idFactory, UserClassification.Person); - if (!created.IsSuccessful) - { - return created.Error; + _recorder.TraceInformation(context.ToCall(), "Guest user {Id} accepted their invitation", invitee.Id); } - - user = created.Value; - } - - var profiled = await _userProfilesService.CreatePersonProfilePrivateAsync(context, user.Id, emailAddress, - firstName, lastName, timezone, countryCode, cancellationToken); - if (!profiled.IsSuccessful) - { - return profiled.Error; - } - - profile = profiled.Value; - var permittedOperators = GetPermittedOperators(); - var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username.Value, - permittedOperators); - var registered = user.Register(platformRoles, platformFeatures, username.Value); - if (!registered.IsSuccessful) - { - return registered.Error; - } - - var organizationName = PersonName.Create(firstName, lastName); - if (!organizationName.IsSuccessful) - { - return organizationName.Error; - } - - var defaultOrganization = - await _organizationsService.CreateOrganizationPrivateAsync(context, user.Id, - organizationName.Value.FullName, OrganizationOwnership.Personal, - cancellationToken); - if (!defaultOrganization.IsSuccessful) - { - return defaultOrganization.Error; - } - - var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); - var enrolled = user.AddMembership(defaultOrganizationId, tenantRoles, - tenantFeatures); - if (!enrolled.IsSuccessful) - { - return enrolled.Error; - } - - var saved = await _repository.SaveAsync(user, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", user.Id); - _recorder.AuditAgainst(context.ToCall(), user.Id, - Audits.EndUsersApplication_User_Registered_TermsAccepted, - "EndUser {Id} accepted their terms and conditions", user.Id); - _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonRegistrationCreated); - - return user.ToRegisteredUser(defaultOrganizationId, profile); + return await RegisterPersonInternalAsync(context, username.Value, firstName, lastName, + timezone, countryCode, termsAndConditionsAccepted, cancellationToken); } 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 +205,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; @@ -329,13 +246,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 +272,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 +289,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 +366,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,6 +383,123 @@ public async Task> AssignTenantRolesAsync( return assignee.ToUserWithMemberships(); } + private async Task> RegisterPersonInternalAsync(ICallerContext context, + EmailAddress username, string firstName, string? lastName, string? timezone, string? countryCode, + bool termsAndConditionsAccepted, CancellationToken cancellationToken) + { + if (!termsAndConditionsAccepted) + { + return Error.RuleViolation(Resources.EndUsersApplication_NotAcceptedTerms); + } + + var existingUser = await FindPersonByEmailAddressInternalAsync(context, username, cancellationToken); + if (!existingUser.IsSuccessful) + { + return existingUser.Error; + } + + EndUserRoot user; + UserProfile? profile; + if (existingUser.Value.HasValue) + { + user = existingUser.Value.Value.User; + if (user.Status == UserStatus.Registered) + { + profile = existingUser.Value.Value.Profile; + if (profile.NotExists() + || profile.Type != UserProfileType.Person + || profile.EmailAddress.HasNoValue()) + { + return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); + } + + var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, user.Id, + profile.EmailAddress, profile.DisplayName, profile.Timezone, profile.Address.CountryCode, + cancellationToken); + if (!notified.IsSuccessful) + { + return notified.Error; + } + + _recorder.TraceInformation(context.ToCall(), + "Attempted re-registration of user: {Id}, with email {EmailAddress}", user.Id, username); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonReRegistered, + new Dictionary + { + { UsageConstants.Properties.Id, user.Id }, + { UsageConstants.Properties.EmailAddress, username } + }); + + return user.ToRegisteredUser(user.Memberships.DefaultMembership.Id, profile); + } + } + else + { + var created = EndUserRoot.Create(_recorder, _idFactory, UserClassification.Person); + if (!created.IsSuccessful) + { + return created.Error; + } + + user = created.Value; + } + + var profiled = await _userProfilesService.CreatePersonProfilePrivateAsync(context, user.Id, username, + firstName, lastName, timezone, countryCode, cancellationToken); + if (!profiled.IsSuccessful) + { + return profiled.Error; + } + + profile = profiled.Value; + var permittedOperators = GetPermittedOperators(); + var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username, + permittedOperators); + var registered = user.Register(platformRoles, platformFeatures, username); + if (!registered.IsSuccessful) + { + return registered.Error; + } + + var organizationName = PersonName.Create(firstName, lastName); + if (!organizationName.IsSuccessful) + { + return organizationName.Error; + } + + var defaultOrganization = + await _organizationsService.CreateOrganizationPrivateAsync(context, user.Id, + organizationName.Value.FullName, OrganizationOwnership.Personal, + cancellationToken); + if (!defaultOrganization.IsSuccessful) + { + return defaultOrganization.Error; + } + + var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); + var enrolled = user.AddMembership(defaultOrganizationId, tenantRoles, + tenantFeatures); + if (!enrolled.IsSuccessful) + { + return enrolled.Error; + } + + var saved = await _endUserRepository.SaveAsync(user, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", user.Id); + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.EndUsersApplication_User_Registered_TermsAccepted, + "EndUser {Id} accepted their terms and conditions", user.Id); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonRegistrationCreated); + + return user.ToRegisteredUser(defaultOrganizationId, profile); + } + private async Task, Error>> FindPersonByEmailAddressInternalAsync( ICallerContext caller, EmailAddress emailAddress, CancellationToken cancellationToken) { @@ -433,7 +513,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 +522,8 @@ private async Task, Error>> FindPersonByEmai return new EndUserWithProfile(user.Value, profile).ToOptional(); } - var invitedGuest = await _repository.FindInvitedGuestByEmailAddressAsync(emailAddress, cancellationToken); + var invitedGuest = + await _invitationRepository.FindInvitedGuestByEmailAddressAsync(emailAddress, cancellationToken); if (!invitedGuest.IsSuccessful) { return invitedGuest.Error; 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..bc4e9356 100644 --- a/src/EndUsersApplication/Resources.Designer.cs +++ b/src/EndUsersApplication/Resources.Designer.cs @@ -59,6 +59,15 @@ internal Resources() { } } + /// + /// 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..a93cfa0c 100644 --- a/src/EndUsersApplication/Resources.resx +++ b/src/EndUsersApplication/Resources.resx @@ -33,4 +33,7 @@ The profile for this person cannot be found + + This guest is already registered as a user + \ 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..b2f0321d --- /dev/null +++ b/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs @@ -0,0 +1,312 @@ +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()); + } + + 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..2864d571 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,22 @@ 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.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..68ef33ca 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -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;