diff --git a/backend/tools.do-work/DoWorkService.cs b/backend/tools.do-work/DoWorkService.cs index 446a19c2d..c9c3b43cc 100644 --- a/backend/tools.do-work/DoWorkService.cs +++ b/backend/tools.do-work/DoWorkService.cs @@ -5,16 +5,10 @@ namespace DoWork; using Pidp.Data; using Pidp.Infrastructure.HttpClients.Keycloak; -public class DoWorkService : IDoWorkService +public class DoWorkService(IKeycloakAdministrationClient keycloakClient, PidpDbContext context) : IDoWorkService { - private readonly IKeycloakAdministrationClient keycloakClient; - private readonly PidpDbContext context; - - public DoWorkService(IKeycloakAdministrationClient keycloakClient, PidpDbContext context) - { - this.keycloakClient = keycloakClient; - this.context = context; - } + private readonly IKeycloakAdministrationClient keycloakClient = keycloakClient; + private readonly PidpDbContext context = context; public async Task DoWorkAsync() { diff --git a/backend/tools.do-work/HostedServiceWrapper.cs b/backend/tools.do-work/HostedServiceWrapper.cs index c51a4abdb..954ad0a52 100644 --- a/backend/tools.do-work/HostedServiceWrapper.cs +++ b/backend/tools.do-work/HostedServiceWrapper.cs @@ -4,23 +4,16 @@ namespace DoWork; using Microsoft.Extensions.Logging; using System.Diagnostics; -internal sealed class HostedServiceWrapper : IHostedService +internal sealed class HostedServiceWrapper( + IDoWorkService service, + IHostApplicationLifetime appLifetime, + ILogger logger) : IHostedService { - private readonly IDoWorkService service; - private readonly IHostApplicationLifetime appLifetime; - private readonly ILogger logger; + private readonly IDoWorkService service = service; + private readonly IHostApplicationLifetime appLifetime = appLifetime; + private readonly ILogger logger = logger; private int? exitCode; - public HostedServiceWrapper( - IDoWorkService service, - IHostApplicationLifetime appLifetime, - ILogger logger) - { - this.service = service; - this.appLifetime = appLifetime; - this.logger = logger; - } - public Task StartAsync(CancellationToken cancellationToken) { this.appLifetime.ApplicationStarted.Register(async () => diff --git a/backend/webapi.tests/Features/AccessRequests/SAEforms.cs b/backend/webapi.tests/Features/AccessRequests/SAEforms.cs index 61e4235a8..271c2b0f4 100644 --- a/backend/webapi.tests/Features/AccessRequests/SAEforms.cs +++ b/backend/webapi.tests/Features/AccessRequests/SAEforms.cs @@ -5,15 +5,17 @@ namespace PidpTests.Features.AccessRequests; using Xunit; using Pidp.Features.AccessRequests; +using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.Keycloak; using Pidp.Infrastructure.HttpClients.Plr; +using Pidp.Models; using PidpTests.TestingExtensions; public class SAEformsTests : InMemoryDbTest { [Theory] [MemberData(nameof(SAEformsIdentifierTypeTestData))] - public async void CreateSAEformsEnrolment_ValidProfileWithVaryingLicence_MatchesExcludedTypes(IdentifierType identifierType, bool expected) + public async Task CreateSAEformsEnrolment_ValidProfileWithVaryingLicence_MatchesExcludedTypes(IdentifierType identifierType, bool expected) { var party = this.TestDb.HasAParty(party => { @@ -23,6 +25,10 @@ public async void CreateSAEformsEnrolment_ValidProfileWithVaryingLicence_Matches party.Email = "Email@email.com"; party.Phone = "5551234567"; party.Cpn = "Cpn"; + party.Credentials = [ + new Credential { UserId = Guid.NewGuid(), IdentityProvider = IdentityProviders.BCServicesCard}, + new Credential { UserId = Guid.NewGuid(), IdentityProvider = IdentityProviders.BCProvider}, + ]; }); var client = A.Fake() .ReturningAStandingsDigest(true, identifierType); @@ -35,7 +41,10 @@ public async void CreateSAEformsEnrolment_ValidProfileWithVaryingLicence_Matches Assert.Equal(expected, result.IsSuccess); if (expected) { - A.CallTo(() => keycloak.AssignAccessRoles(party.PrimaryUserId, MohKeycloakEnrolment.SAEforms)).MustHaveHappened(); + foreach (var credential in party.Credentials) + { + A.CallTo(() => keycloak.AssignAccessRoles(credential.UserId, MohKeycloakEnrolment.SAEforms)).MustHaveHappened(); + } } else { @@ -43,9 +52,15 @@ public async void CreateSAEformsEnrolment_ValidProfileWithVaryingLicence_Matches } } - public static IEnumerable SAEformsIdentifierTypeTestData() + public static TheoryData SAEformsIdentifierTypeTestData() { - return TestData.AllIdentifierTypes - .Select(identifierType => new object[] { identifierType, !SAEforms.ExcludedIdentifierTypes.Contains(identifierType) }); + var testData = new TheoryData(); + + foreach (var identifierType in TestData.AllIdentifierTypes) + { + testData.Add(identifierType, !SAEforms.ExcludedIdentifierTypes.Contains(identifierType)); + } + + return testData; } } diff --git a/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.Designer.cs b/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.Designer.cs new file mode 100644 index 000000000..a6b993d29 --- /dev/null +++ b/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.Designer.cs @@ -0,0 +1,1509 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pidp.Data; + +#nullable disable + +namespace Pidp.Data.Migrations +{ + [DbContext(typeof(PidpDbContext))] + [Migration("20240826214148_PasswordChangeBusinessEvent")] + partial class PasswordChangeBusinessEvent + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Pidp.Models.AccessRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessTypeCode") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("RequestedOn") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.ToTable("AccessRequest"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Postal") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProvinceCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Street") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CountryCode"); + + b.HasIndex("ProvinceCode"); + + b.ToTable("Address"); + + b.HasDiscriminator("Discriminator").HasValue("Address"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.BusinessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator("Discriminator").HasValue("BusinessEvent"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Pidp.Models.ClientLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInformation") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("LogLevel") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ClientLog"); + }); + + modelBuilder.Entity("Pidp.Models.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IdpId") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("\"UserId\" != '00000000-0000-0000-0000-000000000000'"); + + b.ToTable("Credential", t => + { + t.HasCheckConstraint("CHK_Credential_AtLeastOneIdentifier", "((\"UserId\" != '00000000-0000-0000-0000-000000000000') or (\"IdpId\" is not null))"); + }); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkErrorLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialLinkTicketId") + .HasColumnType("integer"); + + b.Property("ExistingCredentialId") + .HasColumnType("integer"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CredentialLinkTicketId"); + + b.HasIndex("ExistingCredentialId"); + + b.ToTable("CredentialLinkErrorLog"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Claimed") + .HasColumnType("boolean"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkToIdentityProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PartyId"); + + b.ToTable("CredentialLinkTicket"); + }); + + modelBuilder.Entity("Pidp.Models.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Cc") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSent") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestStatus") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("MsgId") + .HasColumnType("uuid"); + + b.Property("SendType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("Pidp.Models.Endorsement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Endorsement"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EndorsementId") + .HasColumnType("integer"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EndorsementId"); + + b.HasIndex("PartyId"); + + b.ToTable("EndorsementRelationship"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInformation") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PreApproved") + .HasColumnType("boolean"); + + b.Property("ReceivingPartyId") + .HasColumnType("integer"); + + b.Property("RecipientEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestingPartyId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReceivingPartyId"); + + b.HasIndex("RequestingPartyId"); + + b.ToTable("EndorsementRequest"); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.AccessType", b => + { + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("AccessTypeLookup"); + + b.HasData( + new + { + Code = 1, + Name = "Special Authority eForms" + }, + new + { + Code = 2, + Name = "HCIMWeb Account Transfer" + }, + new + { + Code = 3, + Name = "HCIMWeb Enrolment" + }, + new + { + Code = 4, + Name = "Driver Medical Fitness" + }, + new + { + Code = 5, + Name = "MS Teams for Clinical Use - Privacy Officer" + }, + new + { + Code = 6, + Name = "Prescription Refill eForm for Pharmacists" + }, + new + { + Code = 7, + Name = "Provider Reporting Portal" + }, + new + { + Code = 8, + Name = "MS Teams for Clinical Use - Clinic Member" + }, + new + { + Code = 9, + Name = "Access Harmonization User Access Agreement" + }, + new + { + Code = 10, + Name = "Immunization Entry eForm" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.College", b => + { + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Acronym") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("CollegeLookup"); + + b.HasData( + new + { + Code = 1, + Acronym = "CPSBC", + Name = "College of Physicians and Surgeons of BC" + }, + new + { + Code = 2, + Acronym = "CPBC", + Name = "College of Pharmacists of BC" + }, + new + { + Code = 3, + Acronym = "BCCNM", + Name = "BC College of Nurses and Midwives" + }, + new + { + Code = 4, + Acronym = "CNPBC", + Name = "College of Naturopathic Physicians of BC" + }, + new + { + Code = 5, + Acronym = "CDSBC", + Name = "College of Dental Surgeons of British Columbia" + }, + new + { + Code = 6, + Acronym = "COBC", + Name = "College of Optometrists of British Columbia" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.Country", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("CountryLookup"); + + b.HasData( + new + { + Code = "CA", + Name = "Canada" + }, + new + { + Code = "US", + Name = "United States" + }); + }); + + modelBuilder.Entity("Pidp.Models.Lookups.Province", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.ToTable("ProvinceLookup"); + + b.HasData( + new + { + Code = "AB", + CountryCode = "CA", + Name = "Alberta" + }, + new + { + Code = "BC", + CountryCode = "CA", + Name = "British Columbia" + }, + new + { + Code = "MB", + CountryCode = "CA", + Name = "Manitoba" + }, + new + { + Code = "NB", + CountryCode = "CA", + Name = "New Brunswick" + }, + new + { + Code = "NL", + CountryCode = "CA", + Name = "Newfoundland and Labrador" + }, + new + { + Code = "NS", + CountryCode = "CA", + Name = "Nova Scotia" + }, + new + { + Code = "ON", + CountryCode = "CA", + Name = "Ontario" + }, + new + { + Code = "PE", + CountryCode = "CA", + Name = "Prince Edward Island" + }, + new + { + Code = "QC", + CountryCode = "CA", + Name = "Quebec" + }, + new + { + Code = "SK", + CountryCode = "CA", + Name = "Saskatchewan" + }, + new + { + Code = "NT", + CountryCode = "CA", + Name = "Northwest Territories" + }, + new + { + Code = "NU", + CountryCode = "CA", + Name = "Nunavut" + }, + new + { + Code = "YT", + CountryCode = "CA", + Name = "Yukon" + }, + new + { + Code = "AL", + CountryCode = "US", + Name = "Alabama" + }, + new + { + Code = "AK", + CountryCode = "US", + Name = "Alaska" + }, + new + { + Code = "AS", + CountryCode = "US", + Name = "American Samoa" + }, + new + { + Code = "AZ", + CountryCode = "US", + Name = "Arizona" + }, + new + { + Code = "AR", + CountryCode = "US", + Name = "Arkansas" + }, + new + { + Code = "CA", + CountryCode = "US", + Name = "California" + }, + new + { + Code = "CO", + CountryCode = "US", + Name = "Colorado" + }, + new + { + Code = "CT", + CountryCode = "US", + Name = "Connecticut" + }, + new + { + Code = "DE", + CountryCode = "US", + Name = "Delaware" + }, + new + { + Code = "DC", + CountryCode = "US", + Name = "District of Columbia" + }, + new + { + Code = "FL", + CountryCode = "US", + Name = "Florida" + }, + new + { + Code = "GA", + CountryCode = "US", + Name = "Georgia" + }, + new + { + Code = "GU", + CountryCode = "US", + Name = "Guam" + }, + new + { + Code = "HI", + CountryCode = "US", + Name = "Hawaii" + }, + new + { + Code = "ID", + CountryCode = "US", + Name = "Idaho" + }, + new + { + Code = "IL", + CountryCode = "US", + Name = "Illinois" + }, + new + { + Code = "IN", + CountryCode = "US", + Name = "Indiana" + }, + new + { + Code = "IA", + CountryCode = "US", + Name = "Iowa" + }, + new + { + Code = "KS", + CountryCode = "US", + Name = "Kansas" + }, + new + { + Code = "KY", + CountryCode = "US", + Name = "Kentucky" + }, + new + { + Code = "LA", + CountryCode = "US", + Name = "Louisiana" + }, + new + { + Code = "ME", + CountryCode = "US", + Name = "Maine" + }, + new + { + Code = "MD", + CountryCode = "US", + Name = "Maryland" + }, + new + { + Code = "MA", + CountryCode = "US", + Name = "Massachusetts" + }, + new + { + Code = "MI", + CountryCode = "US", + Name = "Michigan" + }, + new + { + Code = "MN", + CountryCode = "US", + Name = "Minnesota" + }, + new + { + Code = "MS", + CountryCode = "US", + Name = "Mississippi" + }, + new + { + Code = "MO", + CountryCode = "US", + Name = "Missouri" + }, + new + { + Code = "MT", + CountryCode = "US", + Name = "Montana" + }, + new + { + Code = "NE", + CountryCode = "US", + Name = "Nebraska" + }, + new + { + Code = "NV", + CountryCode = "US", + Name = "Nevada" + }, + new + { + Code = "NH", + CountryCode = "US", + Name = "New Hampshire" + }, + new + { + Code = "NJ", + CountryCode = "US", + Name = "New Jersey" + }, + new + { + Code = "NM", + CountryCode = "US", + Name = "New Mexico" + }, + new + { + Code = "NY", + CountryCode = "US", + Name = "New York" + }, + new + { + Code = "NC", + CountryCode = "US", + Name = "North Carolina" + }, + new + { + Code = "ND", + CountryCode = "US", + Name = "North Dakota" + }, + new + { + Code = "MP", + CountryCode = "US", + Name = "Northern Mariana Islands" + }, + new + { + Code = "OH", + CountryCode = "US", + Name = "Ohio" + }, + new + { + Code = "OK", + CountryCode = "US", + Name = "Oklahoma" + }, + new + { + Code = "OR", + CountryCode = "US", + Name = "Oregon" + }, + new + { + Code = "PA", + CountryCode = "US", + Name = "Pennsylvania" + }, + new + { + Code = "PR", + CountryCode = "US", + Name = "Puerto Rico" + }, + new + { + Code = "RI", + CountryCode = "US", + Name = "Rhode Island" + }, + new + { + Code = "SC", + CountryCode = "US", + Name = "South Carolina" + }, + new + { + Code = "SD", + CountryCode = "US", + Name = "South Dakota" + }, + new + { + Code = "TN", + CountryCode = "US", + Name = "Tennessee" + }, + new + { + Code = "TX", + CountryCode = "US", + Name = "Texas" + }, + new + { + Code = "UM", + CountryCode = "US", + Name = "United States Minor Outlying Islands" + }, + new + { + Code = "UT", + CountryCode = "US", + Name = "Utah" + }, + new + { + Code = "VT", + CountryCode = "US", + Name = "Vermont" + }, + new + { + Code = "VI", + CountryCode = "US", + Name = "Virgin Islands, U.S." + }, + new + { + Code = "VA", + CountryCode = "US", + Name = "Virginia" + }, + new + { + Code = "WA", + CountryCode = "US", + Name = "Washington" + }, + new + { + Code = "WV", + CountryCode = "US", + Name = "West Virginia" + }, + new + { + Code = "WI", + CountryCode = "US", + Name = "Wisconsin" + }, + new + { + Code = "WY", + CountryCode = "US", + Name = "Wyoming" + }); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyOfficerId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PrivacyOfficerId"); + + b.ToTable("MSTeamsClinic"); + }); + + modelBuilder.Entity("Pidp.Models.Party", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Birthdate") + .HasColumnType("date"); + + b.Property("Cpn") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("OpId") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PreferredFirstName") + .HasColumnType("text"); + + b.Property("PreferredLastName") + .HasColumnType("text"); + + b.Property("PreferredMiddleName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OpId") + .IsUnique(); + + b.ToTable("Party"); + }); + + modelBuilder.Entity("Pidp.Models.PartyLicenceDeclaration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CollegeCode") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenceNumber") + .HasColumnType("text"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PartyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CollegeCode"); + + b.HasIndex("PartyId") + .IsUnique(); + + b.ToTable("PartyLicenceDeclaration"); + }); + + modelBuilder.Entity("Pidp.Models.PrpAuthorizedLicence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Claimed") + .HasColumnType("boolean"); + + b.Property("LicenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LicenceNumber") + .IsUnique(); + + b.ToTable("PrpAuthorizedLicence"); + }); + + modelBuilder.Entity("Pidp.Models.HcimAccountTransfer", b => + { + b.HasBaseType("Pidp.Models.AccessRequest"); + + b.Property("LdapUsername") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("HcimAccountTransfer"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicMemberEnrolment", b => + { + b.HasBaseType("Pidp.Models.AccessRequest"); + + b.Property("ClinicId") + .HasColumnType("integer"); + + b.HasIndex("ClinicId"); + + b.ToTable("MSTeamsClinicMemberEnrolment"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicAddress", b => + { + b.HasBaseType("Pidp.Models.Address"); + + b.Property("ClinicId") + .HasColumnType("integer"); + + b.HasIndex("ClinicId") + .IsUnique(); + + b.ToTable("Address"); + + b.HasDiscriminator().HasValue("MSTeamsClinicAddress"); + }); + + modelBuilder.Entity("Pidp.Models.BCProviderPasswordReset", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("BCProviderPasswordReset"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("LicenceStatusRoleAssigned"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleUnassigned", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("LicenceStatusRoleUnassigned"); + }); + + modelBuilder.Entity("Pidp.Models.PartyNotInPlr", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("PartyNotInPlr"); + }); + + modelBuilder.Entity("Pidp.Models.AccessRequest", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany("AccessRequests") + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.Address", b => + { + b.HasOne("Pidp.Models.Lookups.Country", "Country") + .WithMany() + .HasForeignKey("CountryCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Lookups.Province", "Province") + .WithMany() + .HasForeignKey("ProvinceCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Pidp.Models.Credential", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany("Credentials") + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkErrorLog", b => + { + b.HasOne("Pidp.Models.CredentialLinkTicket", "CredentialLinkTicket") + .WithMany() + .HasForeignKey("CredentialLinkTicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Credential", "ExistingCredential") + .WithMany() + .HasForeignKey("ExistingCredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CredentialLinkTicket"); + + b.Navigation("ExistingCredential"); + }); + + modelBuilder.Entity("Pidp.Models.CredentialLinkTicket", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRelationship", b => + { + b.HasOne("Pidp.Models.Endorsement", "Endorsement") + .WithMany("EndorsementRelationships") + .HasForeignKey("EndorsementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endorsement"); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.EndorsementRequest", b => + { + b.HasOne("Pidp.Models.Party", "ReceivingParty") + .WithMany() + .HasForeignKey("ReceivingPartyId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Pidp.Models.Party", "RequestingParty") + .WithMany() + .HasForeignKey("RequestingPartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReceivingParty"); + + b.Navigation("RequestingParty"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.HasOne("Pidp.Models.Party", "PrivacyOfficer") + .WithMany() + .HasForeignKey("PrivacyOfficerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PrivacyOfficer"); + }); + + modelBuilder.Entity("Pidp.Models.PartyLicenceDeclaration", b => + { + b.HasOne("Pidp.Models.Lookups.College", "College") + .WithMany() + .HasForeignKey("CollegeCode"); + + b.HasOne("Pidp.Models.Party", "Party") + .WithOne("LicenceDeclaration") + .HasForeignKey("Pidp.Models.PartyLicenceDeclaration", "PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("College"); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.HcimAccountTransfer", b => + { + b.HasOne("Pidp.Models.AccessRequest", null) + .WithOne() + .HasForeignKey("Pidp.Models.HcimAccountTransfer", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicMemberEnrolment", b => + { + b.HasOne("Pidp.Models.MSTeamsClinic", "Clinic") + .WithMany() + .HasForeignKey("ClinicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pidp.Models.AccessRequest", null) + .WithOne() + .HasForeignKey("Pidp.Models.MSTeamsClinicMemberEnrolment", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clinic"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinicAddress", b => + { + b.HasOne("Pidp.Models.MSTeamsClinic", "Clinic") + .WithOne("Address") + .HasForeignKey("Pidp.Models.MSTeamsClinicAddress", "ClinicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clinic"); + }); + + modelBuilder.Entity("Pidp.Models.BCProviderPasswordReset", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleUnassigned", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.PartyNotInPlr", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + + modelBuilder.Entity("Pidp.Models.Endorsement", b => + { + b.Navigation("EndorsementRelationships"); + }); + + modelBuilder.Entity("Pidp.Models.MSTeamsClinic", b => + { + b.Navigation("Address") + .IsRequired(); + }); + + modelBuilder.Entity("Pidp.Models.Party", b => + { + b.Navigation("AccessRequests"); + + b.Navigation("Credentials"); + + b.Navigation("LicenceDeclaration"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.cs b/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.cs new file mode 100644 index 000000000..1991df50b --- /dev/null +++ b/backend/webapi/Data/Migrations/20240826214148_PasswordChangeBusinessEvent.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pidp.Data.Migrations +{ + /// + public partial class PasswordChangeBusinessEvent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs b/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs index b6002cb62..6cc72ed6b 100644 --- a/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs +++ b/backend/webapi/Data/Migrations/PidpDbContextModelSnapshot.cs @@ -1200,6 +1200,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasDiscriminator().HasValue("MSTeamsClinicAddress"); }); + modelBuilder.Entity("Pidp.Models.BCProviderPasswordReset", b => + { + b.HasBaseType("Pidp.Models.BusinessEvent"); + + b.Property("PartyId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("integer") + .HasColumnName("PartyId"); + + b.HasIndex("PartyId"); + + b.ToTable("BusinessEvent"); + + b.HasDiscriminator().HasValue("BCProviderPasswordReset"); + }); + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => { b.HasBaseType("Pidp.Models.BusinessEvent"); @@ -1421,6 +1437,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Clinic"); }); + modelBuilder.Entity("Pidp.Models.BCProviderPasswordReset", b => + { + b.HasOne("Pidp.Models.Party", "Party") + .WithMany() + .HasForeignKey("PartyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Party"); + }); + modelBuilder.Entity("Pidp.Models.LicenceStatusRoleAssigned", b => { b.HasOne("Pidp.Models.Party", "Party") diff --git a/backend/webapi/Features/AccessRequests/SAEforms.cs b/backend/webapi/Features/AccessRequests/SAEforms.cs index df694c7fb..bdbfabe7f 100644 --- a/backend/webapi/Features/AccessRequests/SAEforms.cs +++ b/backend/webapi/Features/AccessRequests/SAEforms.cs @@ -7,6 +7,7 @@ namespace Pidp.Features.AccessRequests; using Pidp.Data; using Pidp.Extensions; +using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.Keycloak; using Pidp.Infrastructure.HttpClients.Mail; using Pidp.Infrastructure.HttpClients.Plr; @@ -50,7 +51,9 @@ public async Task HandleAsync(Command command) .Select(party => new { AlreadyEnroled = party.AccessRequests.Any(request => request.AccessTypeCode == AccessTypeCode.SAEforms), - UserId = party.PrimaryUserId, + UserIds = party.Credentials + .Where(credential => credential.IdentityProvider == IdentityProviders.BCServicesCard || credential.IdentityProvider == IdentityProviders.BCProvider) + .Select(credential => credential.UserId), party.Email, party.DisplayFirstName, party.Cpn, @@ -89,9 +92,12 @@ public async Task HandleAsync(Command command) } } - if (!await this.keycloakClient.AssignAccessRoles(dto.UserId, MohKeycloakEnrolment.SAEforms)) + foreach (var userId in dto.UserIds) { - return DomainResult.Failed(); + if (!await this.keycloakClient.AssignAccessRoles(userId, MohKeycloakEnrolment.SAEforms)) + { + return DomainResult.Failed(); + } } this.context.AccessRequests.Add(new AccessRequest diff --git a/backend/webapi/Features/Credentials/BCProvider.Create.cs b/backend/webapi/Features/Credentials/BCProvider.Create.cs index e2b7e9323..07fb73053 100644 --- a/backend/webapi/Features/Credentials/BCProvider.Create.cs +++ b/backend/webapi/Features/Credentials/BCProvider.Create.cs @@ -63,12 +63,14 @@ public async Task> HandleAsync(Command command) party.LastName, party.Cpn, party.Email, + party.Phone, HasBCProviderCredential = party.Credentials.Any(credential => credential.IdentityProvider == IdentityProviders.BCProvider), Hpdid = party.Credentials.SingleOrDefault(credential => credential.IdentityProvider == IdentityProviders.BCServicesCard)!.IdpId, UaaAgreementDate = party.AccessRequests .Where(request => request.AccessTypeCode == AccessTypeCode.UserAccessAgreement) .Select(request => request.RequestedOn) - .SingleOrDefault() + .SingleOrDefault(), + SAEformsEnroled = party.AccessRequests.Any(request => request.AccessTypeCode == AccessTypeCode.SAEforms), }) .SingleAsync(); @@ -119,7 +121,7 @@ public async Task> HandleAsync(Command command) } // TODO: Domain Event! Probably should create this credential now and then Queue the keycloak User creation + updating the UserId - var userId = await this.CreateKeycloakUser(party.FirstName, party.LastName, createdUser.UserPrincipalName); + var userId = await this.CreateKeycloakUser(party.FirstName, party.LastName, createdUser.UserPrincipalName, party.Email, party.Phone!); if (userId == null) { this.logger.LogKeycloakUserCreationError(command.PartyId, createdUser.UserPrincipalName); @@ -127,6 +129,11 @@ public async Task> HandleAsync(Command command) } await this.keycloakClient.UpdateUser(userId.Value, user => user.SetOpId(party.OpId!)); + if (party.SAEformsEnroled) + { + await this.keycloakClient.AssignAccessRoles(userId.Value, MohKeycloakEnrolment.SAEforms); + } + this.context.Credentials.Add(new Credential { UserId = userId.Value, @@ -142,7 +149,7 @@ public async Task> HandleAsync(Command command) return DomainResult.Success(createdUser.UserPrincipalName); } - private async Task CreateKeycloakUser(string firstName, string lastName, string userPrincipalName) + private async Task CreateKeycloakUser(string firstName, string lastName, string userPrincipalName, string email, string phone) { var newUser = new UserRepresentation { @@ -151,6 +158,9 @@ public async Task> HandleAsync(Command command) LastName = lastName, Username = GenerateMohKeycloakUsername(userPrincipalName) }; + newUser.SetPidpEmail(email) + .SetPidpPhone(phone); + return await this.keycloakClient.CreateUser(newUser); } diff --git a/backend/webapi/Features/Credentials/BCProvider.Password.cs b/backend/webapi/Features/Credentials/BCProvider.Password.cs index 989f79164..544d7f977 100644 --- a/backend/webapi/Features/Credentials/BCProvider.Password.cs +++ b/backend/webapi/Features/Credentials/BCProvider.Password.cs @@ -4,11 +4,13 @@ namespace Pidp.Features.Credentials; using FluentValidation; using HybridModelBinding; using Microsoft.EntityFrameworkCore; +using NodaTime; using System.Text.Json.Serialization; using Pidp.Data; using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.BCProvider; +using Pidp.Models; public class BCProviderPassword { @@ -29,32 +31,32 @@ public CommandValidator() } } - public class CommandHandler : ICommandHandler + public class CommandHandler( + IBCProviderClient client, + PidpDbContext context, + IClock clock) : ICommandHandler { - private readonly IBCProviderClient client; - private readonly PidpDbContext context; - - public CommandHandler(IBCProviderClient client, PidpDbContext context) - { - this.client = client; - this.context = context; - } + private readonly IBCProviderClient client = client; + private readonly PidpDbContext context = context; + private readonly IClock clock = clock; public async Task HandleAsync(Command command) { - var bcProviderId = await this.context.Credentials + var userPrincipalName = await this.context.Credentials .Where(credential => credential.PartyId == command.PartyId && credential.IdentityProvider == IdentityProviders.BCProvider) .Select(credential => credential.IdpId) .SingleOrDefaultAsync(); - if (bcProviderId == null) + if (userPrincipalName == null) { return DomainResult.NotFound(); } - if (await this.client.UpdatePassword(bcProviderId, command.NewPassword)) + if (await this.client.UpdatePassword(userPrincipalName, command.NewPassword)) { + this.context.BusinessEvents.Add(BCProviderPasswordReset.Create(command.PartyId, userPrincipalName, this.clock.GetCurrentInstant())); + await this.context.SaveChangesAsync(); return DomainResult.Success(); } else diff --git a/backend/webapi/Features/Credentials/Create.cs b/backend/webapi/Features/Credentials/Create.cs index d1db5ef44..5be4856fb 100644 --- a/backend/webapi/Features/Credentials/Create.cs +++ b/backend/webapi/Features/Credentials/Create.cs @@ -2,6 +2,7 @@ namespace Pidp.Features.Credentials; using DomainResults.Common; using FluentValidation; +using MassTransit; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Graph.Models; @@ -11,13 +12,13 @@ namespace Pidp.Features.Credentials; using Pidp.Data; using Pidp.Extensions; -using Pidp.Features.Discovery; using Pidp.Infrastructure.Auth; using Pidp.Infrastructure.HttpClients.BCProvider; using Pidp.Infrastructure.HttpClients.Plr; using Pidp.Models; using Pidp.Models.DomainEvents; using Pidp.Models.Lookups; +using static Pidp.Features.CommonHandlers.UpdateKeycloakAttributesConsumer; using Pidp.Infrastructure.HttpClients.Keycloak; public class Create @@ -30,21 +31,14 @@ public class Command : ICommand> public ClaimsPrincipal User { get; set; } = new(); } - public class CommandHandler : ICommandHandler> + public class CommandHandler( + IClock clock, + ILogger logger, + PidpDbContext context) : ICommandHandler> { - private readonly IClock clock; - private readonly ILogger logger; - private readonly PidpDbContext context; - - public CommandHandler( - IClock clock, - ILogger logger, - PidpDbContext context) - { - this.clock = clock; - this.logger = logger; - this.context = context; - } + private readonly IClock clock = clock; + private readonly ILogger logger = logger; + private readonly PidpDbContext context = context; public async Task> HandleAsync(Command command) { @@ -112,24 +106,16 @@ public async Task> HandleAsync(Command command) } } - public class BCProviderUpdateAttributesHandler : INotificationHandler + public class BCProviderUpdateAttributesHandler( + IBCProviderClient bcProviderClient, + IPlrClient plrClient, + PidpConfiguration config, + PidpDbContext context) : INotificationHandler { - private readonly IBCProviderClient bcProviderClient; - private readonly IPlrClient plrClient; - private readonly PidpDbContext context; - private readonly string bcProviderClientId; - - public BCProviderUpdateAttributesHandler( - IBCProviderClient bcProviderClient, - IPlrClient plrClient, - PidpConfiguration config, - PidpDbContext context) - { - this.bcProviderClient = bcProviderClient; - this.plrClient = plrClient; - this.context = context; - this.bcProviderClientId = config.BCProviderClient.ClientId; - } + private readonly IBCProviderClient bcProviderClient = bcProviderClient; + private readonly IPlrClient plrClient = plrClient; + private readonly PidpDbContext context = context; + private readonly string bcProviderClientId = config.BCProviderClient.ClientId; public async Task Handle(CredentialLinked notification, CancellationToken cancellationToken) { @@ -197,18 +183,10 @@ public async Task Handle(CredentialLinked notification, CancellationToken cancel } } - public class UpdateBCServicesCardAttributesHandler : INotificationHandler + public class UpdateBCServicesCardAttributesHandler(IBus bus, PidpDbContext context) : INotificationHandler { - private readonly IKeycloakAdministrationClient keycloakClient; - private readonly PidpDbContext context; - - public UpdateBCServicesCardAttributesHandler( - IKeycloakAdministrationClient keycloakClient, - PidpDbContext context) - { - this.keycloakClient = keycloakClient; - this.context = context; - } + private readonly IBus bus = bus; + private readonly PidpDbContext context = context; public async Task Handle(CredentialLinked notification, CancellationToken cancellationToken) { @@ -226,12 +204,57 @@ public async Task Handle(CredentialLinked notification, CancellationToken cancel foreach (var credential in party.Credentials) { - await this.keycloakClient.UpdateUser(credential.UserId, user => user.SetOpId(party.OpId!)); + await this.bus.Publish(UpdateKeycloakAttributes.FromUpdateAction(credential.UserId, user => user.SetOpId(party.OpId!)), cancellationToken); } } else { - await this.keycloakClient.UpdateUser(newCredential.UserId, user => user.SetOpId(party.OpId!)); + await this.bus.Publish(UpdateKeycloakAttributes.FromUpdateAction(newCredential.UserId, user => user.SetOpId(party.OpId!)), cancellationToken); + } + } + } + + public class UpdateKeycloakAttributesHandler(IBus bus, PidpDbContext context) : INotificationHandler + { + private readonly IBus bus = bus; + private readonly PidpDbContext context = context; + + public async Task Handle(CredentialLinked notification, CancellationToken cancellationToken) + { + var attributes = await this.context.Parties + .Where(party => party.Id == notification.Credential.PartyId) + .Select(party => new + { + party.Email, + party.Phone, + }) + .SingleAsync(cancellationToken); + + await this.bus.Publish(UpdateKeycloakAttributes.FromUpdateAction(notification.Credential.UserId, user => user.SetPidpEmail(attributes.Email!).SetPidpPhone(attributes.Phone!)), cancellationToken); + } + } + + public class UpdateKeycloakRolesHandler(IKeycloakAdministrationClient keycloakClient, PidpDbContext context) : INotificationHandler + { + private readonly IKeycloakAdministrationClient keycloakClient = keycloakClient; + private readonly PidpDbContext context = context; + + public async Task Handle(CredentialLinked notification, CancellationToken cancellationToken) + { + var newCredential = notification.Credential; + if (newCredential.IdentityProvider is not (IdentityProviders.BCServicesCard or IdentityProviders.BCProvider)) + { + return; + } + + var hasSAEformsEnrolment = await this.context.AccessRequests + .AnyAsync(request => request.PartyId == newCredential.PartyId + && request.AccessTypeCode == AccessTypeCode.SAEforms, cancellationToken); + + if (hasSAEformsEnrolment) + { + // TODO: bus message for roles + await this.keycloakClient.AssignAccessRoles(newCredential.UserId, MohKeycloakEnrolment.SAEforms); } } } diff --git a/backend/webapi/Features/Parties/Create.cs b/backend/webapi/Features/Parties/Create.cs index 00c7fac12..9f7202ba5 100644 --- a/backend/webapi/Features/Parties/Create.cs +++ b/backend/webapi/Features/Parties/Create.cs @@ -1,13 +1,16 @@ namespace Pidp.Features.Parties; using FluentValidation; +using MassTransit; +using MediatR; using NodaTime; using Pidp.Data; using Pidp.Extensions; using Pidp.Infrastructure.Auth; -using Pidp.Infrastructure.HttpClients.Keycloak; using Pidp.Models; +using Pidp.Models.DomainEvents; +using static Pidp.Features.CommonHandlers.UpdateKeycloakAttributesConsumer; public class Create { @@ -39,16 +42,9 @@ public CommandValidator(IHttpContextAccessor accessor) } } - public class CommandHandler : ICommandHandler + public class CommandHandler(PidpDbContext context) : ICommandHandler { - private readonly PidpDbContext context; - private readonly IKeycloakAdministrationClient keycloakClient; - - public CommandHandler(PidpDbContext context, IKeycloakAdministrationClient keycloakClient) - { - this.context = context; - this.keycloakClient = keycloakClient; - } + private readonly PidpDbContext context = context; public async Task HandleAsync(Command command) { @@ -68,7 +64,7 @@ public async Task HandleAsync(Command command) if (command.IdentityProvider == IdentityProviders.BCServicesCard) { await party.GenerateOpId(this.context); - await this.keycloakClient.UpdateUser(command.UserId, user => user.SetOpId(party.OpId!)); + party.DomainEvents.Add(new OpIdCreated(command.UserId, party.OpId!)); } this.context.Parties.Add(party); @@ -78,4 +74,11 @@ public async Task HandleAsync(Command command) return party.Id; } } + + public class UpdateKeycloakWhenOpIdCreatedHandler(IBus bus) : INotificationHandler + { + private readonly IBus bus = bus; + + public async Task Handle(OpIdCreated notification, CancellationToken cancellationToken) => await this.bus.Publish(UpdateKeycloakAttributes.FromUpdateAction(notification.UserId, user => user.SetOpId(notification.OpId)), cancellationToken); + } } diff --git a/backend/webapi/Features/Parties/Demographics.cs b/backend/webapi/Features/Parties/Demographics.cs index 076bfdfd3..15adf9717 100644 --- a/backend/webapi/Features/Parties/Demographics.cs +++ b/backend/webapi/Features/Parties/Demographics.cs @@ -89,8 +89,13 @@ public async Task HandleAsync(Command command) if (party.Email != command.Email) { + // TODO: update all credentials. party.DomainEvents.Add(new PartyEmailUpdated(party.Id, party.PrimaryUserId, command.Email!)); } + if (party.Phone != command.Phone) + { + party.DomainEvents.Add(new PartyPhoneUpdated(party.Id, party.Credentials.Select(credential => credential.UserId), command.Phone!)); + } party.PreferredFirstName = command.PreferredFirstName?.Trim(); party.PreferredMiddleName = command.PreferredMiddleName?.Trim(); @@ -147,6 +152,19 @@ public async Task Consume(ConsumeContext context) } } } + + public class PartyPhoneUpdatedHandler(IBus bus) : INotificationHandler + { + private readonly IBus bus = bus; + + public async Task Handle(PartyPhoneUpdated notification, CancellationToken cancellationToken) + { + foreach (var userId in notification.UserIds) + { + await this.bus.Publish(UpdateKeycloakAttributes.FromUpdateAction(userId, user => user.SetPidpPhone(notification.NewPhone)), cancellationToken); + } + } + } } public static partial class PartyEmailUpdatedBcProviderConsumerLoggingExtensions diff --git a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs index 390dcd2e8..ada12569d 100644 --- a/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs +++ b/backend/webapi/Infrastructure/HttpClients/Keycloak/KeycloakApiDefinitions.cs @@ -73,7 +73,7 @@ public class Role /// public class UserRepresentation { - public Dictionary Attributes { get; set; } = new(); + public Dictionary Attributes { get; set; } = []; public string? Email { get; set; } public bool? Enabled { get; set; } public string? FirstName { get; set; } @@ -81,7 +81,7 @@ public class UserRepresentation public string? LastName { get; set; } public string? Username { get; set; } - public void SetCollegeLicenceInformation(IEnumerable plrRecords) + public UserRepresentation SetCollegeLicenceInformation(IEnumerable plrRecords) { var data = plrRecords.Select(record => new { @@ -92,16 +92,18 @@ public void SetCollegeLicenceInformation(IEnumerable plrRecords) record.StatusReasonCode }); - this.SetAttribute("college_licence_info", JsonSerializer.Serialize(data, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + return this.SetAttribute("college_licence_info", JsonSerializer.Serialize(data, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); } - public void SetCpn(string cpn) => this.SetAttribute("common_provider_number", cpn); + public UserRepresentation SetCpn(string cpn) => this.SetAttribute("common_provider_number", cpn); - internal void SetLdapOrgDetails(LdapLoginResponse.OrgDetails orgDetails) => this.SetAttribute("org_details", JsonSerializer.Serialize(orgDetails, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + internal UserRepresentation SetLdapOrgDetails(LdapLoginResponse.OrgDetails orgDetails) => this.SetAttribute("org_details", JsonSerializer.Serialize(orgDetails, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); - public void SetOpId(string opId) => this.SetAttribute("opId", opId); + public UserRepresentation SetOpId(string opId) => this.SetAttribute("opId", opId); - public void SetPidpEmail(string pidpEmail) => this.SetAttribute("pidp_email", pidpEmail); + public UserRepresentation SetPidpEmail(string pidpEmail) => this.SetAttribute("pidp_email", pidpEmail); + + public UserRepresentation SetPidpPhone(string pidpPhone) => this.SetAttribute("pidp_phone", pidpPhone); /// /// Adds the given attributes to this User Representation. Overwrites any duplicate keys. @@ -114,5 +116,9 @@ public void SetAttributes(Dictionary newAttributes) } } - private void SetAttribute(string key, string value) => this.Attributes[key] = new[] { value }; + private UserRepresentation SetAttribute(string key, string value) + { + this.Attributes[key] = [value]; + return this; + } } diff --git a/backend/webapi/Models/BusinessEvent.cs b/backend/webapi/Models/BusinessEvent.cs index 454f51319..a275701ef 100644 --- a/backend/webapi/Models/BusinessEvent.cs +++ b/backend/webapi/Models/BusinessEvent.cs @@ -69,3 +69,17 @@ public static LicenceStatusRoleUnassigned Create(int partyId, MohKeycloakEnrolme } } +public class BCProviderPasswordReset : PartyBusinessEvent +{ + public static BCProviderPasswordReset Create(int partyId, string userPrincipalName, Instant recordedOn) + { + return new BCProviderPasswordReset + { + PartyId = partyId, + Description = $"Party with User Principal Name {userPrincipalName} reset their BCProvider password.", + Severity = LogLevel.Information, + RecordedOn = recordedOn + }; + } +} + diff --git a/backend/webapi/Models/DomainEvents/OpIdCreated.cs b/backend/webapi/Models/DomainEvents/OpIdCreated.cs new file mode 100644 index 000000000..6c936cd04 --- /dev/null +++ b/backend/webapi/Models/DomainEvents/OpIdCreated.cs @@ -0,0 +1,3 @@ +namespace Pidp.Models.DomainEvents; + +public record OpIdCreated(Guid UserId, string OpId) : IDomainEvent { } diff --git a/backend/webapi/Models/DomainEvents/PartyPhoneUpdated.cs b/backend/webapi/Models/DomainEvents/PartyPhoneUpdated.cs new file mode 100644 index 000000000..de70b36ad --- /dev/null +++ b/backend/webapi/Models/DomainEvents/PartyPhoneUpdated.cs @@ -0,0 +1,3 @@ +namespace Pidp.Models.DomainEvents; + +public record PartyPhoneUpdated(int PartyId, IEnumerable UserIds, string NewPhone) : IDomainEvent { } diff --git a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.html b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.html index a12d32e83..69b021da3 100644 --- a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.html +++ b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.html @@ -1,76 +1,99 @@
- {{ title }} - - - - Provide the following information to complete your Provider Identity - Profile. - - - +

Personal Information

+
+

+ Welcome to OneHealthID {{userName}}. Before you get started using + the systems available to you, we just need some information for our + records. This will be a one time setup of some general information we + need from you, you can provide this information now, or at your leisure. + But you will be directed where you left off at your next login. +

+

Let's get started.

+

+ In the next few steps, we'll be asking for your contact, college, and + user agreement signing. Please enter you contact information for our + records. +

+
+ +
+
+
+ Contact Calendar +
+
+

BC Services Card information

+ +
- + This information comes from the BC Services Card. If you use a different name in your college licence, select "My name is different on my college licence". +

+ This will not change your BC Services card information. +

- +
+
+ + + My name is different on my college licence + {{panel.expanded ? 'remove' : 'add'}} + + +

Preferred Name

+ + If you use a name professionally that is different from your legal + name, enter it here. +

+ Entering a preferred name will not change your BC Services Card + name. +

+
- - - - If you use a name professionally that is different from your legal - name, enter it here. - - Entering a preferred name will not change your BC Services Card - name. - - - - - + +
+
+
- - - Provide your contact information. Please use an email that you check - regularly. - - +

Contact Information

+ + Provide your contact information. Please use an email that you check + regularly. +
- - - +
diff --git a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.scss b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.scss index 63fa4568b..16f15306b 100644 --- a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.scss +++ b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.scss @@ -1,5 +1,60 @@ .viewport-all { padding: 1.25rem 0.75rem; + h1 { + color: #036; + font-size: 3.125rem; + font-weight: 700; + margin-bottom: 0; + padding-bottom: 1rem; + } + + & .card { + display: flex; + flex-direction: column; + padding-right: calc(var(--gap) * 0.5); + position: relative; + line-height: 1.5rem; + } + + & .card-styles { + border-radius: 1.25rem; + border: 1px solid #ccc; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + width: 25.1875rem; + height: 11.875rem; + padding: 1.25rem; + + h2 { + color: #036; + font-size: 1.5rem; + font-weight: 700; + padding-top: 0.25rem; + margin-bottom: 0.75rem; + } + } + + .light-color { + color: #ccc; + font-size: 1rem; + } + + .expansion-item-container { + padding: 0 16px; + .expansion-item { + border-radius: 0.25rem; + border: 1px solid #c2c2c2; + padding: 0; + + .mat-expansion-panel-header { + padding: 0 12px 0 28px; + + .mat-icon { + margin-top: 0.25rem; + } + } + } + } } .viewport-small { @@ -13,3 +68,31 @@ font-size: 0.9rem; } } + +.viewport-small, +.viewport-xsmall { + h1 { + font-size: 40px; + line-height: 1.1; + } +} + +.viewport-xsmall { + & .card-styles { + width: 18rem; + height: 14rem; + } + + .expansion-item-container { + padding: 0 10px; + .expansion-item { + .mat-expansion-panel-header { + padding: 0 12px 0 10px; + + .mat-icon { + margin-top: 1rem; + } + } + } + } +} diff --git a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.ts b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.ts index 80c5aed27..d21fa8b47 100644 --- a/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.ts +++ b/workspace/apps/pidp/src/app/features/profile/pages/personal-information/personal-information.page.ts @@ -1,8 +1,10 @@ import { AsyncPipe, NgIf } from '@angular/common'; import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; import { FormBuilder, FormControl } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; @@ -37,7 +39,10 @@ import { LoggerService } from '@app/core/services/logger.service'; import { IdentityProvider } from '@app/features/auth/enums/identity-provider.enum'; import { User } from '@app/features/auth/models/user.model'; import { AuthorizedUserService } from '@app/features/auth/services/authorized-user.service'; +import { ProfileStatus } from '@app/features/portal/models/profile-status.model'; +import { PortalResource } from '@app/features/portal/portal-resource.service'; import { LookupResource } from '@app/modules/lookup/lookup-resource.service'; +import { BreadcrumbComponent } from '@app/shared/components/breadcrumb/breadcrumb.component'; import { IsHighAssurancePipe } from '@app/shared/pipes/is-high-assurance.pipe'; import { @@ -49,7 +54,6 @@ import { UserInfoComponent } from './components/user-info/user-info.component'; import { PersonalInformationFormState } from './personal-information-form-state'; import { PersonalInformationResource } from './personal-information-resource.service'; import { PersonalInformation } from './personal-information.model'; -import { BreadcrumbComponent } from '@app/shared/components/breadcrumb/breadcrumb.component'; @Component({ selector: 'app-personal-information', @@ -63,7 +67,10 @@ import { BreadcrumbComponent } from '@app/shared/components/breadcrumb/breadcrum ContactFormComponent, InjectViewportCssClassDirective, IsHighAssurancePipe, + InjectViewportCssClassDirective, MatButtonModule, + MatExpansionModule, + MatIconModule, NgIf, PageComponent, PageFooterActionDirective, @@ -89,11 +96,15 @@ export class PersonalInformationPage public warningMessage: string; public emailChanged: Subject; public userEmail: string; + public userName: string; + public userDOB: string; + public profileStatus: string; public IdentityProvider = IdentityProvider; // ui-page is handling this. public showOverlayOnSubmit = false; + public readonly panelOpenState = signal(false); public breadcrumbsData: Array<{ title: string; path: string }> = [ { title: 'Home', path: '' }, { title: 'Personal Information', path: '' }, @@ -105,6 +116,7 @@ export class PersonalInformationPage private router: Router, private partyService: PartyService, private resource: PersonalInformationResource, + private portalResource: PortalResource, private authorizedUserService: AuthorizedUserService, private logger: LoggerService, private _snackBar: MatSnackBar, @@ -122,6 +134,9 @@ export class PersonalInformationPage 'Our system is not familiar with this email address, please double check the spelling. If everything is correct hit save information, and our system will update.'; this.emailChanged = new Subject(); this.userEmail = ''; + this.userName = ''; + this.userDOB = ''; + this.profileStatus = ''; } public onPreferredNameToggle({ checked }: ToggleContentChange): void { @@ -163,6 +178,25 @@ export class PersonalInformationPage } }); + if (this.user$ !== undefined) { + this.user$.pipe().subscribe((userFound) => { + if (userFound) { + this.userName = userFound.firstName + ' ' + userFound.lastName; + } + }); + } + + this.getProfileStatus(this.partyService.partyId) + .pipe() + .subscribe((profileStatus: ProfileStatus | null) => { + if (!profileStatus) { + return ''; + } + this.profileStatus = + profileStatus.status.userAccessAgreement.statusCode.toString(); + return this.profileStatus; + }); + this.resource .get(partyId) .pipe( @@ -176,9 +210,9 @@ export class PersonalInformationPage return of(null); }), ) - .subscribe((model: PersonalInformation | null) => - this.handlePreferredNameChange(!!model?.preferredFirstName), - ); + .subscribe((model: PersonalInformation | null) => { + this.handlePreferredNameChange(!!model?.preferredFirstName); + }); } protected performSubmission(): Observable { @@ -209,4 +243,8 @@ export class PersonalInformationPage private navigateToRoot(): void { this.router.navigate([this.route.snapshot.data.routes.root]); } + + private getProfileStatus(partyId: number): Observable { + return this.portalResource.getProfileStatus(partyId); + } } diff --git a/workspace/apps/pidp/src/scss/material/_material-overrides.scss b/workspace/apps/pidp/src/scss/material/_material-overrides.scss index c6197c20a..4d801ef73 100644 --- a/workspace/apps/pidp/src/scss/material/_material-overrides.scss +++ b/workspace/apps/pidp/src/scss/material/_material-overrides.scss @@ -179,6 +179,13 @@ overflow: visible !important; } +.expansion-item, +.contact-information { + mat-label { + font-weight: 700; + color: #313132; + } +} // // SideNav // diff --git a/workspace/libs/shared/ui/src/lib/components/contact-info-form/contact-info-form.component.html b/workspace/libs/shared/ui/src/lib/components/contact-info-form/contact-info-form.component.html index 6c1029114..026cfb212 100644 --- a/workspace/libs/shared/ui/src/lib/components/contact-info-form/contact-info-form.component.html +++ b/workspace/libs/shared/ui/src/lib/components/contact-info-form/contact-info-form.component.html @@ -1,5 +1,5 @@
-
+
-
+
Phone Number