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 @@
+
+
+
+
+