diff --git a/Wino.Core.Domain/Entities/Shared/WinoAccount.cs b/Wino.Core.Domain/Entities/Shared/WinoAccount.cs new file mode 100644 index 00000000..9b0a92e1 --- /dev/null +++ b/Wino.Core.Domain/Entities/Shared/WinoAccount.cs @@ -0,0 +1,30 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Shared; + +public class WinoAccount +{ + [PrimaryKey] + public Guid Id { get; set; } + + public string Email { get; set; } = string.Empty; + + public string AccountStatus { get; set; } = string.Empty; + + public bool HasPassword { get; set; } + + public bool HasGoogleLogin { get; set; } + + public bool HasFacebookLogin { get; set; } + + public string AccessToken { get; set; } = string.Empty; + + public DateTime AccessTokenExpiresAtUtc { get; set; } + + public string RefreshToken { get; set; } = string.Empty; + + public DateTime RefreshTokenExpiresAtUtc { get; set; } + + public DateTime LastAuthenticatedUtc { get; set; } +} diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs index 8d5ab945..007c9572 100644 --- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs @@ -1,4 +1,5 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; @@ -41,7 +42,7 @@ public interface IMailDialogService : IDialogServiceBase /// Presents a dialog to the user for signature creation/modification. /// /// Signature information. Null if canceled. - Task ShowSignatureEditorDialog(AccountSignature signatureModel = null); + Task ShowSignatureEditorDialog(AccountSignature? signatureModel = null); /// /// Presents a dialog to the user for account alias creation/modification. @@ -59,7 +60,7 @@ public interface IMailDialogService : IDialogServiceBase /// /// Existing shortcut to edit, or null for new shortcut. /// Dialog result with shortcut information. -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. +#pragma warning disable CS8625 Task ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null); #pragma warning restore CS8625 @@ -68,5 +69,9 @@ public interface IMailDialogService : IDialogServiceBase /// /// Existing contact to edit, or null for new contact. /// Contact information. Null if canceled. - Task ShowEditContactDialogAsync(AccountContact contact = null); + Task ShowEditContactDialogAsync(AccountContact? contact = null); + + Task ShowWinoAccountRegistrationDialogAsync(); + + Task ShowWinoAccountLoginDialogAsync(); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs new file mode 100644 index 00000000..ce018924 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Common; + +namespace Wino.Core.Domain.Interfaces; + +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> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); +} diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs new file mode 100644 index 00000000..85870ddf --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -0,0 +1,17 @@ +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Core.Domain.Interfaces; + +public interface IWinoAccountProfileService +{ + Task RegisterAsync(string email, string password, CancellationToken cancellationToken = default); + Task LoginAsync(string email, string password, CancellationToken cancellationToken = default); + Task RefreshAsync(CancellationToken cancellationToken = default); + Task GetActiveAccountAsync(); + Task HasActiveAccountAsync(); + Task SignOutAsync(CancellationToken cancellationToken = default); +} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs new file mode 100644 index 00000000..15763b40 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs @@ -0,0 +1,25 @@ +#nullable enable +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class WinoAccountOperationResult +{ + public bool IsSuccess { get; init; } + public string? ErrorCode { get; init; } + public WinoAccount? Account { get; init; } + + public static WinoAccountOperationResult Success(WinoAccount account) + => new() + { + IsSuccess = true, + Account = account + }; + + public static WinoAccountOperationResult Failure(string? errorCode) + => new() + { + IsSuccess = false, + ErrorCode = errorCode + }; +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 2dd27a95..235e3a31 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1144,6 +1144,48 @@ "WelcomeWindow_SkipForNow": "Skip for now — I'll set it up later", "WelcomeWindow_AppDescription": "A fast, focused inbox — redesigned for Windows 11", "WelcomeWizard_Step1Title": "Welcome", + "WinoAccount_SettingsSection_Title": "Wino Account", + "WinoAccount_SettingsSection_Description": "Create or sign in to a Wino Account using your localhost auth service.", + "WinoAccount_RegisterButton_Title": "Register account", + "WinoAccount_RegisterButton_Description": "Create a Wino Account with email and password.", + "WinoAccount_RegisterButton_Action": "Open registration", + "WinoAccount_LoginButton_Title": "Sign in", + "WinoAccount_LoginButton_Description": "Sign in to an existing Wino Account with email and password.", + "WinoAccount_LoginButton_Action": "Open login", + "WinoAccount_SignOutButton_Title": "Sign out", + "WinoAccount_SignOutButton_Description": "Remove the locally stored Wino Account session.", + "WinoAccount_SignOutButton_Action": "Sign out", + "WinoAccount_RegisterDialog_Title": "Create Wino Account", + "WinoAccount_RegisterDialog_Description": "Register a new Wino Account using the localhost API.", + "WinoAccount_RegisterDialog_PrimaryButton": "Register", + "WinoAccount_LoginDialog_Title": "Sign In to Wino Account", + "WinoAccount_LoginDialog_Description": "Sign in using the localhost API.", + "WinoAccount_EmailLabel": "Email", + "WinoAccount_EmailPlaceholder": "name@example.com", + "WinoAccount_PasswordLabel": "Password", + "WinoAccount_ConfirmPasswordLabel": "Confirm password", + "WinoAccount_Validation_EmailRequired": "Email is required.", + "WinoAccount_Validation_PasswordRequired": "Password is required.", + "WinoAccount_Validation_PasswordMismatch": "Passwords do not match.", + "WinoAccount_Error_InvalidCredentials": "The email address or password is incorrect.", + "WinoAccount_Error_AccountLocked": "This account is temporarily locked.", + "WinoAccount_Error_AccountBanned": "This account has been banned.", + "WinoAccount_Error_AccountSuspended": "This account has been suspended.", + "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.", + "WinoAccount_Error_ExternalLoginInvalid": "The external sign-in request is invalid.", + "WinoAccount_Error_ExternalAuthStateInvalid": "The external sign-in state is invalid or expired.", + "WinoAccount_Error_ExternalAuthCodeInvalid": "The external sign-in code is invalid or expired.", + "WinoAccount_Error_Forbidden": "You do not have permission to perform this action.", + "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_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", + "WinoAccount_Titlebar_SignedOutDescription": "Sign in or create a Wino Account to manage your Wino session.", + "WinoAccount_Titlebar_SignedInStatus": "Status: {0}", "WelcomeWizard_Step2Title": "Add Account", "WelcomeWizard_Step3Title": "Finish Setup", "ProviderSelection_Title": "Choose your email provider", diff --git a/Wino.Core.Domain/Wino.Core.Domain.csproj b/Wino.Core.Domain/Wino.Core.Domain.csproj index c0fa6b9a..b44190a8 100644 --- a/Wino.Core.Domain/Wino.Core.Domain.csproj +++ b/Wino.Core.Domain/Wino.Core.Domain.csproj @@ -61,6 +61,9 @@ + + + @@ -71,4 +74,4 @@ - \ No newline at end of file + diff --git a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs index a95a3e97..2c027196 100644 --- a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs +++ b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs @@ -50,6 +50,7 @@ public class InMemoryDatabaseService : IDatabaseService await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); } public async ValueTask DisposeAsync() diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs new file mode 100644 index 00000000..9ad47176 --- /dev/null +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Common; +using Wino.Services; +using Wino.Core.Tests.Helpers; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class WinoAccountProfileServiceTests : IAsyncLifetime +{ + private readonly Mock _apiClient = new(); + private InMemoryDatabaseService _databaseService = null!; + private WinoAccountProfileService _service = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + _service = new WinoAccountProfileService(_databaseService, _apiClient.Object); + } + + public async Task DisposeAsync() + { + await _databaseService.DisposeAsync(); + } + + [Fact] + public async Task LoginAsync_ShouldPersistSingleActiveAccount() + { + var authResult = CreateAuthResult("first@example.com"); + + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(ApiEnvelope.Success(authResult)); + + var result = await _service.LoginAsync("first@example.com", "pw"); + + result.IsSuccess.Should().BeTrue(); + result.Account.Should().NotBeNull(); + + var persisted = await _databaseService.Connection.Table().ToListAsync(); + persisted.Should().ContainSingle(); + persisted[0].Email.Should().Be("first@example.com"); + persisted[0].AccessToken.Should().Be(authResult.AccessToken); + persisted[0].RefreshToken.Should().Be(authResult.RefreshToken); + } + + [Fact] + public async Task LoginAsync_ShouldReplaceExistingActiveAccount() + { + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("first@example.com"))); + + _apiClient + .Setup(x => x.LoginAsync("second@example.com", "pw", default)) + .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("second@example.com"))); + + await _service.LoginAsync("first@example.com", "pw"); + await _service.LoginAsync("second@example.com", "pw"); + + var persisted = await _databaseService.Connection.Table().ToListAsync(); + persisted.Should().ContainSingle(); + persisted[0].Email.Should().Be("second@example.com"); + } + + [Fact] + public async Task SignOutAsync_ShouldDeletePersistedAccount() + { + var authResult = CreateAuthResult("signout@example.com"); + + _apiClient + .Setup(x => x.LoginAsync("signout@example.com", "pw", default)) + .ReturnsAsync(ApiEnvelope.Success(authResult)); + + _apiClient + .Setup(x => x.LogoutAsync(authResult.RefreshToken, default)) + .ReturnsAsync(ApiEnvelope.Success(default)); + + await _service.LoginAsync("signout@example.com", "pw"); + await _service.SignOutAsync(); + + var persisted = await _databaseService.Connection.Table().ToListAsync(); + persisted.Should().BeEmpty(); + } + + private static AuthResultDto CreateAuthResult(string email) + { + return new AuthResultDto( + new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false), + "access-token", + DateTimeOffset.UtcNow.AddMinutes(30), + "refresh-token", + DateTimeOffset.UtcNow.AddDays(30)); + } +} diff --git a/Wino.Mail.Contracts/Admin/AdminContracts.cs b/Wino.Mail.Contracts/Admin/AdminContracts.cs new file mode 100644 index 00000000..82cb8c4d --- /dev/null +++ b/Wino.Mail.Contracts/Admin/AdminContracts.cs @@ -0,0 +1,5 @@ +namespace Wino.Mail.Api.Contracts.Admin; + +public sealed record ModerateUserRequest(string ReasonCode, string? ReasonNote); +public sealed record AdminUserResultDto(Guid UserId, string Email, string AccountStatus, DateTimeOffset CreatedUtc); +public sealed record ModerationActionResultDto(string Action, string ReasonCode, string? ReasonNote, Guid? ActorUserId, DateTimeOffset CreatedUtc); diff --git a/Wino.Mail.Contracts/Ai/AiContracts.cs b/Wino.Mail.Contracts/Ai/AiContracts.cs new file mode 100644 index 00000000..db9b825f --- /dev/null +++ b/Wino.Mail.Contracts/Ai/AiContracts.cs @@ -0,0 +1,7 @@ +namespace Wino.Mail.Api.Contracts.Ai; + +public sealed record SummarizeRequest(string Html); +public sealed record TranslateRequest(string Html, string TargetLanguage); +public sealed record RewriteRequest(string Html, string Instruction); +public sealed record AiTextResultDto(string Text); +public sealed record AiStatusResultDto(bool HasAiPack, string EntitlementStatus, DateTimeOffset? CurrentPeriodStartUtc, DateTimeOffset? CurrentPeriodEndUtc, int? MonthlyLimit, int? Used, int? Remaining); diff --git a/Wino.Mail.Contracts/Auth/AuthContracts.cs b/Wino.Mail.Contracts/Auth/AuthContracts.cs new file mode 100644 index 00000000..5335ae93 --- /dev/null +++ b/Wino.Mail.Contracts/Auth/AuthContracts.cs @@ -0,0 +1,11 @@ +namespace Wino.Mail.Api.Contracts.Auth; + +public sealed record RegisterRequest(string Email, string Password); +public sealed record LoginRequest(string Email, string Password); +public sealed record CompleteExternalAuthRequest(string Code); +public sealed record RefreshRequest(string RefreshToken); +public sealed record LogoutRequest(string RefreshToken); +public sealed record ForgotPasswordRequest(string Email); +public sealed record ResetPasswordRequest(string Email, string ResetToken, string NewPassword); +public sealed record AuthUserDto(Guid UserId, string Email, string AccountStatus, bool HasPassword, bool HasGoogleLogin, bool HasFacebookLogin); +public sealed record AuthResultDto(AuthUserDto User, string AccessToken, DateTimeOffset AccessTokenExpiresAtUtc, string RefreshToken, DateTimeOffset RefreshTokenExpiresAtUtc); diff --git a/Wino.Mail.Contracts/Billing/BillingContracts.cs b/Wino.Mail.Contracts/Billing/BillingContracts.cs new file mode 100644 index 00000000..e2f18d7d --- /dev/null +++ b/Wino.Mail.Contracts/Billing/BillingContracts.cs @@ -0,0 +1,4 @@ +namespace Wino.Mail.Api.Contracts.Billing; + +public sealed record CheckoutSessionResultDto(string Url); +public sealed record CustomerPortalResultDto(string Url); diff --git a/Wino.Mail.Contracts/Common/ApiEnvelope.cs b/Wino.Mail.Contracts/Common/ApiEnvelope.cs new file mode 100644 index 00000000..7a3f34d2 --- /dev/null +++ b/Wino.Mail.Contracts/Common/ApiEnvelope.cs @@ -0,0 +1,25 @@ +namespace Wino.Mail.Api.Contracts.Common; + +public sealed class ApiEnvelope +{ + public bool IsSuccess { get; init; } + public string? ErrorCode { get; init; } + public T? Result { get; init; } + public QuotaInfoDto? Quota { get; init; } + + public static ApiEnvelope Success(T result, QuotaInfoDto? quota = null) + => new() + { + IsSuccess = true, + Result = result, + Quota = quota, + }; + + public static ApiEnvelope Failure(string errorCode, QuotaInfoDto? quota = null) + => new() + { + IsSuccess = false, + ErrorCode = errorCode, + Quota = quota, + }; +} diff --git a/Wino.Mail.Contracts/Common/ApiErrorCodes.cs b/Wino.Mail.Contracts/Common/ApiErrorCodes.cs new file mode 100644 index 00000000..967869f0 --- /dev/null +++ b/Wino.Mail.Contracts/Common/ApiErrorCodes.cs @@ -0,0 +1,27 @@ +namespace Wino.Mail.Api.Contracts.Common; + +public static class ApiErrorCodes +{ + public const string InvalidCredentials = "INVALID_CREDENTIALS"; + public const string AccountLocked = "ACCOUNT_LOCKED"; + public const string AccountBanned = "ACCOUNT_BANNED"; + public const string AccountSuspended = "ACCOUNT_SUSPENDED"; + public const string RefreshTokenInvalid = "REFRESH_TOKEN_INVALID"; + public const string EmailAlreadyRegistered = "EMAIL_ALREADY_REGISTERED"; + public const string ExternalLoginEmailRequired = "EXTERNAL_LOGIN_EMAIL_REQUIRED"; + public const string ExternalLoginInvalid = "EXTERNAL_LOGIN_INVALID"; + public const string ExternalAuthStateInvalid = "EXTERNAL_AUTH_STATE_INVALID"; + public const string ExternalAuthCodeInvalid = "EXTERNAL_AUTH_CODE_INVALID"; + public const string AiPackRequired = "AI_PACK_REQUIRED"; + public const string AiQuotaExceeded = "AI_QUOTA_EXCEEDED"; + public const string AiHtmlEmpty = "AI_HTML_EMPTY"; + public const string AiHtmlTooLarge = "AI_HTML_TOO_LARGE"; + public const string AiUnsupportedLanguage = "AI_UNSUPPORTED_LANGUAGE"; + public const string AiSanitizationFailed = "AI_SANITIZATION_FAILED"; + public const string AiProviderUnavailable = "AI_PROVIDER_UNAVAILABLE"; + public const string AiRequestBlocked = "AI_REQUEST_BLOCKED"; + public const string AiInternalError = "AI_INTERNAL_ERROR"; + public const string PaddleWebhookInvalid = "PADDLE_WEBHOOK_INVALID"; + public const string Forbidden = "FORBIDDEN"; + public const string ValidationFailed = "VALIDATION_FAILED"; +} diff --git a/Wino.Mail.Contracts/Common/QuotaInfoDto.cs b/Wino.Mail.Contracts/Common/QuotaInfoDto.cs new file mode 100644 index 00000000..87936114 --- /dev/null +++ b/Wino.Mail.Contracts/Common/QuotaInfoDto.cs @@ -0,0 +1,10 @@ +namespace Wino.Mail.Api.Contracts.Common; + +public sealed record QuotaInfoDto( + bool HasAiPack, + string EntitlementStatus, + DateTimeOffset? CurrentPeriodStartUtc, + DateTimeOffset? CurrentPeriodEndUtc, + int? MonthlyLimit, + int? Used, + int? Remaining); diff --git a/Wino.Mail.Contracts/Wino.Mail.Contracts.csproj b/Wino.Mail.Contracts/Wino.Mail.Contracts.csproj new file mode 100644 index 00000000..1a578629 --- /dev/null +++ b/Wino.Mail.Contracts/Wino.Mail.Contracts.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 5ae22e89..831dcddc 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -255,6 +255,7 @@ public partial class App : WinoApplication, // Initialize theme service after window creation. // Theme service requires the window to exist to properly load and apply themes. await NewThemeService.InitializeAsync(); + await LoadInitialWinoAccountAsync(); LogActivation("Theme service initialized."); // If startup task launch, keep window hidden (system tray only). @@ -826,6 +827,17 @@ public partial class App : WinoApplication, _autoSynchronizationLoopCts = null; } + private async Task LoadInitialWinoAccountAsync() + { + var winoAccountProfileService = Services.GetRequiredService(); + var winoAccount = await winoAccountProfileService.GetActiveAccountAsync().ConfigureAwait(false); + + if (winoAccount != null) + { + WeakReferenceMessenger.Default.Send(new WinoAccountSignedInMessage(winoAccount)); + } + } + private async Task RunAutoSynchronizationLoopAsync(TimeSpan interval, CancellationToken cancellationToken) { try diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml new file mode 100644 index 00000000..67cc828e --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml @@ -0,0 +1,51 @@ + + + + 440 + 440 + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs new file mode 100644 index 00000000..20b367f3 --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs @@ -0,0 +1,97 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.WinUI.Services; + +namespace Wino.Dialogs; + +public sealed partial class WinoAccountLoginDialog : ContentDialog +{ + private readonly IWinoAccountProfileService _profileService; + + public WinoAccountLoginDialog(IWinoAccountProfileService profileService) + { + _profileService = profileService; + InitializeComponent(); + } + + public WinoAccount? Result { get; private set; } + + private async void LoginClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + args.Cancel = true; + + var validationError = ValidateInput(); + if (!string.IsNullOrWhiteSpace(validationError)) + { + ShowError(validationError); + return; + } + + var deferral = args.GetDeferral(); + + try + { + SetBusyState(true); + HideError(); + + var result = await _profileService.LoginAsync(EmailTextBox.Text.Trim(), PasswordBox.Password); + + if (!result.IsSuccess || result.Account == null) + { + ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); + return; + } + + Result = result.Account; + args.Cancel = false; + Hide(); + } + finally + { + SetBusyState(false); + deferral.Complete(); + } + } + + private string ValidateInput() + { + if (string.IsNullOrWhiteSpace(EmailTextBox.Text)) + { + return Translator.WinoAccount_Validation_EmailRequired; + } + + if (string.IsNullOrWhiteSpace(PasswordBox.Password)) + { + return Translator.WinoAccount_Validation_PasswordRequired; + } + + return string.Empty; + } + + private void InputChanged(TextBox sender, TextBoxTextChangingEventArgs args) => HideError(); + + private void InputChanged(object sender, RoutedEventArgs e) => HideError(); + + private void SetBusyState(bool isBusy) + { + IsPrimaryButtonEnabled = !isBusy; + 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; + } +} diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml new file mode 100644 index 00000000..5e879b4a --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml @@ -0,0 +1,56 @@ + + + + 440 + 440 + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs new file mode 100644 index 00000000..fcc01d94 --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.WinUI.Services; + +namespace Wino.Dialogs; + +public sealed partial class WinoAccountRegistrationDialog : ContentDialog +{ + private readonly IWinoAccountProfileService _profileService; + + public WinoAccountRegistrationDialog(IWinoAccountProfileService profileService) + { + _profileService = profileService; + InitializeComponent(); + } + + public WinoAccount? Result { get; private set; } + + private async void RegisterClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + args.Cancel = true; + + var validationError = ValidateInput(); + if (!string.IsNullOrWhiteSpace(validationError)) + { + ShowError(validationError); + return; + } + + var deferral = args.GetDeferral(); + + try + { + SetBusyState(true); + HideError(); + + var result = await _profileService.RegisterAsync(EmailTextBox.Text.Trim(), PasswordBox.Password); + + if (!result.IsSuccess || result.Account == null) + { + ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); + return; + } + + Result = result.Account; + args.Cancel = false; + Hide(); + } + finally + { + SetBusyState(false); + deferral.Complete(); + } + } + + private string ValidateInput() + { + if (string.IsNullOrWhiteSpace(EmailTextBox.Text)) + { + return Translator.WinoAccount_Validation_EmailRequired; + } + + if (string.IsNullOrWhiteSpace(PasswordBox.Password)) + { + return Translator.WinoAccount_Validation_PasswordRequired; + } + + if (!string.Equals(PasswordBox.Password, ConfirmPasswordBox.Password, StringComparison.Ordinal)) + { + return Translator.WinoAccount_Validation_PasswordMismatch; + } + + return string.Empty; + } + + private void InputChanged(TextBox sender, TextBoxTextChangingEventArgs args) => HideError(); + + private void InputChanged(object sender, RoutedEventArgs e) => HideError(); + + private void SetBusyState(bool isBusy) + { + IsPrimaryButtonEnabled = !isBusy; + 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; + } +} diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs index f9a3faa9..89887935 100644 --- a/Wino.Mail.WinUI/Services/DialogService.cs +++ b/Wino.Mail.WinUI/Services/DialogService.cs @@ -27,11 +27,15 @@ namespace Wino.Services; public class DialogService : DialogServiceBase, IMailDialogService { + private readonly IWinoAccountProfileService _winoAccountProfileService; + public DialogService(INewThemeService themeService, IConfigurationService configurationService, IApplicationResourceManager applicationResourceManager, - IUpdateManager updateManager) : base(themeService, configurationService, applicationResourceManager, updateManager) + IUpdateManager updateManager, + IWinoAccountProfileService winoAccountProfileService) : base(themeService, configurationService, applicationResourceManager, updateManager) { + _winoAccountProfileService = winoAccountProfileService; } public async Task ShowCreateAccountAliasDialogAsync() @@ -213,4 +217,32 @@ public class DialogService : DialogServiceBase, IMailDialogService return null; } + + public async Task ShowWinoAccountRegistrationDialogAsync() + { + var dialog = new WinoAccountRegistrationDialog(_winoAccountProfileService) + { + RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() + }; + + var result = await HandleDialogPresentationAsync(dialog); + + return result == ContentDialogResult.Primary + ? dialog.Result + : null; + } + + public async Task ShowWinoAccountLoginDialogAsync() + { + var dialog = new WinoAccountLoginDialog(_winoAccountProfileService) + { + RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() + }; + + var result = await HandleDialogPresentationAsync(dialog); + + return result == ContentDialogResult.Primary + ? dialog.Result + : null; + } } diff --git a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs new file mode 100644 index 00000000..61ba82c4 --- /dev/null +++ b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs @@ -0,0 +1,32 @@ +using Wino.Core.Domain; +using Wino.Mail.Api.Contracts.Common; + +namespace Wino.Mail.WinUI.Services; + +public static class WinoAccountAuthErrorTranslator +{ + public static string Translate(string? errorCode) + { + if (string.IsNullOrWhiteSpace(errorCode)) + { + return Translator.GeneralTitle_Error; + } + + return errorCode switch + { + ApiErrorCodes.InvalidCredentials => Translator.WinoAccount_Error_InvalidCredentials, + ApiErrorCodes.AccountLocked => Translator.WinoAccount_Error_AccountLocked, + ApiErrorCodes.AccountBanned => Translator.WinoAccount_Error_AccountBanned, + ApiErrorCodes.AccountSuspended => Translator.WinoAccount_Error_AccountSuspended, + ApiErrorCodes.RefreshTokenInvalid => Translator.WinoAccount_Error_RefreshTokenInvalid, + ApiErrorCodes.EmailAlreadyRegistered => Translator.WinoAccount_Error_EmailAlreadyRegistered, + ApiErrorCodes.ExternalLoginEmailRequired => Translator.WinoAccount_Error_ExternalLoginEmailRequired, + ApiErrorCodes.ExternalLoginInvalid => Translator.WinoAccount_Error_ExternalLoginInvalid, + ApiErrorCodes.ExternalAuthStateInvalid => Translator.WinoAccount_Error_ExternalAuthStateInvalid, + ApiErrorCodes.ExternalAuthCodeInvalid => Translator.WinoAccount_Error_ExternalAuthCodeInvalid, + ApiErrorCodes.Forbidden => Translator.WinoAccount_Error_Forbidden, + ApiErrorCodes.ValidationFailed => Translator.WinoAccount_Error_ValidationFailed, + _ => errorCode + }; + } +} diff --git a/Wino.Mail.WinUI/ShellWindow.xaml b/Wino.Mail.WinUI/ShellWindow.xaml index 1276d551..55d2c75d 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml +++ b/Wino.Mail.WinUI/ShellWindow.xaml @@ -86,6 +86,68 @@ + diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs index 46b5ea63..e27c82e2 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml.cs +++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs @@ -10,11 +10,13 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Windows.UI; using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Synchronization; using Wino.Extensions; using Wino.Mail.WinUI.Activation; +using Wino.Mail.WinUI.Extensions; using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Views; using Wino.Messaging.Client.Shell; @@ -28,11 +30,15 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient, + IRecipient { public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("StatePersistanceService not registered in DI container."); public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("PreferencesService not registered in DI container."); public INavigationService NavigationService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("NavigationService not registered in DI container."); + private IMailDialogService MailDialogService { get; } = WinoApplication.Current.Services.GetRequiredService(); + private IWinoAccountProfileService WinoAccountProfileService { get; } = WinoApplication.Current.Services.GetRequiredService(); public ICommand ShowWinoCommand { get; set; } public ICommand ShowWinoCalendarCommand { get; set; } @@ -180,26 +186,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, public void Receive(InfoBarMessageRequested message) { - DispatcherQueue.TryEnqueue(() => - { - if (string.IsNullOrEmpty(message.ActionButtonTitle) || message.Action == null) - { - ShellInfoBar.ActionButton = null; - } - else - { - ShellInfoBar.ActionButton = new Button() - { - Content = message.ActionButtonTitle, - Command = new RelayCommand(message.Action) - }; - } - - ShellInfoBar.Message = message.Message; - ShellInfoBar.Title = message.Title; - ShellInfoBar.Severity = message.Severity.AsMUXCInfoBarSeverity(); - ShellInfoBar.IsOpen = true; - }); + ShowInfoBarMessage(message); } public void Receive(SynchronizationActionsAdded message) @@ -226,6 +213,16 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, }); } + public void Receive(WinoAccountSignedInMessage message) + { + DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(message.Account)); + } + + public void Receive(WinoAccountSignedOutMessage message) + { + DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(null)); + } + private void UpdateSyncStatusVisibility() { SyncStatusButton.Visibility = SyncActionItems.Any() @@ -329,6 +326,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } private void UnregisterRecipients() @@ -338,6 +337,118 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + } + + private void ShowInfoBarMessage(InfoBarMessageRequested message) + { + DispatcherQueue.TryEnqueue(() => + { + if (string.IsNullOrEmpty(message.ActionButtonTitle) || message.Action == null) + { + ShellInfoBar.ActionButton = null; + } + else + { + ShellInfoBar.ActionButton = new Button() + { + Content = message.ActionButtonTitle, + Command = new RelayCommand(message.Action) + }; + } + + ShellInfoBar.Message = message.Message; + ShellInfoBar.Title = message.Title; + ShellInfoBar.Severity = message.Severity.AsMUXCInfoBarSeverity(); + ShellInfoBar.IsOpen = true; + }); + } + + private void UpdateWinoAccountState(WinoAccount? account) + { + var isSignedIn = account != null; + + WinoAccountSignedOutView.Visibility = isSignedIn ? Visibility.Collapsed : Visibility.Visible; + WinoAccountSignedInView.Visibility = isSignedIn ? Visibility.Visible : Visibility.Collapsed; + + var initials = GetInitials(account?.Email); + + WinoAccountButtonPicture.Initials = initials; + WinoAccountFlyoutPicture.Initials = initials; + WinoAccountButtonPicture.DisplayName = account?.Email ?? Translator.WinoAccount_Titlebar_SignedOutTitle; + WinoAccountFlyoutPicture.DisplayName = account?.Email ?? Translator.WinoAccount_Titlebar_SignedOutTitle; + + WinoAccountFlyoutEmailText.Text = account?.Email ?? string.Empty; + WinoAccountFlyoutStatusText.Text = account == null + ? string.Empty + : string.Format(Translator.WinoAccount_Titlebar_SignedInStatus, account.AccountStatus); + } + + private static string GetInitials(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return "W"; + } + + var localPart = email.Split('@')[0]; + var segments = localPart + .Split(['.', '_', '-', ' '], StringSplitOptions.RemoveEmptyEntries) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .Take(2) + .ToArray(); + + if (segments.Length == 0) + { + return email[..1].ToUpperInvariant(); + } + + return string.Concat(segments.Select(segment => char.ToUpperInvariant(segment[0]))); + } + + private async void RegisterWinoAccountClicked(object sender, RoutedEventArgs e) + { + var account = await MailDialogService.ShowWinoAccountRegistrationDialogAsync(); + if (account != null) + { + ShowInfoBarMessage(new InfoBarMessageRequested( + InfoBarMessageType.Success, + Translator.GeneralTitle_Info, + string.Format(Translator.WinoAccount_RegisterSuccessMessage, account.Email))); + } + } + + private async void LoginWinoAccountClicked(object sender, RoutedEventArgs e) + { + var account = await MailDialogService.ShowWinoAccountLoginDialogAsync(); + if (account != null) + { + ShowInfoBarMessage(new InfoBarMessageRequested( + InfoBarMessageType.Success, + Translator.GeneralTitle_Info, + string.Format(Translator.WinoAccount_LoginSuccessMessage, account.Email))); + } + } + + private async void SignOutWinoAccountClicked(object sender, RoutedEventArgs e) + { + var activeAccount = await WinoAccountProfileService.GetActiveAccountAsync(); + if (activeAccount == null) + { + ShowInfoBarMessage(new InfoBarMessageRequested( + InfoBarMessageType.Warning, + Translator.GeneralTitle_Info, + Translator.WinoAccount_SignOut_NoAccountMessage)); + return; + } + + await WinoAccountProfileService.SignOutAsync(); + + ShowInfoBarMessage(new InfoBarMessageRequested( + InfoBarMessageType.Success, + Translator.GeneralTitle_Info, + string.Format(Translator.WinoAccount_SignOut_SuccessMessage, activeAccount.Email))); } } diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index 9221f473..5abc726b 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -226,6 +226,7 @@ + diff --git a/Wino.Messages/UI/WinoAccountSignedInMessage.cs b/Wino.Messages/UI/WinoAccountSignedInMessage.cs new file mode 100644 index 00000000..dc18b6b9 --- /dev/null +++ b/Wino.Messages/UI/WinoAccountSignedInMessage.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Messaging.UI; + +public record WinoAccountSignedInMessage(WinoAccount Account) : UIMessageBase; diff --git a/Wino.Messages/UI/WinoAccountSignedOutMessage.cs b/Wino.Messages/UI/WinoAccountSignedOutMessage.cs new file mode 100644 index 00000000..9dff9343 --- /dev/null +++ b/Wino.Messages/UI/WinoAccountSignedOutMessage.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Messaging.UI; + +public record WinoAccountSignedOutMessage(WinoAccount Account) : UIMessageBase; diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index e5bb5154..6e69c962 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -67,8 +67,8 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), - Connection.CreateTableAsync() - ); + Connection.CreateTableAsync(), + Connection.CreateTableAsync()); await EnsureSchemaUpgradesAsync().ConfigureAwait(false); await EnsureIndexesAsync().ConfigureAwait(false); @@ -206,6 +206,7 @@ SET {nameof(KeyboardShortcut.Action)} = await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailAccountAlias_AccountId_AliasAddress ON MailAccountAlias(AccountId, AliasAddress)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailAccountPreferences_AccountId ON MailAccountPreferences(AccountId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_CustomServerInformation_AccountId ON CustomServerInformation(AccountId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccount_Email ON WinoAccount(Email)").ConfigureAwait(false); // Calendar indexes await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false); diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index d02a13ed..4d657efa 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -27,6 +27,8 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/Wino.Services/Wino.Services.csproj b/Wino.Services/Wino.Services.csproj index bf159dcc..628ce966 100644 --- a/Wino.Services/Wino.Services.csproj +++ b/Wino.Services/Wino.Services.csproj @@ -25,6 +25,7 @@ + - \ No newline at end of file + diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs new file mode 100644 index 00000000..4fc4c6cb --- /dev/null +++ b/Wino.Services/WinoAccountApiClient.cs @@ -0,0 +1,123 @@ +#nullable enable +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Interfaces; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Common; + +namespace Wino.Services; + +public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable +{ + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + + public WinoAccountApiClient(HttpClient? httpClient = null) + { + if (httpClient != null) + { + _httpClient = httpClient; + return; + } + + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = ValidateCertificate + }; + + _httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://localhost:7204/") + }; + _ownsHttpClient = true; + } + + public Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default) + => SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken); + + public Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default) + => SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken); + + public Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken); + + public async Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default) + { + try + { + using var response = await _httpClient.PostAsJsonAsync( + "api/v1/auth/logout", + new LogoutRequest(refreshToken), + WinoAccountApiJsonContext.Default.LogoutRequest, + cancellationToken).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var envelope = string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement); + + return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); + } + catch (Exception ex) + { + return ApiEnvelope.Failure(ex.Message); + } + } + + private async Task> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken) + { + try + { + using var response = await _httpClient.PostAsJsonAsync( + endpoint, + request, + typeInfo, + cancellationToken).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var envelope = string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto); + + return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); + } + catch (Exception ex) + { + return ApiEnvelope.Failure(ex.Message); + } + } + + private static bool ValidateCertificate(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, System.Net.Security.SslPolicyErrors sslPolicyErrors) + { + if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true) + { + return true; + } + + return sslPolicyErrors == System.Net.Security.SslPolicyErrors.None; + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } +} + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(RegisterRequest))] +[JsonSerializable(typeof(LoginRequest))] +[JsonSerializable(typeof(RefreshRequest))] +[JsonSerializable(typeof(LogoutRequest))] +[JsonSerializable(typeof(ApiEnvelope))] +[JsonSerializable(typeof(ApiEnvelope))] +internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext; diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs new file mode 100644 index 00000000..92d551f5 --- /dev/null +++ b/Wino.Services/WinoAccountProfileService.cs @@ -0,0 +1,130 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Common; +using Wino.Messaging.UI; + +namespace Wino.Services; + +public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService +{ + private readonly IWinoAccountApiClient _apiClient; + private readonly ILogger _logger = Log.ForContext(); + + public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient) : base(databaseService) + { + _apiClient = apiClient; + } + + 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) + { + ReportUIChange(new WinoAccountSignedInMessage(result.Account)); + } + + return result; + } + + public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) + { + var response = await _apiClient.LoginAsync(email, password, cancellationToken).ConfigureAwait(false); + var result = await PersistResponseAsync(response).ConfigureAwait(false); + + if (result.IsSuccess && result.Account != null) + { + ReportUIChange(new WinoAccountSignedInMessage(result.Account)); + } + + return result; + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + var account = await GetActiveAccountAsync().ConfigureAwait(false); + if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken)) + { + return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid); + } + + var response = await _apiClient.RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false); + return await PersistResponseAsync(response).ConfigureAwait(false); + } + + public async Task GetActiveAccountAsync() + { + var account = await Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); + return account; + } + + public async Task HasActiveAccountAsync() + => await Connection.Table().CountAsync().ConfigureAwait(false) > 0; + + public async Task SignOutAsync(CancellationToken cancellationToken = default) + { + var account = await GetActiveAccountAsync().ConfigureAwait(false); + + if (account != null && !string.IsNullOrWhiteSpace(account.RefreshToken)) + { + try + { + var result = await _apiClient.LogoutAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess && !string.IsNullOrWhiteSpace(result.ErrorCode)) + { + _logger.Warning("Wino account remote sign-out failed with error code {ErrorCode}", result.ErrorCode); + } + } + catch (Exception ex) + { + _logger.Warning(ex, "Wino account remote sign-out failed."); + } + } + + await Connection.DeleteAllAsync().ConfigureAwait(false); + + if (account != null) + { + ReportUIChange(new WinoAccountSignedOutMessage(account)); + } + } + + private async Task PersistResponseAsync(ApiEnvelope response) + { + if (!response.IsSuccess || response.Result == null) + { + return WinoAccountOperationResult.Failure(response.ErrorCode); + } + + var account = Map(response.Result); + + await Connection.DeleteAllAsync().ConfigureAwait(false); + await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); + + return WinoAccountOperationResult.Success(account); + } + + private static WinoAccount Map(AuthResultDto result) + => new() + { + Id = result.User.UserId, + Email = result.User.Email, + AccountStatus = result.User.AccountStatus, + HasPassword = result.User.HasPassword, + HasGoogleLogin = result.User.HasGoogleLogin, + HasFacebookLogin = result.User.HasFacebookLogin, + AccessToken = result.AccessToken, + AccessTokenExpiresAtUtc = result.AccessTokenExpiresAtUtc.UtcDateTime, + RefreshToken = result.RefreshToken, + RefreshTokenExpiresAtUtc = result.RefreshTokenExpiresAtUtc.UtcDateTime, + LastAuthenticatedUtc = DateTime.UtcNow + }; +} diff --git a/WinoMail.slnx b/WinoMail.slnx index 9a2893e3..6137bb94 100644 --- a/WinoMail.slnx +++ b/WinoMail.slnx @@ -44,6 +44,11 @@ + + + + +