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);