diff --git a/Directory.Packages.props b/Directory.Packages.props index f5a976ea..b27a5f57 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index 07d53f42..c910df1c 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -16,6 +16,8 @@ public interface IWinoAccountApiClient Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default); Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default); Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); + Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default); + Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default); Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task> GetAiStatusAsync(CancellationToken cancellationToken = default); diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index 2ee2c34e..4f5e049d 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Shared; @@ -18,6 +19,8 @@ public interface IWinoAccountProfileService Task LoginAsync(string email, string password, CancellationToken cancellationToken = default); Task RefreshAsync(CancellationToken cancellationToken = default); Task RefreshProfileAsync(CancellationToken cancellationToken = default); + Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default); + Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default); Task GetActiveAccountAsync(); Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task HasActiveAccountAsync(); diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs index 2baafc87..ee4f2c3d 100644 --- a/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Text.Json; namespace Wino.Core.Domain.Models.Accounts; @@ -7,6 +8,7 @@ public sealed class WinoAccountApiResult public bool IsSuccess { get; init; } public string? ErrorCode { get; init; } public string? ErrorMessage { get; init; } + public JsonElement? ErrorDetails { get; init; } public T? Result { get; init; } public static WinoAccountApiResult Success(T result) @@ -16,11 +18,12 @@ public sealed class WinoAccountApiResult Result = result }; - public static WinoAccountApiResult Failure(string? errorCode, string? errorMessage = null) + public static WinoAccountApiResult Failure(string? errorCode, string? errorMessage = null, JsonElement? errorDetails = null) => new() { IsSuccess = false, ErrorCode = errorCode, - ErrorMessage = errorMessage + ErrorMessage = errorMessage, + ErrorDetails = errorDetails }; } diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs index 47253403..8b14fdc7 100644 --- a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Text.Json; using Wino.Core.Domain.Entities.Shared; namespace Wino.Core.Domain.Models.Accounts; @@ -8,6 +9,7 @@ public sealed class WinoAccountOperationResult public bool IsSuccess { get; init; } public string? ErrorCode { get; init; } public string? ErrorMessage { get; init; } + public JsonElement? ErrorDetails { get; init; } public WinoAccount? Account { get; init; } public static WinoAccountOperationResult Success(WinoAccount account) @@ -17,11 +19,12 @@ public sealed class WinoAccountOperationResult Account = account }; - public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null) + public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null, JsonElement? errorDetails = null) => new() { IsSuccess = false, ErrorCode = errorCode, - ErrorMessage = errorMessage + ErrorMessage = errorMessage, + ErrorDetails = errorDetails }; } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 1265ffc3..4c0f325a 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1238,10 +1238,15 @@ "WinoAccount_LoginDialog_BenefitsDescription": "Use your Wino Account to continue syncing settings across devices and to access paid add-ons such as Wino AI Pack.", "WinoAccount_LoginDialog_DifferenceTitle": "This is not your email mailbox sign-in", "WinoAccount_LoginDialog_DifferenceDescription": "Signing in here does not add or replace your Outlook, Gmail, or IMAP accounts in Wino. It only signs you in to Wino-specific services.", + "WinoAccount_LoginDialog_ForgotPasswordLink": "Forgot password?", "WinoAccount_EmailLabel": "Email", "WinoAccount_EmailPlaceholder": "name@example.com", "WinoAccount_PasswordLabel": "Password", "WinoAccount_ConfirmPasswordLabel": "Confirm password", + "WinoAccount_ForgotPasswordDialog_Title": "Reset your password", + "WinoAccount_ForgotPasswordDialog_PrimaryButton": "Send reset email", + "WinoAccount_ForgotPasswordDialog_BackToSignIn": "Back to sign in", + "WinoAccount_ForgotPasswordDialog_Description": "Enter your Wino Account email address and we will send you a password reset link if the address is registered.", "WinoAccount_Validation_EmailRequired": "Email is required.", "WinoAccount_Validation_PasswordRequired": "Password is required.", "WinoAccount_Validation_PasswordMismatch": "Passwords do not match.", @@ -1251,6 +1256,10 @@ "WinoAccount_Error_AccountBanned": "This account has been banned.", "WinoAccount_Error_AccountSuspended": "This account has been suspended.", "WinoAccount_Error_EmailNotConfirmed": "Please confirm your email address before signing in.", + "WinoAccount_Error_EmailConfirmationRequired": "Please confirm your email address before signing in.", + "WinoAccount_Error_EmailConfirmationResendNotAvailable": "A new confirmation email is not available yet.", + "WinoAccount_Error_EmailConfirmationResendInvalid": "This confirmation request is no longer valid. Please try signing in again.", + "WinoAccount_Error_EmailNotRegistered": "This email address is not registered.", "WinoAccount_Error_RefreshTokenInvalid": "Your session is no longer valid. Please sign in again.", "WinoAccount_Error_EmailAlreadyRegistered": "This email address is already registered.", "WinoAccount_Error_ExternalLoginEmailRequired": "An email address is required to complete external sign-in.", @@ -1261,6 +1270,21 @@ "WinoAccount_Error_ValidationFailed": "The request is invalid. Please review the entered values.", "WinoAccount_RegisterSuccessMessage": "Wino Account registration completed for {0}.", "WinoAccount_LoginSuccessMessage": "Signed in to Wino Account as {0}.", + "WinoAccount_EmailConfirmationSentDialog_Title": "Confirm your email address", + "WinoAccount_EmailConfirmationSentDialog_Message": "We sent an email confirmation to {0}. Please confirm it and try signing in again.", + "WinoAccount_EmailConfirmationPendingDialog_Title": "Email confirmation required", + "WinoAccount_EmailConfirmationPendingDialog_Message": "We are still waiting for you to confirm {0}.", + "WinoAccount_EmailConfirmationPendingDialog_ResendButton": "Resend confirmation email", + "WinoAccount_EmailConfirmationPendingDialog_Countdown": "You can resend the confirmation email in {0}.", + "WinoAccount_EmailConfirmationPendingDialog_ReadyToResend": "You can resend the confirmation email now.", + "WinoAccount_EmailConfirmationResentDialog_Title": "Confirmation email resent", + "WinoAccount_EmailConfirmationResentDialog_Message": "We sent another confirmation email to {0}. Please confirm it and try signing in again.", + "WinoAccount_ForgotPasswordDialog_SuccessTitle": "Password reset email sent", + "WinoAccount_ForgotPasswordDialog_SuccessMessage": "We sent a password reset email to {0}. Open that message to choose a new password.", + "WinoAccount_ChangePassword_Title": "Change password", + "WinoAccount_ChangePassword_Description": "Send a password reset email to this Wino Account.", + "WinoAccount_ChangePassword_Action": "Send reset email", + "WinoAccount_ChangePassword_ConfirmationMessage": "Do you want Wino to send a password reset email to {0}?", "WinoAccount_SignOut_SuccessMessage": "Signed out from Wino Account {0}.", "WinoAccount_SignOut_NoAccountMessage": "There is no active Wino Account to sign out.", "WinoAccount_Titlebar_SignedOutTitle": "Wino Account", diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index 5904ff8d..ad0631c1 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -119,6 +120,57 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime result.ErrorMessage.Should().Be("Password does not match this account."); } + [Fact] + public async Task LoginAsync_ShouldPreserveErrorDetails() + { + var details = JsonSerializer.SerializeToElement(new EmailConfirmationRequiredDetailsDto( + "/api/v1/auth/confirm-email/resend", + "ticket", + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow.AddMinutes(8))); + + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Failure(ApiErrorCodes.EmailNotConfirmed, null, details)); + + var result = await _service.LoginAsync("first@example.com", "pw"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorDetails.Should().NotBeNull(); + JsonSerializer.Deserialize(result.ErrorDetails!.Value.GetRawText())! + .ResendConfirmationTicket.Should().Be("ticket"); + } + + [Fact] + public async Task RegisterAsync_ShouldNotPersistAccountUntilEmailIsConfirmed() + { + var authResult = CreateAuthResult("register@example.com"); + + _apiClient + .Setup(x => x.RegisterAsync("register@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); + + var result = await _service.RegisterAsync("register@example.com", "pw"); + + result.IsSuccess.Should().BeTrue(); + result.Account.Should().NotBeNull(); + + var persisted = await _databaseService.Connection.Table().ToListAsync(); + persisted.Should().BeEmpty(); + } + + [Fact] + public async Task ForgotPasswordAsync_ShouldForwardApiResponse() + { + _apiClient + .Setup(x => x.ForgotPasswordAsync("reset@example.com", default)) + .ReturnsAsync(ApiEnvelope.Success(default)); + + var result = await _service.ForgotPasswordAsync("reset@example.com"); + + result.IsSuccess.Should().BeTrue(); + } + [Fact] public async Task RefreshProfileAsync_ShouldPersistLatestProfileData() { @@ -136,7 +188,8 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime "Premium", authResult.User.HasPassword, authResult.User.HasGoogleLogin, - authResult.User.HasFacebookLogin))); + authResult.User.HasFacebookLogin, + authResult.User.HasUnlimitedAccounts))); await _service.LoginAsync("first@example.com", "pw"); @@ -184,7 +237,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( - new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false), + new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false, false), "access-token", DateTimeOffset.UtcNow.AddMinutes(30), "refresh-token", diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 258e274d..61a3508b 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -13,6 +13,7 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels.Data; +using Wino.Mail.Api.Contracts.Common; using Wino.Messaging.UI; namespace Wino.Core.ViewModels; @@ -112,6 +113,51 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, InfoBarMessageType.Success); } + [RelayCommand] + private async Task ChangePasswordAsync() + { + var account = await _profileService.GetActiveAccountAsync().ConfigureAwait(false); + if (account == null) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, + Translator.WinoAccount_SignOut_NoAccountMessage, + InfoBarMessageType.Warning); + return; + } + + var shouldContinue = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.WinoAccount_ChangePassword_ConfirmationMessage, account.Email), + Translator.WinoAccount_ChangePassword_Title, + Translator.WinoAccount_ChangePassword_Action).ConfigureAwait(false); + + if (!shouldContinue) + { + return; + } + + var response = await _profileService.ForgotPasswordAsync(account.Email).ConfigureAwait(false); + if (!response.IsSuccess) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + TranslateForgotPasswordError(response.ErrorCode), + InfoBarMessageType.Error); + return; + } + + _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, + string.Format(Translator.WinoAccount_ForgotPasswordDialog_SuccessMessage, account.Email), + InfoBarMessageType.Success); + } + + private static string TranslateForgotPasswordError(string? errorCode) + => errorCode switch + { + ApiErrorCodes.EmailNotRegistered => Translator.WinoAccount_Error_EmailNotRegistered, + ApiErrorCodes.ValidationFailed => Translator.WinoAccount_Error_ValidationFailed, + _ when string.IsNullOrWhiteSpace(errorCode) => Translator.GeneralTitle_Error, + _ => errorCode! + }; + [RelayCommand(CanExecute = nameof(CanPurchaseAddOn))] private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn) { diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml b/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml new file mode 100644 index 00000000..61abaae1 --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml @@ -0,0 +1,51 @@ + + + + 520 + 520 + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml.cs new file mode 100644 index 00000000..f839afcc --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountEmailConfirmationRequiredDialog.xaml.cs @@ -0,0 +1,124 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.WinUI.Services; + +namespace Wino.Dialogs; + +public sealed partial class WinoAccountEmailConfirmationRequiredDialog : ContentDialog +{ + private readonly IWinoAccountProfileService _profileService; + private readonly DispatcherTimer _countdownTimer; + private readonly string _email; + private readonly string _endpoint; + private readonly string _ticket; + private DateTimeOffset _resendAvailableAtUtc; + + public WinoAccountEmailConfirmationRequiredDialog(IWinoAccountProfileService profileService, string email, EmailConfirmationRequiredDetailsDto details) + { + _profileService = profileService; + _email = email; + _endpoint = details.ResendConfirmationEndpoint; + _ticket = details.ResendConfirmationTicket; + _resendAvailableAtUtc = details.ResendAvailableAtUtc; + + InitializeComponent(); + + MessageTextBlock.Text = string.Format(Translator.WinoAccount_EmailConfirmationPendingDialog_Message, email); + + _countdownTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _countdownTimer.Tick += CountdownTimer_Tick; + + Closing += DialogClosing; + + UpdateCountdown(); + _countdownTimer.Start(); + } + + public bool ResendSucceeded { get; private set; } + + private async void ResendClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + args.Cancel = true; + + if (DateTimeOffset.UtcNow < _resendAvailableAtUtc) + { + UpdateCountdown(); + return; + } + + var deferral = args.GetDeferral(); + + try + { + SetBusyState(true); + HideError(); + + var response = await _profileService.ResendEmailConfirmationAsync(_endpoint, _ticket); + if (!response.IsSuccess) + { + ShowError(WinoAccountAuthErrorTranslator.Translate(response.ErrorCode)); + return; + } + + ResendSucceeded = true; + Hide(); + } + finally + { + SetBusyState(false); + deferral.Complete(); + } + } + + private void CountdownTimer_Tick(object? sender, object e) => UpdateCountdown(); + + private void UpdateCountdown() + { + var remaining = _resendAvailableAtUtc - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + { + IsPrimaryButtonEnabled = true; + CountdownTextBlock.Text = Translator.WinoAccount_EmailConfirmationPendingDialog_ReadyToResend; + return; + } + + IsPrimaryButtonEnabled = false; + CountdownTextBlock.Text = string.Format( + Translator.WinoAccount_EmailConfirmationPendingDialog_Countdown, + $"{Math.Max(0, (int)remaining.TotalMinutes):00}:{Math.Max(0, remaining.Seconds):00}"); + } + + private void SetBusyState(bool isBusy) + { + IsPrimaryButtonEnabled = !isBusy && DateTimeOffset.UtcNow >= _resendAvailableAtUtc; + IsSecondaryButtonEnabled = !isBusy; + BusyRing.IsActive = isBusy; + BusyRing.Visibility = isBusy ? Visibility.Visible : Visibility.Collapsed; + } + + private void ShowError(string message) + { + ErrorTextBlock.Text = message; + ErrorTextBlock.Visibility = Visibility.Visible; + } + + private void HideError() + { + ErrorTextBlock.Text = string.Empty; + ErrorTextBlock.Visibility = Visibility.Collapsed; + } + + private void DialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args) + { + _countdownTimer.Stop(); + _countdownTimer.Tick -= CountdownTimer_Tick; + Closing -= DialogClosing; + } +} diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml index 1e8cfae2..23485a6a 100644 --- a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml @@ -152,7 +152,9 @@ - + - + + + + + + + + + HideError(); private void InputChanged(object sender, RoutedEventArgs e) => HideError(); @@ -131,4 +180,22 @@ public sealed partial class WinoAccountLoginDialog : ContentDialog ErrorTextBlock.Text = string.Empty; ErrorTextBlock.Visibility = Visibility.Collapsed; } + + private void UpdateMode() + { + Title = _isForgotPasswordMode + ? Translator.WinoAccount_ForgotPasswordDialog_Title + : Translator.WinoAccount_LoginDialog_Title; + + PrimaryButtonText = _isForgotPasswordMode + ? Translator.WinoAccount_ForgotPasswordDialog_PrimaryButton + : Translator.Buttons_SignIn; + + BenefitsPanel.Visibility = _isForgotPasswordMode ? Visibility.Collapsed : Visibility.Visible; + PasswordPanel.Visibility = _isForgotPasswordMode ? Visibility.Collapsed : Visibility.Visible; + ForgotPasswordInfoPanel.Visibility = _isForgotPasswordMode ? Visibility.Visible : Visibility.Collapsed; + ModeToggleButton.Content = _isForgotPasswordMode + ? Translator.WinoAccount_ForgotPasswordDialog_BackToSignIn + : Translator.WinoAccount_LoginDialog_ForgotPasswordLink; + } } diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs index 5fbd6ad9..50d43ec1 100644 --- a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs @@ -22,6 +22,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog } public WinoAccount? Result { get; private set; } + public string? ConfirmationEmailAddress { get; private set; } private async void RegisterClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) { @@ -68,7 +69,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog return; } - Result = result.Account; + ConfirmationEmailAddress = result.Account.Email; Hide(); } finally diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs index 89887935..533ba2f3 100644 --- a/Wino.Mail.WinUI/Services/DialogService.cs +++ b/Wino.Mail.WinUI/Services/DialogService.cs @@ -225,11 +225,16 @@ public class DialogService : DialogServiceBase, IMailDialogService RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() }; - var result = await HandleDialogPresentationAsync(dialog); + await HandleDialogPresentationAsync(dialog); - return result == ContentDialogResult.Primary - ? dialog.Result - : null; + if (!string.IsNullOrWhiteSpace(dialog.ConfirmationEmailAddress)) + { + await ShowMessageAsync( + string.Format(Translator.WinoAccount_EmailConfirmationSentDialog_Message, dialog.ConfirmationEmailAddress), + Translator.WinoAccount_EmailConfirmationSentDialog_Title); + } + + return null; } public async Task ShowWinoAccountLoginDialogAsync() @@ -241,8 +246,37 @@ public class DialogService : DialogServiceBase, IMailDialogService var result = await HandleDialogPresentationAsync(dialog); - return result == ContentDialogResult.Primary - ? dialog.Result - : null; + if (dialog.EmailConfirmationRequiredDetails != null && !string.IsNullOrWhiteSpace(dialog.PendingConfirmationEmailAddress)) + { + var confirmationDialog = new WinoAccountEmailConfirmationRequiredDialog( + _winoAccountProfileService, + dialog.PendingConfirmationEmailAddress, + dialog.EmailConfirmationRequiredDetails) + { + RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() + }; + + await HandleDialogPresentationAsync(confirmationDialog); + + if (confirmationDialog.ResendSucceeded) + { + await ShowMessageAsync( + string.Format(Translator.WinoAccount_EmailConfirmationResentDialog_Message, dialog.PendingConfirmationEmailAddress), + Translator.WinoAccount_EmailConfirmationResentDialog_Title); + } + + return null; + } + + if (!string.IsNullOrWhiteSpace(dialog.PasswordResetEmailAddress)) + { + await ShowMessageAsync( + string.Format(Translator.WinoAccount_ForgotPasswordDialog_SuccessMessage, dialog.PasswordResetEmailAddress), + Translator.WinoAccount_ForgotPasswordDialog_SuccessTitle); + + return null; + } + + return dialog.Result; } } diff --git a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs index 22e7b1f0..46a5ad3e 100644 --- a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs +++ b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs @@ -19,6 +19,10 @@ public static class WinoAccountAuthErrorTranslator ApiErrorCodes.AccountBanned => Translator.WinoAccount_Error_AccountBanned, ApiErrorCodes.AccountSuspended => Translator.WinoAccount_Error_AccountSuspended, ApiErrorCodes.EmailNotConfirmed => Translator.WinoAccount_Error_EmailNotConfirmed, + ApiErrorCodes.EmailConfirmationRequired => Translator.WinoAccount_Error_EmailConfirmationRequired, + ApiErrorCodes.EmailConfirmationResendNotAvailable => Translator.WinoAccount_Error_EmailConfirmationResendNotAvailable, + ApiErrorCodes.EmailConfirmationResendInvalid => Translator.WinoAccount_Error_EmailConfirmationResendInvalid, + ApiErrorCodes.EmailNotRegistered => Translator.WinoAccount_Error_EmailNotRegistered, ApiErrorCodes.RefreshTokenInvalid => Translator.WinoAccount_Error_RefreshTokenInvalid, ApiErrorCodes.EmailAlreadyRegistered => Translator.WinoAccount_Error_EmailAlreadyRegistered, ApiErrorCodes.ExternalLoginEmailRequired => Translator.WinoAccount_Error_ExternalLoginEmailRequired, diff --git a/Wino.Mail.WinUI/Services/WinoAccountEmailConfirmationHelper.cs b/Wino.Mail.WinUI/Services/WinoAccountEmailConfirmationHelper.cs new file mode 100644 index 00000000..258eedbf --- /dev/null +++ b/Wino.Mail.WinUI/Services/WinoAccountEmailConfirmationHelper.cs @@ -0,0 +1,69 @@ +using System; +using System.Text.Json; +using Wino.Mail.Api.Contracts.Auth; + +namespace Wino.Mail.WinUI.Services; + +internal static class WinoAccountEmailConfirmationHelper +{ + public static bool IsEmailConfirmationRequiredError(string? errorCode) + => string.Equals(errorCode, Wino.Mail.Api.Contracts.Common.ApiErrorCodes.EmailNotConfirmed, StringComparison.Ordinal) || + string.Equals(errorCode, Wino.Mail.Api.Contracts.Common.ApiErrorCodes.EmailConfirmationRequired, StringComparison.Ordinal); + + public static EmailConfirmationRequiredDetailsDto? Parse(JsonElement? details) + { + if (details is not JsonElement element || element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return null; + } + + try + { + if (!TryGetString(element, "resendConfirmationEndpoint", out var endpoint) || + !TryGetString(element, "resendConfirmationTicket", out var ticket) || + !TryGetDateTimeOffset(element, "resendAvailableAtUtc", out var resendAvailableAtUtc)) + { + return null; + } + + DateTimeOffset? latestSentUtc = null; + if (element.TryGetProperty("latestConfirmationEmailSentUtc", out var latestSentElement) && + latestSentElement.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(latestSentElement.GetString(), out var latestParsed)) + { + latestSentUtc = latestParsed; + } + + return new EmailConfirmationRequiredDetailsDto(endpoint, ticket, latestSentUtc, resendAvailableAtUtc); + } + catch (FormatException) + { + return null; + } + } + + private static bool TryGetString(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryGetDateTimeOffset(JsonElement element, string propertyName, out DateTimeOffset value) + { + value = default; + + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + return DateTimeOffset.TryParse(property.GetString(), out value); + } +} diff --git a/Wino.Mail.WinUI/Views/Settings/SignatureAndEncryptionPage.xaml b/Wino.Mail.WinUI/Views/Settings/SignatureAndEncryptionPage.xaml index be078b4f..1f36f603 100644 --- a/Wino.Mail.WinUI/Views/Settings/SignatureAndEncryptionPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/SignatureAndEncryptionPage.xaml @@ -17,8 +17,8 @@ @@ -44,15 +44,15 @@ + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_NameColumn, Mode=OneTime}" /> + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ExpiresColumn, Mode=OneTime}" /> + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ThumbprintColumn, Mode=OneTime}" /> @@ -96,8 +96,8 @@ @@ -125,15 +125,15 @@ + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_NameColumn, Mode=OneTime}" /> + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ExpiresColumn, Mode=OneTime}" /> + Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ThumbprintColumn, Mode=OneTime}" /> diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml index 92c86f0a..76b61911 100644 --- a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml @@ -315,6 +315,16 @@ + + + + diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs index ea1ac65d..f3c69dc8 100644 --- a/Wino.Services/WinoAccountApiClient.cs +++ b/Wino.Services/WinoAccountApiClient.cs @@ -27,6 +27,9 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable private readonly IDatabaseService _databaseService; private readonly bool _ownsHttpClient; + private const string ApiUrl = "https://localhost:7204/"; + // private const string ApiUrl = "https://api.winomail.app/"; + public WinoAccountApiClient(IDatabaseService databaseService, HttpClient? httpClient = null) { _databaseService = databaseService; @@ -44,8 +47,9 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable _httpClient = new HttpClient(handler) { - BaseAddress = new Uri("https://api.winomail.app/") + BaseAddress = new Uri(ApiUrl) }; + _ownsHttpClient = true; } @@ -58,6 +62,24 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable public Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) => SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken); + public Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default) + => SendAnonymousRequestAsync( + HttpMethod.Post, + endpoint, + new ResendEmailConfirmationRequest(ticket), + WinoAccountApiJsonContext.Default.ResendEmailConfirmationRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeEmailConfirmationResendResultDto, + cancellationToken); + + public Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default) + => SendAnonymousRequestAsync( + HttpMethod.Post, + "api/v1/auth/forgot-password", + new ForgotPasswordRequest(email), + WinoAccountApiJsonContext.Default.ForgotPasswordRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement, + cancellationToken); + public async Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default) { try @@ -173,8 +195,9 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable var errorCode = envelope?.ErrorCode ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim(); var errorMessage = ExtractErrorMessage(payload) ?? response.ReasonPhrase; + var errorDetails = ExtractDetails(payload); - return WinoAccountApiResult.Failure(errorCode, errorMessage); + return WinoAccountApiResult.Failure(errorCode, errorMessage, errorDetails); } catch (Exception ex) { @@ -200,6 +223,29 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable } } + private static JsonElement? ExtractDetails(string? payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(payload); + if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.TryGetProperty("details", out var details)) + { + return null; + } + + return details.Clone(); + } + catch (JsonException) + { + return null; + } + } + private static string? TryGetErrorMessage(JsonElement element) { if (TryGetStringProperty(element, "errorMessage", out var errorMessage)) @@ -246,6 +292,35 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable private Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken) => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken); + private async Task> SendAnonymousRequestAsync(HttpMethod method, + string endpoint, + TRequest requestBody, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo> responseTypeInfo, + CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(method, endpoint) + { + Content = JsonContent.Create(requestBody, requestTypeInfo) + }; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var envelope = string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, responseTypeInfo); + + return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); + } + catch (Exception ex) + { + return ApiEnvelope.Failure(ex.Message); + } + } + private async Task> SendAuthorizedRequestAsync(HttpMethod method, string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken) { try @@ -310,7 +385,10 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable [JsonSerializable(typeof(LoginRequest))] [JsonSerializable(typeof(RefreshRequest))] [JsonSerializable(typeof(LogoutRequest))] +[JsonSerializable(typeof(ResendEmailConfirmationRequest))] +[JsonSerializable(typeof(ForgotPasswordRequest))] [JsonSerializable(typeof(ApiEnvelope))] +[JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index 7221bcef..86da903c 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Reflection; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Serilog; @@ -34,15 +35,14 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun public async Task RegisterAsync(string email, string password, CancellationToken cancellationToken = default) { var response = await _apiClient.RegisterAsync(email, password, cancellationToken).ConfigureAwait(false); - var result = await PersistResponseAsync(response).ConfigureAwait(false); - - if (result.IsSuccess && result.Account != null) + if (!response.IsSuccess || response.Result == null) { - PublishProfileUpdated(result.Account); - ReportUIChange(new WinoAccountSignedInMessage(result.Account)); + _logger.Warning("Wino account registration failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage); + return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage, response.ErrorDetails); } - return result; + // Registration no longer signs the user in locally until the email address is confirmed. + return WinoAccountOperationResult.Success(Map(response.Result)); } public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) @@ -59,6 +59,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return result; } + public Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default) + => _apiClient.ResendEmailConfirmationAsync(endpoint, ticket, cancellationToken); + + public Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default) + => _apiClient.ForgotPasswordAsync(email, cancellationToken); + public async Task RefreshAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); @@ -310,7 +316,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (!response.IsSuccess || response.Result == null) { _logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage); - return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage); + return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage, response.ErrorDetails); } var account = Map(response.Result);