Sign in , out ,register.
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <returns>Signature information. Null if canceled.</returns>
|
||||
Task<AccountSignature> ShowSignatureEditorDialog(AccountSignature signatureModel = null);
|
||||
Task<AccountSignature> ShowSignatureEditorDialog(AccountSignature? signatureModel = null);
|
||||
|
||||
/// <summary>
|
||||
/// Presents a dialog to the user for account alias creation/modification.
|
||||
@@ -59,7 +60,7 @@ public interface IMailDialogService : IDialogServiceBase
|
||||
/// </summary>
|
||||
/// <param name="existingShortcut">Existing shortcut to edit, or null for new shortcut.</param>
|
||||
/// <returns>Dialog result with shortcut information.</returns>
|
||||
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||
#pragma warning disable CS8625
|
||||
Task<KeyboardShortcutDialogResult> ShowKeyboardShortcutDialogAsync(KeyboardShortcut existingShortcut = null);
|
||||
#pragma warning restore CS8625
|
||||
|
||||
@@ -68,5 +69,9 @@ public interface IMailDialogService : IDialogServiceBase
|
||||
/// </summary>
|
||||
/// <param name="contact">Existing contact to edit, or null for new contact.</param>
|
||||
/// <returns>Contact information. Null if canceled.</returns>
|
||||
Task<AccountContact> ShowEditContactDialogAsync(AccountContact contact = null);
|
||||
Task<AccountContact?> ShowEditContactDialogAsync(AccountContact? contact = null);
|
||||
|
||||
Task<WinoAccount?> ShowWinoAccountRegistrationDialogAsync();
|
||||
|
||||
Task<WinoAccount?> ShowWinoAccountLoginDialogAsync();
|
||||
}
|
||||
|
||||
@@ -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<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||
Task<WinoAccountOperationResult> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
|
||||
Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||
Task<WinoAccount?> GetActiveAccountAsync();
|
||||
Task<bool> HasActiveAccountAsync();
|
||||
Task SignOutAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
<PackageReference Include="sqlite-net-pcl" />
|
||||
<PackageReference Include="TimePeriodLibrary.NET" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Mail.Contracts\Wino.Mail.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
|
||||
</ItemGroup>
|
||||
@@ -71,4 +74,4 @@
|
||||
<!-- Source Generators -->
|
||||
<ProjectReference Include="..\Wino.SourceGenerators\Wino.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -50,6 +50,7 @@ public class InMemoryDatabaseService : IDatabaseService
|
||||
await Connection.CreateTableAsync<CalendarAttachment>();
|
||||
await Connection.CreateTableAsync<Reminder>();
|
||||
await Connection.CreateTableAsync<MailInvitationCalendarMapping>();
|
||||
await Connection.CreateTableAsync<WinoAccount>();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -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<IWinoAccountApiClient> _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<AuthResultDto>.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<WinoAccount>().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<AuthResultDto>.Success(CreateAuthResult("first@example.com")));
|
||||
|
||||
_apiClient
|
||||
.Setup(x => x.LoginAsync("second@example.com", "pw", default))
|
||||
.ReturnsAsync(ApiEnvelope<AuthResultDto>.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<WinoAccount>().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<AuthResultDto>.Success(authResult));
|
||||
|
||||
_apiClient
|
||||
.Setup(x => x.LogoutAsync(authResult.RefreshToken, default))
|
||||
.ReturnsAsync(ApiEnvelope<System.Text.Json.JsonElement>.Success(default));
|
||||
|
||||
await _service.LoginAsync("signout@example.com", "pw");
|
||||
await _service.SignOutAsync();
|
||||
|
||||
var persisted = await _databaseService.Connection.Table<WinoAccount>().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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Mail.Api.Contracts.Billing;
|
||||
|
||||
public sealed record CheckoutSessionResultDto(string Url);
|
||||
public sealed record CustomerPortalResultDto(string Url);
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Wino.Mail.Api.Contracts.Common;
|
||||
|
||||
public sealed class ApiEnvelope<T>
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public T? Result { get; init; }
|
||||
public QuotaInfoDto? Quota { get; init; }
|
||||
|
||||
public static ApiEnvelope<T> Success(T result, QuotaInfoDto? quota = null)
|
||||
=> new()
|
||||
{
|
||||
IsSuccess = true,
|
||||
Result = result,
|
||||
Quota = quota,
|
||||
};
|
||||
|
||||
public static ApiEnvelope<T> Failure(string errorCode, QuotaInfoDto? quota = null)
|
||||
=> new()
|
||||
{
|
||||
IsSuccess = false,
|
||||
ErrorCode = errorCode,
|
||||
Quota = quota,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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<IWinoAccountProfileService>();
|
||||
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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<ContentDialog
|
||||
x:Class="Wino.Dialogs.WinoAccountLoginDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:domain="using:Wino.Core.Domain"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="{x:Bind domain:Translator.WinoAccount_LoginDialog_Title}"
|
||||
PrimaryButtonClick="LoginClicked"
|
||||
PrimaryButtonText="{x:Bind domain:Translator.Buttons_SignIn}"
|
||||
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel}"
|
||||
Style="{StaticResource WinoDialogStyle}"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ContentDialog.Resources>
|
||||
<x:Double x:Key="ContentDialogMinWidth">440</x:Double>
|
||||
<x:Double x:Key="ContentDialogMaxWidth">440</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_LoginDialog_Description}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
|
||||
<TextBox
|
||||
x:Name="EmailTextBox"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_EmailLabel}"
|
||||
PlaceholderText="{x:Bind domain:Translator.WinoAccount_EmailPlaceholder}"
|
||||
TextChanging="InputChanged" />
|
||||
|
||||
<PasswordBox
|
||||
x:Name="PasswordBox"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_PasswordLabel}"
|
||||
PasswordChanged="InputChanged" />
|
||||
|
||||
<ProgressRing
|
||||
x:Name="BusyRing"
|
||||
Width="20"
|
||||
Height="20"
|
||||
HorizontalAlignment="Left"
|
||||
IsActive="False"
|
||||
Visibility="Collapsed" />
|
||||
|
||||
<TextBlock
|
||||
x:Name="ErrorTextBlock"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<ContentDialog
|
||||
x:Class="Wino.Dialogs.WinoAccountRegistrationDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:domain="using:Wino.Core.Domain"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="{x:Bind domain:Translator.WinoAccount_RegisterDialog_Title}"
|
||||
PrimaryButtonClick="RegisterClicked"
|
||||
PrimaryButtonText="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrimaryButton}"
|
||||
SecondaryButtonText="{x:Bind domain:Translator.Buttons_Cancel}"
|
||||
Style="{StaticResource WinoDialogStyle}"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<ContentDialog.Resources>
|
||||
<x:Double x:Key="ContentDialogMinWidth">440</x:Double>
|
||||
<x:Double x:Key="ContentDialogMaxWidth">440</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_RegisterDialog_Description}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
|
||||
<TextBox
|
||||
x:Name="EmailTextBox"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_EmailLabel}"
|
||||
PlaceholderText="{x:Bind domain:Translator.WinoAccount_EmailPlaceholder}"
|
||||
TextChanging="InputChanged" />
|
||||
|
||||
<PasswordBox
|
||||
x:Name="PasswordBox"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_PasswordLabel}"
|
||||
PasswordChanged="InputChanged" />
|
||||
|
||||
<PasswordBox
|
||||
x:Name="ConfirmPasswordBox"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_ConfirmPasswordLabel}"
|
||||
PasswordChanged="InputChanged" />
|
||||
|
||||
<ProgressRing
|
||||
x:Name="BusyRing"
|
||||
Width="20"
|
||||
Height="20"
|
||||
HorizontalAlignment="Left"
|
||||
IsActive="False"
|
||||
Visibility="Collapsed" />
|
||||
|
||||
<TextBlock
|
||||
x:Name="ErrorTextBlock"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,15 @@ namespace Wino.Services;
|
||||
|
||||
public class DialogService : DialogServiceBase, IMailDialogService
|
||||
{
|
||||
private readonly IWinoAccountProfileService _winoAccountProfileService;
|
||||
|
||||
public DialogService(INewThemeService themeService,
|
||||
IConfigurationService configurationService,
|
||||
IApplicationResourceManager<ResourceDictionary> applicationResourceManager,
|
||||
IUpdateManager updateManager) : base(themeService, configurationService, applicationResourceManager, updateManager)
|
||||
IUpdateManager updateManager,
|
||||
IWinoAccountProfileService winoAccountProfileService) : base(themeService, configurationService, applicationResourceManager, updateManager)
|
||||
{
|
||||
_winoAccountProfileService = winoAccountProfileService;
|
||||
}
|
||||
|
||||
public async Task<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync()
|
||||
@@ -213,4 +217,32 @@ public class DialogService : DialogServiceBase, IMailDialogService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<WinoAccount?> 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<WinoAccount?> ShowWinoAccountLoginDialogAsync()
|
||||
{
|
||||
var dialog = new WinoAccountLoginDialog(_winoAccountProfileService)
|
||||
{
|
||||
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
|
||||
};
|
||||
|
||||
var result = await HandleDialogPresentationAsync(dialog);
|
||||
|
||||
return result == ContentDialogResult.Primary
|
||||
? dialog.Result
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,68 @@
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="WinoAccountButton"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<Grid MinWidth="320" MaxWidth="360">
|
||||
<StackPanel
|
||||
x:Name="WinoAccountSignedOutView"
|
||||
Spacing="12">
|
||||
<TextBlock
|
||||
Style="{StaticResource BodyStrongTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_Titlebar_SignedOutTitle}" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_Titlebar_SignedOutDescription}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
Click="RegisterWinoAccountClicked"
|
||||
Content="{x:Bind domain:Translator.WinoAccount_RegisterButton_Action}" />
|
||||
<Button
|
||||
Click="LoginWinoAccountClicked"
|
||||
Content="{x:Bind domain:Translator.WinoAccount_LoginButton_Action}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel
|
||||
x:Name="WinoAccountSignedInView"
|
||||
Spacing="12"
|
||||
Visibility="Collapsed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<PersonPicture
|
||||
x:Name="WinoAccountFlyoutPicture"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Initials="W" />
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock
|
||||
x:Name="WinoAccountFlyoutEmailText"
|
||||
Style="{StaticResource BodyStrongTextBlockStyle}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
x:Name="WinoAccountFlyoutStatusText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Button
|
||||
HorizontalAlignment="Left"
|
||||
Click="SignOutWinoAccountClicked"
|
||||
Content="{x:Bind domain:Translator.WinoAccount_SignOutButton_Action}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
<PersonPicture
|
||||
x:Name="WinoAccountButtonPicture"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Initials="W" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</TitleBar.RightHeader>
|
||||
</TitleBar>
|
||||
|
||||
@@ -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<InfoBarMessageRequested>,
|
||||
IRecipient<TitleBarShellContentUpdated>,
|
||||
IRecipient<SynchronizationActionsAdded>,
|
||||
IRecipient<SynchronizationActionsCompleted>
|
||||
IRecipient<SynchronizationActionsCompleted>,
|
||||
IRecipient<WinoAccountSignedInMessage>,
|
||||
IRecipient<WinoAccountSignedOutMessage>
|
||||
{
|
||||
public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService<IStatePersistanceService>() ?? throw new Exception("StatePersistanceService not registered in DI container.");
|
||||
public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService<IPreferencesService>() ?? throw new Exception("PreferencesService not registered in DI container.");
|
||||
public INavigationService NavigationService { get; } = WinoApplication.Current.Services.GetService<INavigationService>() ?? throw new Exception("NavigationService not registered in DI container.");
|
||||
private IMailDialogService MailDialogService { get; } = WinoApplication.Current.Services.GetRequiredService<IMailDialogService>();
|
||||
private IWinoAccountProfileService WinoAccountProfileService { get; } = WinoApplication.Current.Services.GetRequiredService<IWinoAccountProfileService>();
|
||||
|
||||
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<InfoBarMessageRequested>(this);
|
||||
WeakReferenceMessenger.Default.Register<SynchronizationActionsAdded>(this);
|
||||
WeakReferenceMessenger.Default.Register<SynchronizationActionsCompleted>(this);
|
||||
WeakReferenceMessenger.Default.Register<WinoAccountSignedInMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<WinoAccountSignedOutMessage>(this);
|
||||
}
|
||||
|
||||
private void UnregisterRecipients()
|
||||
@@ -338,6 +337,118 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow,
|
||||
WeakReferenceMessenger.Default.Unregister<InfoBarMessageRequested>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsAdded>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<SynchronizationActionsCompleted>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<WinoAccountSignedInMessage>(this);
|
||||
WeakReferenceMessenger.Default.Unregister<WinoAccountSignedOutMessage>(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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Calendar.ViewModels\Wino.Calendar.ViewModels.csproj" />
|
||||
<ProjectReference Include="..\Wino.Mail.Contracts\Wino.Mail.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
<ProjectReference Include="..\Wino.Core.ViewModels\Wino.Core.ViewModels.csproj" />
|
||||
<ProjectReference Include="..\Wino.Mail.ViewModels\Wino.Mail.ViewModels.csproj" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
namespace Wino.Messaging.UI;
|
||||
|
||||
public record WinoAccountSignedInMessage(WinoAccount Account) : UIMessageBase<WinoAccountSignedInMessage>;
|
||||
@@ -0,0 +1,5 @@
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
namespace Wino.Messaging.UI;
|
||||
|
||||
public record WinoAccountSignedOutMessage(WinoAccount Account) : UIMessageBase<WinoAccountSignedOutMessage>;
|
||||
@@ -67,8 +67,8 @@ public class DatabaseService : IDatabaseService
|
||||
Connection.CreateTableAsync<CalendarItem>(),
|
||||
Connection.CreateTableAsync<CalendarAttachment>(),
|
||||
Connection.CreateTableAsync<Reminder>(),
|
||||
Connection.CreateTableAsync<MailInvitationCalendarMapping>()
|
||||
);
|
||||
Connection.CreateTableAsync<MailInvitationCalendarMapping>(),
|
||||
Connection.CreateTableAsync<WinoAccount>());
|
||||
|
||||
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);
|
||||
|
||||
@@ -27,6 +27,8 @@ public static class ServicesContainerSetup
|
||||
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
||||
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||
services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
|
||||
|
||||
services.AddTransient<ICalDavClient, CalDavClient>();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
<ProjectReference Include="..\Wino.Mail.Contracts\Wino.Mail.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -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<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken);
|
||||
|
||||
public async Task<ApiEnvelope<JsonElement>> 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<JsonElement>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiEnvelope<JsonElement>.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiEnvelope<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> 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<AuthResultDto>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiEnvelope<AuthResultDto>.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<AuthResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
|
||||
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
|
||||
@@ -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<WinoAccountProfileService>();
|
||||
|
||||
public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient) : base(databaseService)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
}
|
||||
|
||||
public async Task<WinoAccountOperationResult> 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<WinoAccountOperationResult> 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<WinoAccountOperationResult> 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<WinoAccount?> GetActiveAccountAsync()
|
||||
{
|
||||
var account = await Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
||||
return account;
|
||||
}
|
||||
|
||||
public async Task<bool> HasActiveAccountAsync()
|
||||
=> await Connection.Table<WinoAccount>().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<WinoAccount>().ConfigureAwait(false);
|
||||
|
||||
if (account != null)
|
||||
{
|
||||
ReportUIChange(new WinoAccountSignedOutMessage(account));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WinoAccountOperationResult> PersistResponseAsync(ApiEnvelope<AuthResultDto> response)
|
||||
{
|
||||
if (!response.IsSuccess || response.Result == null)
|
||||
{
|
||||
return WinoAccountOperationResult.Failure(response.ErrorCode);
|
||||
}
|
||||
|
||||
var account = Map(response.Result);
|
||||
|
||||
await Connection.DeleteAllAsync<WinoAccount>().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
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,11 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
<Platform Solution="*|x86" Project="x86" />
|
||||
</Project>
|
||||
<Project Path="Wino.Mail.Contracts/Wino.Mail.Contracts.csproj">
|
||||
<Platform Solution="*|arm64" Project="Any CPU" />
|
||||
<Platform Solution="*|x64" Project="Any CPU" />
|
||||
<Platform Solution="*|x86" Project="Any CPU" />
|
||||
</Project>
|
||||
<Project Path="Wino.Messages/Wino.Messaging.csproj">
|
||||
<Platform Solution="*|arm64" Project="arm64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
Reference in New Issue
Block a user