diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs index 78c701a64..4aba50406 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs @@ -108,6 +108,8 @@ public AuthenticationState( [JsonInclude] public bool MobileNumberVerified { get; private set; } [JsonInclude] + public bool ContinueWithoutMobileNumber { get; private set; } + [JsonInclude] public Guid? ExistingAccountUserId { get; private set; } [JsonInclude] public string? ExistingAccountEmail { get; private set; } @@ -162,7 +164,9 @@ public AuthenticationState( [JsonIgnore] public bool HasIttProviderSet => HasIttProvider.HasValue; [JsonIgnore] - public bool ContactDetailsVerified => EmailAddressVerified && MobileNumberVerified; + public bool ContactDetailsVerified => EmailAddressVerified && MobileNumberVerifiedOrSkipped; + [JsonIgnore] + public bool MobileNumberVerifiedOrSkipped => MobileNumberVerified || ContinueWithoutMobileNumber; [JsonIgnore] public bool HasTrnToken => !string.IsNullOrEmpty(TrnToken); [JsonIgnore] @@ -242,6 +246,7 @@ public void Reset(DateTime utcNow) HasPreviousName = default; MobileNumber = default; MobileNumberVerified = default; + ContinueWithoutMobileNumber = default; ExistingAccountUserId = default; ExistingAccountEmail = default; ExistingAccountMobileNumber = default; @@ -313,6 +318,7 @@ public void OnMobileNumberVerified(User? user = null) MobileNumberVerified = true; FirstTimeSignInForEmail = user is null; + ContinueWithoutMobileNumber = false; if (user is not null) { @@ -390,12 +396,12 @@ public void OnUserRegistered(User user) throw new InvalidOperationException($"Email has not been verified."); } - if (MobileNumber is null) + if (MobileNumber is null && !ContinueWithoutMobileNumber) { throw new InvalidOperationException($"{nameof(MobileNumber)} is not known."); } - if (!MobileNumberSet) + if (!MobileNumberVerified && !ContinueWithoutMobileNumber) { throw new InvalidOperationException($"Mobile number has not been verified."); } @@ -565,6 +571,14 @@ public void OnMobileNumberSet(string mobileNumber) { MobileNumber = mobileNumber; MobileNumberVerified = false; + ContinueWithoutMobileNumber = false; + } + + public void OnContinueWithoutMobileNumber() + { + MobileNumber = null; + MobileNumberVerified = false; + ContinueWithoutMobileNumber = true; } public void OnHaveResumedCompletedJourney() diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs index 16d825952..57e0cccce 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs @@ -23,14 +23,14 @@ protected IdentityLinkGenerator( protected abstract bool TryGetAuthenticationState([NotNullWhen(true)] out AuthenticationState? authenticationState); - protected virtual string Page(string pageName, bool authenticationJourneyRequired = true) + protected virtual string Page(string pageName, string? handler = null, bool authenticationJourneyRequired = true) { if (!TryGetAuthenticationState(out var authenticationState) && authenticationJourneyRequired) { throw new InvalidOperationException($"The current request has no {nameof(AuthenticationState)}."); } - var url = new Url(LinkGenerator.GetPathByPage(pageName)); + var url = new Url(LinkGenerator.GetPathByPage(pageName, handler)); if (authenticationState is not null) { @@ -78,6 +78,8 @@ protected virtual string Page(string pageName, bool authenticationJourneyRequire public string RegisterPhone() => Page("/SignIn/Register/Phone"); + public string RegisterPhoneContinueWithout() => Page("/SignIn/Register/Phone", handler: "ContinueWithout"); + public string RegisterPhoneConfirmation() => Page("/SignIn/Register/PhoneConfirmation"); public string RegisterResendPhoneConfirmation() => Page("/SignIn/Register/ResendPhoneConfirmation"); diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourney.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourney.cs index c6d623cc0..aa3566875 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourney.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourney.cs @@ -85,6 +85,7 @@ public override string GetLastAccessibleStepUrl(string? requestedStep) (Steps.ResendEmailConfirmation, _) => Steps.EmailConfirmation, (Steps.InstitutionEmail, { EmailAddressVerified: false }) => Steps.EmailConfirmation, (Steps.InstitutionEmail, { EmailAddressVerified: true }) => shouldCheckAnswers ? Steps.CheckAnswers : Steps.Phone, + (Steps.Phone, { ContinueWithoutMobileNumber: true }) => shouldCheckAnswers ? Steps.CheckAnswers : Steps.Name, (Steps.Phone, _) => Steps.PhoneConfirmation, (Steps.PhoneConfirmation, { UserId: not null }) => Steps.PhoneExists, (Steps.PhoneConfirmation, _) => shouldCheckAnswers ? Steps.CheckAnswers : Steps.Name, @@ -123,6 +124,7 @@ public override string GetLastAccessibleStepUrl(string? requestedStep) (Steps.ResendPhoneConfirmation, _) => Steps.PhoneConfirmation, (Steps.PhoneExists, { MobileNumberVerified: true }) => Steps.Phone, (Steps.PhoneExists, { MobileNumberVerified: false }) => Steps.PhoneConfirmation, + (Steps.Name, { ContinueWithoutMobileNumber: true }) => Steps.Phone, (Steps.Name, { MobileNumberVerified: true }) => Steps.Phone, (Steps.Name, { MobileNumberVerified: false }) => Steps.PhoneConfirmation, (Steps.PreferredName, _) => Steps.Name, @@ -175,9 +177,8 @@ AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: true, + MobileNumberVerifiedOrSkipped: true, HasValidEmail: true, - MobileNumberSet: true, - MobileNumberVerified: true, NameSet: true, PreferredNameSet: true, DateOfBirthSet: true, diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnTokenSignInJourney.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnTokenSignInJourney.cs index 52c2926d2..5727fb107 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnTokenSignInJourney.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnTokenSignInJourney.cs @@ -72,10 +72,10 @@ public override bool CanAccessStep(string step) CoreSignInJourney.Steps.PhoneConfirmation => AuthenticationState is { MobileNumberSet: true, MobileNumberVerified: false, EmailAddressVerified: true }, CoreSignInJourney.Steps.PhoneExists => AuthenticationState.UserId is not null, CoreSignInJourney.Steps.ResendPhoneConfirmation => AuthenticationState is { MobileNumberSet: true, MobileNumberVerified: false }, - CoreSignInJourney.Steps.Email => AuthenticationState.MobileNumberVerified, - CoreSignInJourney.Steps.EmailConfirmation => AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: false, MobileNumberVerified: true }, + CoreSignInJourney.Steps.Email => AuthenticationState.MobileNumberVerifiedOrSkipped, + CoreSignInJourney.Steps.EmailConfirmation => AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: false, MobileNumberVerifiedOrSkipped: true }, CoreSignInJourney.Steps.ResendEmailConfirmation => AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: false }, - CoreSignInJourney.Steps.InstitutionEmail => AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: true, MobileNumberVerified: true, IsInstitutionEmail: true }, + CoreSignInJourney.Steps.InstitutionEmail => AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: true, MobileNumberVerifiedOrSkipped: true, IsInstitutionEmail: true }, CoreSignInJourney.Steps.PreferredName => AuthenticationState.ContactDetailsVerified, CoreSignInJourney.Steps.DateOfBirth => AuthenticationState is { PreferredNameSet: true, ContactDetailsVerified: true }, CoreSignInJourney.Steps.AccountExists => AuthenticationState.ExistingAccountFound, @@ -99,6 +99,7 @@ public override bool CanAccessStep(string step) (SignInJourney.Steps.Email, _) => SignInJourney.Steps.EmailConfirmation, (SignInJourney.Steps.EmailConfirmation, { UserId: not null }) => CoreSignInJourney.Steps.EmailExists, (SignInJourney.Steps.EmailConfirmation, _) => shouldCheckAnswers ? Steps.CheckAnswers : CoreSignInJourney.Steps.Phone, + (CoreSignInJourney.Steps.Phone, { ContinueWithoutMobileNumber: true }) => shouldCheckAnswers ? Steps.CheckAnswers : CoreSignInJourney.Steps.PreferredName, (CoreSignInJourney.Steps.Phone, _) => CoreSignInJourney.Steps.PhoneConfirmation, (CoreSignInJourney.Steps.PhoneConfirmation, { UserId: not null }) => CoreSignInJourney.Steps.PhoneExists, (CoreSignInJourney.Steps.PhoneConfirmation, _) => shouldCheckAnswers ? Steps.CheckAnswers : CoreSignInJourney.Steps.PreferredName, @@ -138,6 +139,7 @@ public override bool CanAccessStep(string step) (CoreSignInJourney.Steps.ResendEmailConfirmation, _) => CoreSignInJourney.Steps.EmailConfirmation, (CoreSignInJourney.Steps.InstitutionEmail, { EmailAddressVerified: false }) => CoreSignInJourney.Steps.EmailConfirmation, (CoreSignInJourney.Steps.InstitutionEmail, { EmailAddressVerified: true }) => CoreSignInJourney.Steps.Email, + (CoreSignInJourney.Steps.PreferredName, { ContinueWithoutMobileNumber: true }) => CoreSignInJourney.Steps.Phone, (CoreSignInJourney.Steps.PreferredName, { MobileNumberVerified: true }) => CoreSignInJourney.Steps.Phone, (CoreSignInJourney.Steps.PreferredName, { MobileNumberVerified: false }) => CoreSignInJourney.Steps.PhoneConfirmation, (CoreSignInJourney.Steps.DateOfBirth, _) => CoreSignInJourney.Steps.PreferredName, @@ -160,9 +162,8 @@ AuthenticationState is { EmailAddressSet: true, EmailAddressVerified: true, + MobileNumberVerifiedOrSkipped: true, HasValidEmail: true, - MobileNumberSet: true, - MobileNumberVerified: true, NameSet: true, PreferredNameSet: true, DateOfBirthSet: true, diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs index f5a49b821..95f8ada58 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs @@ -53,7 +53,7 @@ public async Task CreateUser(AuthenticationState authenticationState) DateOfBirth = authenticationState.DateOfBirth, EmailAddress = authenticationState.EmailAddress!, MobileNumber = authenticationState.MobileNumber, - NormalizedMobileNumber = MobileNumber.Parse(authenticationState.MobileNumber!), + NormalizedMobileNumber = authenticationState.MobileNumber is not null ? MobileNumber.Parse(authenticationState.MobileNumber) : null, FirstName = authenticationState.FirstName!, MiddleName = authenticationState.MiddleName, LastName = authenticationState.LastName!, @@ -94,6 +94,7 @@ public async Task CreateUserWithTrnLookup(AuthenticationState authenticati DateOfBirth = authenticationState.DateOfBirth, EmailAddress = authenticationState.EmailAddress!, MobileNumber = authenticationState.MobileNumber, + NormalizedMobileNumber = authenticationState.MobileNumber is not null ? MobileNumber.Parse(authenticationState.MobileNumber) : null, FirstName = useDqtRecordForNames ? authenticationState.DqtFirstName! : authenticationState.FirstName!, MiddleName = useDqtRecordForNames ? authenticationState.DqtMiddleName : authenticationState.MiddleName, LastName = useDqtRecordForNames ? authenticationState.DqtLastName! : authenticationState.LastName!, @@ -140,6 +141,7 @@ public async Task CreateUserWithTrnToken(AuthenticationState authenticatio DateOfBirth = authenticationState.DateOfBirth, EmailAddress = authenticationState.EmailAddress!, MobileNumber = authenticationState.MobileNumber, + NormalizedMobileNumber = authenticationState.MobileNumber is not null ? MobileNumber.Parse(authenticationState.MobileNumber) : null, FirstName = authenticationState.FirstName!, MiddleName = authenticationState.MiddleName, LastName = authenticationState.LastName!, diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml index c1dc233b8..d5b4a6170 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml @@ -24,7 +24,9 @@ Mobile phone - @Model.MobilePhoneNumber + + @Model.MobilePhoneNumber + Change diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml.cs index 1707c603a..6bda0572f 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/CheckAnswers.cshtml.cs @@ -22,7 +22,7 @@ public CheckAnswers(SignInJourney journey) public bool? RequiresTrnLookup => _journey.AuthenticationState.UserRequirements.RequiresTrnLookup(); public string? EmailAddress => _journey.AuthenticationState.EmailAddress; - public string? MobilePhoneNumber => MobileNumber.Parse(_journey.AuthenticationState.MobileNumber!).ToDisplayString(); + public string? MobilePhoneNumber => _journey.AuthenticationState.MobileNumber is not null ? MobileNumber.Parse(_journey.AuthenticationState.MobileNumber).ToDisplayString() : null; public string? FullName => _journey.AuthenticationState.GetName(); public string? PreferredName => _journey.AuthenticationState.PreferredName; public DateOnly? DateOfBirth => _journey.AuthenticationState.DateOfBirth; diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml index e806cf158..d99c61862 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml @@ -1,4 +1,4 @@ -@page "/sign-in/register/phone" +@page "/sign-in/register/phone/{handler?}" @model TeacherIdentity.AuthServer.Pages.SignIn.Register.Phone @{ ViewBag.Title = "Your mobile number"; @@ -22,6 +22,15 @@ input-class="govuk-input--extra-letter-spacing" label-class="govuk-label--s" /> + + I do not have access to a mobile phone + + + Continue without it + + + + Continue diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml.cs index 2530291d0..769ed7855 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/Phone.cshtml.cs @@ -45,7 +45,14 @@ public async Task OnPost() return pinGenerationResult.Result!; } - HttpContext.GetAuthenticationState().OnMobileNumberSet(MobileNumber!); + _journey.AuthenticationState.OnMobileNumberSet(MobileNumber!); + + return await _journey.Advance(CurrentStep); + } + + public async Task OnPostContinueWithout() + { + _journey.AuthenticationState.OnContinueWithoutMobileNumber(); return await _journey.Advance(CurrentStep); } diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/PhoneConfirmation.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/PhoneConfirmation.cshtml index a61d83cbd..81e9da9b3 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/PhoneConfirmation.cshtml +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/PhoneConfirmation.cshtml @@ -23,9 +23,18 @@ autocomplete="one-time-code" label-class="govuk-label--s" /> -

- I have not received a text message -

+ + I have not received a code + +

+ We can send you another code + or you can continue without it. +

+ + Continue without it + +
+
Continue diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml index 850bac76c..9665d8a4f 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml @@ -62,7 +62,9 @@
Mobile phone - @Model.MobilePhoneNumber + + @Model.MobilePhoneNumber + Change diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml.cs index d7c2d1c3b..45fb63b7d 100644 --- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml.cs +++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/TrnToken/CheckAnswers.cshtml.cs @@ -20,7 +20,7 @@ public CheckAnswers(SignInJourney journey) public string BackLink => _journey.GetPreviousStepUrl(CurrentStep); public string? EmailAddress => _journey.AuthenticationState.EmailAddress; - public string? MobilePhoneNumber => MobileNumber.Parse(_journey.AuthenticationState.MobileNumber!).ToDisplayString(); + public string? MobilePhoneNumber => _journey.AuthenticationState.MobileNumber is not null ? MobileNumber.Parse(_journey.AuthenticationState.MobileNumber).ToDisplayString() : null; public string? FirstName => _journey.AuthenticationState.FirstName; public string? MiddleName => _journey.AuthenticationState.MiddleName; public string? LastName => _journey.AuthenticationState.LastName; diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Register/PhoneTests.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Register/PhoneTests.cs index c529833bf..c64de8de6 100644 --- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Register/PhoneTests.cs +++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Register/PhoneTests.cs @@ -100,7 +100,6 @@ public async Task Post_ValidMobileNumberWithBlockedClient_ReturnsTooManyRequests Assert.Equal(StatusCodes.Status429TooManyRequests, (int)response.StatusCode); } - [Fact] public async Task Post_EmptyMobileNumber_ReturnsError() { @@ -190,6 +189,25 @@ public async Task Post_NotificationServiceInvalidMobileNumber_ReturnsError() await AssertEx.HtmlResponseHasError(response, "MobileNumber", "Enter a valid mobile phone number"); } + [Fact] + public async Task PostContinueWithout_SetsContinueWithoutPhoneNumberOnAuthenticationStateAndRedirects() + { + // Arrange + var authStateHelper = await CreateAuthenticationStateHelper(_currentPageAuthenticationState(), additionalScopes: null); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/sign-in/register/phone/ContinueWithout?{authStateHelper.ToQueryParam()}") + { + Content = new FormUrlEncodedContentBuilder() + }; + + // Act + var response = await HttpClient.SendAsync(request); + + // Assert + Assert.Equal(StatusCodes.Status302Found, (int)response.StatusCode); + Assert.True(authStateHelper.AuthenticationState.ContinueWithoutMobileNumber); + } + private readonly AuthenticationStateConfigGenerator _currentPageAuthenticationState = RegisterJourneyAuthenticationStateHelper.ConfigureAuthenticationStateForPage(RegisterJourneyPage.Phone); private readonly AuthenticationStateConfigGenerator _previousPageAuthenticationState = RegisterJourneyAuthenticationStateHelper.ConfigureAuthenticationStateForPage(RegisterJourneyPage.EmailConfirmation); }