Browse Source

Reworked api and login UI

Krzysztof Krysiński 4 months ago
parent
commit
b28e0de468

+ 44 - 0
src/PixiEditor.Extensions/UI/Translate.cs

@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.Extensions.UI;
+
+public class Translate : MarkupExtension
+{
+    public string Key { get; set; } = string.Empty;
+
+    private AvaloniaObject targetObject;
+    private AvaloniaProperty targetProperty;
+
+    public Translate()
+    {
+        ILocalizationProvider.Current.OnLanguageChanged += (lang) => LanguageChanged();
+    }
+
+
+    public override object ProvideValue(IServiceProvider provider)
+    {
+        if (targetObject == null)
+        {
+            var target = (IProvideValueTarget)provider.GetService(typeof(IProvideValueTarget))!;
+            targetObject = target.TargetObject as AvaloniaObject;
+            targetProperty = target.TargetProperty as AvaloniaProperty;
+        }
+
+        if (string.IsNullOrEmpty(Key))
+            return string.Empty;
+
+        return new LocalizedString(Key).Value;
+    }
+
+    private void LanguageChanged()
+    {
+        if (targetObject != null && targetProperty != null)
+        {
+            var newValue = new LocalizedString(Key).Value;
+            targetObject.SetValue(targetProperty, newValue);
+        }
+    }
+}

+ 10 - 21
src/PixiEditor.PixiAuth/PixiAuthClient.cs

@@ -16,7 +16,6 @@ public class PixiAuthClient
     {
     {
         httpClient = new HttpClient();
         httpClient = new HttpClient();
         httpClient.BaseAddress = new Uri(baseUrl);
         httpClient.BaseAddress = new Uri(baseUrl);
-        // TODO: Update expiration date locally
     }
     }
 
 
     public async Task<Guid?> GenerateSession(string email)
     public async Task<Guid?> GenerateSession(string email)
@@ -50,10 +49,9 @@ public class PixiAuthClient
         return null;
         return null;
     }
     }
 
 
-    public async Task<(string? token, DateTime? expirationDate)> TryClaimSessionToken(string email, Guid session)
+    public async Task<(string? token, DateTime? expirationDate)> TryClaimSessionToken(Guid session)
     {
     {
-        Dictionary<string, string> body = new() { { "email", email }, { "sessionId", session.ToString() } };
-        var response = await httpClient.GetAsync($"/session/claimToken?userEmail={email}&sessionId={session}");
+        var response = await httpClient.GetAsync($"/session/claimToken?sessionId={session}");
 
 
         if (response.IsSuccessStatusCode)
         if (response.IsSuccessStatusCode)
         {
         {
@@ -92,20 +90,19 @@ public class PixiAuthClient
     ///     /// Refreshes the session token.
     ///     /// Refreshes the session token.
     /// </summary>
     /// </summary>
     /// <param name="userSessionId">Id of the session.</param>
     /// <param name="userSessionId">Id of the session.</param>
-    /// <param name="userSessionToken">Authentication token.</param>
+    /// <param name="sessionToken">Authentication token.</param>
     /// <returns>Token if successful, null otherwise.</returns>
     /// <returns>Token if successful, null otherwise.</returns>
     /// <exception cref="UnauthorizedAccessException">Thrown if the session is not valid.</exception>
     /// <exception cref="UnauthorizedAccessException">Thrown if the session is not valid.</exception>
-    public async Task<(string? token, DateTime? expirationDate)> RefreshToken(Guid userSessionId,
-        string userSessionToken)
+    public async Task<(string? token, DateTime? expirationDate)> RefreshToken(string sessionToken)
     {
     {
-        if (string.IsNullOrEmpty(userSessionToken))
+        if (string.IsNullOrEmpty(sessionToken))
         {
         {
             return (null, null);
             return (null, null);
         }
         }
 
 
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "/session/refreshToken");
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "/session/refreshToken");
-        request.Content = JsonContent.Create(new SessionModel(userSessionId, userSessionToken));
-        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userSessionToken);
+        request.Content = JsonContent.Create(sessionToken); // Name is important here, do not change!
+        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", sessionToken);
 
 
         var response = await httpClient.SendAsync(request);
         var response = await httpClient.SendAsync(request);
 
 
@@ -145,7 +142,7 @@ public class PixiAuthClient
         return (null, null);
         return (null, null);
     }
     }
 
 
-    public async Task Logout(Guid userSessionId, string userSessionToken)
+    public async Task Logout(string userSessionToken)
     {
     {
         if (string.IsNullOrEmpty(userSessionToken))
         if (string.IsNullOrEmpty(userSessionToken))
         {
         {
@@ -153,22 +150,14 @@ public class PixiAuthClient
         }
         }
 
 
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "/session/logout");
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "/session/logout");
-        string sessionId = userSessionId.ToString(); // Name is important here, do not change!
-        request.Content = JsonContent.Create(sessionId);
         request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userSessionToken);
         request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userSessionToken);
 
 
         await httpClient.SendAsync(request);
         await httpClient.SendAsync(request);
     }
     }
 
 
-    public async Task ResendActivation(string userEmail, Guid userSessionId)
+    public async Task ResendActivation(Guid userSessionId)
     {
     {
-        if (string.IsNullOrEmpty(userEmail))
-        {
-            return;
-        }
-
-        var response = await httpClient.PostAsJsonAsync("/session/resendActivation",
-            new ResendActivationModel(userEmail, userSessionId));
+        var response = await httpClient.PostAsJsonAsync("/session/resendActivation", userSessionId);
 
 
         if (response.StatusCode == HttpStatusCode.BadRequest)
         if (response.StatusCode == HttpStatusCode.BadRequest)
         {
         {

+ 0 - 14
src/PixiEditor.PixiAuth/ResendActivationModel.cs

@@ -1,14 +0,0 @@
-namespace PixiEditor.PixiAuth;
-
-public class ResendActivationModel
-{
-    public string Email { get; set; }
-
-    public Guid SessionId { get; set; }
-
-    public ResendActivationModel(string email, Guid sessionId)
-    {
-        Email = email;
-        SessionId = sessionId;
-    }
-}

+ 0 - 13
src/PixiEditor.PixiAuth/SessionModel.cs

@@ -1,13 +0,0 @@
-namespace PixiEditor.PixiAuth;
-
-public class SessionModel
-{
-    public Guid SessionId { get; set; }
-    public string SessionToken { get; set; } = string.Empty;
-
-    public SessionModel(Guid sessionId, string sessionToken)
-    {
-        SessionId = sessionId;
-        SessionToken = sessionToken;
-    }
-}

+ 2 - 6
src/PixiEditor.PixiAuth/User.cs

@@ -3,7 +3,8 @@ namespace PixiEditor.PixiAuth;
 [Serializable]
 [Serializable]
 public class User
 public class User
 {
 {
-    public string Email { get; set; } = string.Empty;
+    public string EmailHash { get; set; } = string.Empty;
+    public string Username { get; set; } = string.Empty;
     public Guid? SessionId { get; set; }
     public Guid? SessionId { get; set; }
     public string? SessionToken { get; set; } = string.Empty;
     public string? SessionToken { get; set; } = string.Empty;
     public DateTime? SessionExpirationDate { get; set; }
     public DateTime? SessionExpirationDate { get; set; }
@@ -12,9 +13,4 @@ public class User
     {
     {
 
 
     }
     }
-
-    public User(string email)
-    {
-        Email = email;
-    }
 }
 }

+ 1 - 1
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -30,7 +30,7 @@
             <Color x:Key="ThemeBorderHighColor">#4F4F4F</Color>
             <Color x:Key="ThemeBorderHighColor">#4F4F4F</Color>
 
 
             <Color x:Key="ErrorColor">#B00020</Color>
             <Color x:Key="ErrorColor">#B00020</Color>
-            <Color x:Key="ErrorOnDarkColor">#FF0000</Color>
+            <Color x:Key="ErrorOnDarkColor">#FF4267</Color>
 
 
             <Color x:Key="GlyphColor">#444</Color>
             <Color x:Key="GlyphColor">#444</Color>
             <Color x:Key="GlyphBackground">White</Color>
             <Color x:Key="GlyphBackground">White</Color>

+ 2 - 0
src/PixiEditor.UI.Common/Controls/Button.axaml

@@ -10,6 +10,8 @@
     <ControlTheme TargetType="Button" x:Key="{x:Type Button}">
     <ControlTheme TargetType="Button" x:Key="{x:Type Button}">
         <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
         <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
         <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
         <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
+        <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}" />
+        <Setter Property="BorderThickness" Value="1" />
         <Setter Property="HorizontalContentAlignment" Value="Center" />
         <Setter Property="HorizontalContentAlignment" Value="Center" />
         <Setter Property="VerticalContentAlignment" Value="Center" />
         <Setter Property="VerticalContentAlignment" Value="Center" />
         <Setter Property="FontSize" Value="15" />
         <Setter Property="FontSize" Value="15" />

+ 1 - 0
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -185,6 +185,7 @@
         <Setter Property="Padding" Value="0" />
         <Setter Property="Padding" Value="0" />
         <Setter Property="HorizontalContentAlignment" Value="Center" />
         <Setter Property="HorizontalContentAlignment" Value="Center" />
         <Setter Property="VerticalContentAlignment" Value="Center" />
         <Setter Property="VerticalContentAlignment" Value="Center" />
+        <Setter Property="BorderThickness" Value="0" />
     </Style>
     </Style>
 
 
     <Style Selector="Button.pixi-icon:pointerover">
     <Style Selector="Button.pixi-icon:pointerover">

+ 10 - 4
src/PixiEditor/Data/Localization/Languages/en.json

@@ -1026,7 +1026,7 @@
   "GRAPH_STATE_UNABLE_TO_CREATE_MEMBER": "Current Node Graph setup disallows creation of a new layer next to the selected one.",
   "GRAPH_STATE_UNABLE_TO_CREATE_MEMBER": "Current Node Graph setup disallows creation of a new layer next to the selected one.",
   "PRIMARY_TOOLSET": "Primary Toolset",
   "PRIMARY_TOOLSET": "Primary Toolset",
   "OPEN_ONBOARDING_WINDOW": "Open onboarding window",
   "OPEN_ONBOARDING_WINDOW": "Open onboarding window",
-  "USER_NOT_FOUND": "User not found",
+  "USER_NOT_FOUND": "Please enter the email you used to purchase the Founder's Edition.",
   "SESSION_NOT_VALID": "Session is not valid, please log in again",
   "SESSION_NOT_VALID": "Session is not valid, please log in again",
   "SESSION_NOT_FOUND": "Session not found, try logging in again",
   "SESSION_NOT_FOUND": "Session not found, try logging in again",
   "INTERNAL_SERVER_ERROR": "There was an internal server error. Please try again later.",
   "INTERNAL_SERVER_ERROR": "There was an internal server error. Please try again later.",
@@ -1035,7 +1035,13 @@
   "CONNECTION_ERROR": "Connection error. Please check your internet connection.",
   "CONNECTION_ERROR": "Connection error. Please check your internet connection.",
   "FAIL_LOAD_USER_DATA": "Failed to load saved user data",
   "FAIL_LOAD_USER_DATA": "Failed to load saved user data",
   "LOGOUT": "Logout",
   "LOGOUT": "Logout",
-  "LOGIN": "Login",
-  "LOGGED_IN_AS": "Logged in as",
-  "AVATAR_INFO": "Avatar taken from Gravatar, click to go to gravatar.com"
+  "LOGGED_IN_AS": "Hello",
+  "AVATAR_INFO": "Profile information taken from Gravatar, click to go to gravatar.com",
+  "EMAIL_SENT": "Email sent! Check your inbox.",
+  "RESEND_ACTIVATION": "Resend",
+  "INVALID_TOKEN": "Session is invalid or expired. Please log in again.",
+  "ENTER_EMAIL": "Enter your email",
+  "LOGIN_LINK": "Send Login Link",
+  "LOGIN_LINK_INFO": "We'll email you a secure link to log in. No password needed.",
+  "LOGIN_WINDOW_TITLE": "Founder's Account"
 }
 }

+ 2 - 1
src/PixiEditor/Data/Localization/Languages/pl.json

@@ -560,5 +560,6 @@
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Ctrl i przeciągnij aby przemieścić uchwyt swobodnie. Przytrzymaj Shify aby skalować proporcjonalnie. Przytrzymaj Alt i przeciągnij boczny uchwyt aby ściąć. Porusz zewnętrzne uchwyty aby obracać.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Ctrl i przeciągnij aby przemieścić uchwyt swobodnie. Przytrzymaj Shify aby skalować proporcjonalnie. Przytrzymaj Alt i przeciągnij boczny uchwyt aby ściąć. Porusz zewnętrzne uchwyty aby obracać.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shify aby zrobić to proporcjonalnie. Przytrzymaj Alt i przeciągnij aby ściąć. Przeciągnij zewnętrzne uchwyty aby obracać.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shify aby zrobić to proporcjonalnie. Przytrzymaj Alt i przeciągnij aby ściąć. Przeciągnij zewnętrzne uchwyty aby obracać.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shify aby skalować proporcjonalnie. Przeciągnij zewnętrzne uchwyty aby obracać.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shify aby skalować proporcjonalnie. Przeciągnij zewnętrzne uchwyty aby obracać.",
-  "TRANSFORM_ACTION_DISPLAY_SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shift aby skalować proporcjonalnie."
+  "TRANSFORM_ACTION_DISPLAY_SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE": "Przeciągnij uchwyty aby skalować transformację. Przytrzymaj Shift aby skalować proporcjonalnie.",
+  "ENTER_EMAIL": "Wpisz adres e-mail"
 }
 }

+ 26 - 0
src/PixiEditor/Helpers/Converters/OneTrueConverter.cs

@@ -0,0 +1,26 @@
+using System.Globalization;
+
+namespace PixiEditor.Helpers.Converters;
+
+internal class OneTrueConverter : SingleInstanceMultiValueConverter<OneTrueConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        if (value is IEnumerable<bool> bools)
+        {
+            return bools.Any(x => x);
+        }
+
+        return false;
+    }
+
+    public override object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
+    {
+        if (values.All(x => x is bool b))
+        {
+            return values.Cast<bool>().Any(x => x);
+        }
+
+        return false;
+    }
+}

+ 46 - 0
src/PixiEditor/Models/User/Gravatar.cs

@@ -0,0 +1,46 @@
+using System.Net.Http.Headers;
+
+namespace PixiEditor.Models.User;
+
+public static class Gravatar
+{
+    private static HttpClient httpClient = new HttpClient();
+    private const string GravatarUrl = "https://www.gravatar.com/";
+
+    public static async Task<string?> GetUsername(string emailHash)
+    {
+        var request = new HttpRequestMessage(HttpMethod.Get, $"{GravatarUrl}{emailHash}.json");
+        request.Headers.UserAgent.Add(new ProductInfoHeaderValue("PixiEditor", "2.0"));
+        var response = await httpClient.SendAsync(request);
+
+        if (response.IsSuccessStatusCode)
+        {
+            var content = await response.Content.ReadAsStringAsync();
+            var json = System.Text.Json.JsonDocument.Parse(content);
+            if (json.RootElement.TryGetProperty("entry", out var entry) && entry.ValueKind == System.Text.Json.JsonValueKind.Array)
+            {
+                if (entry.GetArrayLength() > 0)
+                {
+                    return GetUsernameFromJson(entry[0]);
+                }
+            }
+        }
+
+        return null;
+    }
+
+    private static string? GetUsernameFromJson(System.Text.Json.JsonElement entry)
+    {
+        if (entry.TryGetProperty("preferredUsername", out var username))
+        {
+            return username.GetString();
+        }
+
+        if (entry.TryGetProperty("displayName", out var name))
+        {
+            return name.GetString();
+        }
+
+        return null;
+    }
+}

+ 38 - 0
src/PixiEditor/Models/User/UsernameGenerator.cs

@@ -0,0 +1,38 @@
+using System.Text;
+
+namespace PixiEditor.Models.User;
+
+public static class UsernameGenerator
+{
+    private static List<string> adjectives { get; } = new List<string>
+    {
+        "Quick", "Lazy", "Sleepy", "Happy", "Sad", "Angry", "Excited", "Bored",
+        "Curious", "Clever", "Brave", "Shy", "Bold", "Witty", "Charming", "Swift",
+        "Silly", "Wise", "Eager", "Jolly", "Fierce", "Gentle", "Playful", "Mysterious",
+        "Cunning", "Daring", "Lively", "Noble", "Radiant", "Serene", "Vibrant", "Melted"
+    };
+
+    private static List<string> nouns { get; } = new List<string>
+    {
+        "Fox", "Bear", "Wolf", "Eagle", "Lion", "Tiger", "Dragon", "Phoenix",
+        "Shark", "Dolphin", "Whale", "Falcon", "Hawk", "Owl", "Raven", "Sparrow",
+        "Turtle", "Frog", "Lizard", "Snake", "Spider", "Ant", "Bee", "Butterfly",
+        "Potato", "Pixel", "Vector", "Brush", "Artist", "Knight", "Wizard", "Ninja"
+    };
+
+    public static string GenerateUsername(string hash)
+    {
+        Random random = new Random(Encoding.UTF8.GetBytes(hash).Sum(b => b));
+        string adjective = GetRandomElement(adjectives, random);
+        string noun = GetRandomElement(nouns, random);
+
+        return $"{adjective}{noun}";
+    }
+
+    private static string GetRandomElement(List<string> set, Random random)
+    {
+        int index = random.Next(set.Count);
+        string element = set[index];
+        return element;
+    }
+}

+ 96 - 17
src/PixiEditor/ViewModels/SubViewModels/UserViewModel.cs

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.User;
 using PixiEditor.OperatingSystem;
 using PixiEditor.OperatingSystem;
 using PixiEditor.PixiAuth;
 using PixiEditor.PixiAuth;
 using PixiEditor.PixiAuth.Exceptions;
 using PixiEditor.PixiAuth.Exceptions;
@@ -18,12 +19,15 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
     public bool NotLoggedIn => User?.SessionId is null || User.SessionId == Guid.Empty;
     public bool NotLoggedIn => User?.SessionId is null || User.SessionId == Guid.Empty;
     public bool WaitingForActivation => User is { SessionId: not null } && string.IsNullOrEmpty(User.SessionToken);
     public bool WaitingForActivation => User is { SessionId: not null } && string.IsNullOrEmpty(User.SessionToken);
     public bool IsLoggedIn => User is { SessionId: not null } && !string.IsNullOrEmpty(User.SessionToken);
     public bool IsLoggedIn => User is { SessionId: not null } && !string.IsNullOrEmpty(User.SessionToken);
+    public bool EmailEqualsLastSentMail => (CurrentEmail != null ? GetEmailHash(CurrentEmail) : "") == lastSentHash;
 
 
     public AsyncRelayCommand<string> RequestLoginCommand { get; }
     public AsyncRelayCommand<string> RequestLoginCommand { get; }
     public AsyncRelayCommand TryValidateSessionCommand { get; }
     public AsyncRelayCommand TryValidateSessionCommand { get; }
-    public AsyncRelayCommand ResendActivationCommand { get; }
+    public AsyncRelayCommand<string> ResendActivationCommand { get; }
     public AsyncRelayCommand LogoutCommand { get; }
     public AsyncRelayCommand LogoutCommand { get; }
 
 
+    private string lastSentHash = string.Empty;
+
     public LocalizedString? LastError
     public LocalizedString? LastError
     {
     {
         get => lastError;
         get => lastError;
@@ -49,13 +53,26 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
     }
     }
 
 
     public string? UserGravatarUrl =>
     public string? UserGravatarUrl =>
-        User?.Email != null ? $"https://www.gravatar.com/avatar/{GetEmailHash()}?s=100&d=initials" : null;
+        User?.EmailHash != null ? $"https://www.gravatar.com/avatar/{User.EmailHash}?s=100&d=initials" : null;
+
+    private string currentEmail = string.Empty;
+    public string CurrentEmail
+    {
+        get => currentEmail;
+        set
+        {
+            if (SetProperty(ref currentEmail, value))
+            {
+                NotifyProperties();
+            }
+        }
+    }
 
 
     public UserViewModel(ViewModelMain owner) : base(owner)
     public UserViewModel(ViewModelMain owner) : base(owner)
     {
     {
-        RequestLoginCommand = new AsyncRelayCommand<string>(RequestLogin);
+        RequestLoginCommand = new AsyncRelayCommand<string>(RequestLogin, CanRequestLogin);
         TryValidateSessionCommand = new AsyncRelayCommand(TryValidateSession);
         TryValidateSessionCommand = new AsyncRelayCommand(TryValidateSession);
-        ResendActivationCommand = new AsyncRelayCommand(ResendActivation, CanResendActivation);
+        ResendActivationCommand = new AsyncRelayCommand<string>(ResendActivation, CanResendActivation);
         LogoutCommand = new AsyncRelayCommand(Logout);
         LogoutCommand = new AsyncRelayCommand(Logout);
 
 
         string baseUrl = BuildConstants.PixiEditorApiUrl;
         string baseUrl = BuildConstants.PixiEditorApiUrl;
@@ -97,7 +114,8 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
             if (session != null)
             if (session != null)
             {
             {
                 LastError = null;
                 LastError = null;
-                User = new User(email) { SessionId = session.Value };
+                User = new User { SessionId = session.Value, EmailHash = GetEmailHash(email) };
+                lastSentHash = User.EmailHash;
                 NotifyProperties();
                 NotifyProperties();
                 SaveUserInfo();
                 SaveUserInfo();
             }
             }
@@ -112,10 +130,22 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    public async Task ResendActivation()
+    public bool CanRequestLogin(string email)
+    {
+        return !string.IsNullOrEmpty(email) && email.Contains('@');
+    }
+
+    public async Task ResendActivation(string email)
     {
     {
         if (!apiValid) return;
         if (!apiValid) return;
 
 
+        string emailHash = GetEmailHash(email);
+        if (User?.EmailHash != emailHash)
+        {
+            await RequestLogin(email);
+            return;
+        }
+
         if (User?.SessionId == null)
         if (User?.SessionId == null)
         {
         {
             return;
             return;
@@ -123,7 +153,7 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
 
 
         try
         try
         {
         {
-            await PixiAuthClient.ResendActivation(User.Email, User.SessionId.Value);
+            await PixiAuthClient.ResendActivation(User.SessionId.Value);
             TimeToEndTimeout = DateTime.Now.Add(TimeSpan.FromSeconds(60));
             TimeToEndTimeout = DateTime.Now.Add(TimeSpan.FromSeconds(60));
             RunTimeoutTimers(60);
             RunTimeoutTimers(60);
             NotifyProperties();
             NotifyProperties();
@@ -163,8 +193,15 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         }, TimeSpan.FromSeconds(1));
         }, TimeSpan.FromSeconds(1));
     }
     }
 
 
-    public bool CanResendActivation()
+    public bool CanResendActivation(string email)
     {
     {
+        if (email == null || User?.EmailHash == null)
+        {
+            return false;
+        }
+
+        if (User?.EmailHash != GetEmailHash(email)) return true;
+
         return WaitingForActivation && TimeToEndTimeout == null;
         return WaitingForActivation && TimeToEndTimeout == null;
     }
     }
 
 
@@ -179,8 +216,7 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
 
 
         try
         try
         {
         {
-            (string? token, DateTime? expirationDate) =
-                await PixiAuthClient.RefreshToken(User.SessionId.Value, User.SessionToken);
+            (string? token, DateTime? expirationDate) = await PixiAuthClient.RefreshToken(User.SessionToken);
 
 
             if (token != null)
             if (token != null)
             {
             {
@@ -222,12 +258,22 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         try
         try
         {
         {
             (string? token, DateTime? expirationDate) =
             (string? token, DateTime? expirationDate) =
-                await PixiAuthClient.TryClaimSessionToken(User.Email, User.SessionId.Value);
+                await PixiAuthClient.TryClaimSessionToken(User.SessionId.Value);
             if (token != null)
             if (token != null)
             {
             {
                 LastError = null;
                 LastError = null;
                 User.SessionToken = token;
                 User.SessionToken = token;
                 User.SessionExpirationDate = expirationDate;
                 User.SessionExpirationDate = expirationDate;
+                User.Username = GenerateUsername(User.EmailHash);
+                try
+                {
+                    User.Username = await TryFetchUserName(User.EmailHash);
+                }
+                catch
+                {
+                }
+
+                CurrentEmail = null;
                 NotifyProperties();
                 NotifyProperties();
                 SaveUserInfo();
                 SaveUserInfo();
                 return true;
                 return true;
@@ -259,7 +305,6 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
             return;
             return;
         }
         }
 
 
-        Guid? sessionId = User?.SessionId;
         string? sessionToken = User?.SessionToken;
         string? sessionToken = User?.SessionToken;
 
 
         User = null;
         User = null;
@@ -270,15 +315,13 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
 
 
         try
         try
         {
         {
-            await PixiAuthClient.Logout(sessionId.Value, sessionToken);
+            await PixiAuthClient.Logout(sessionToken);
         }
         }
         catch (PixiAuthException authException)
         catch (PixiAuthException authException)
         {
         {
-
         }
         }
         catch (HttpRequestException httpRequestException)
         catch (HttpRequestException httpRequestException)
         {
         {
-
         }
         }
     }
     }
 
 
@@ -299,6 +342,14 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         try
         try
         {
         {
             User = await SecureStorage.GetValueAsync<User>("UserData", null);
             User = await SecureStorage.GetValueAsync<User>("UserData", null);
+            try
+            {
+                User.Username = await TryFetchUserName(User.EmailHash);
+                NotifyProperties();
+            }
+            catch
+            {
+            }
         }
         }
         catch (Exception e)
         catch (Exception e)
         {
         {
@@ -318,14 +369,41 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    private string GetEmailHash()
+    private async Task<string> TryFetchUserName(string emailHash)
+    {
+        try
+        {
+            string? username = await Gravatar.GetUsername(emailHash);
+            if (username != null)
+            {
+                return username;
+            }
+        }
+        catch (PixiAuthException authException)
+        {
+            LastError = new LocalizedString(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            LastError = new LocalizedString("CONNECTION_ERROR");
+        }
+
+        return GenerateUsername(emailHash);
+    }
+
+    private string GetEmailHash(string email)
     {
     {
         using var sha256 = System.Security.Cryptography.SHA256.Create();
         using var sha256 = System.Security.Cryptography.SHA256.Create();
-        byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(User?.Email.ToLower() ?? string.Empty);
+        byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(email.ToLower());
         byte[] hashBytes = sha256.ComputeHash(inputBytes);
         byte[] hashBytes = sha256.ComputeHash(inputBytes);
         return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
         return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
     }
     }
 
 
+    private string GenerateUsername(string emailHash)
+    {
+        return UsernameGenerator.GenerateUsername(emailHash);
+    }
+
     private void NotifyProperties()
     private void NotifyProperties()
     {
     {
         OnPropertyChanged(nameof(User));
         OnPropertyChanged(nameof(User));
@@ -336,6 +414,7 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         OnPropertyChanged(nameof(TimeToEndTimeout));
         OnPropertyChanged(nameof(TimeToEndTimeout));
         OnPropertyChanged(nameof(TimeToEndTimeoutString));
         OnPropertyChanged(nameof(TimeToEndTimeoutString));
         OnPropertyChanged(nameof(UserGravatarUrl));
         OnPropertyChanged(nameof(UserGravatarUrl));
+        OnPropertyChanged(nameof(EmailEqualsLastSentMail));
         ResendActivationCommand.NotifyCanExecuteChanged();
         ResendActivationCommand.NotifyCanExecuteChanged();
     }
     }
 }
 }

+ 80 - 6
src/PixiEditor/Views/Auth/LoginForm.axaml

@@ -3,12 +3,86 @@
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:auth1="clr-namespace:PixiEditor.Views.Auth"
              xmlns:auth1="clr-namespace:PixiEditor.Views.Auth"
+             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+             xmlns:subViewModels="clr-namespace:PixiEditor.ViewModels.SubViewModels"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+             xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Views.Auth.LoginForm">
              x:Class="PixiEditor.Views.Auth.LoginForm">
-    <StackPanel>
-        <TextBox Name="Email" />
-        <Button Name="LoginButton" Content="Login"
-                Command="{Binding RequestLoginCommand, RelativeSource={RelativeSource AncestorType=auth1:LoginForm, Mode=FindAncestor}}"
-                CommandParameter="{Binding ElementName=Email, Path=Text}" />
-    </StackPanel>
+    <Design.DataContext>
+        <subViewModels:UserViewModel />
+    </Design.DataContext>
+
+    <Panel>
+        <StackPanel IsVisible="{Binding !IsLoggedIn}" VerticalAlignment="Top" Spacing="12">
+            <TextBox Text="{Binding CurrentEmail, Mode=TwoWay}" Watermark="{ui:Translate Key=ENTER_EMAIL}" Name="Email"
+                     IsVisible="{Binding !IsLoggedIn}" />
+            <Button Name="LoginButton" ui:Translator.Key="LOGIN_LINK"
+                    Command="{Binding RequestLoginCommand}"
+                    CommandParameter="{Binding ElementName=Email, Path=Text}">
+                <Button.IsVisible>
+                    <MultiBinding Converter="{converters:OneTrueConverter}">
+                        <Binding Path="NotLoggedIn" />
+                        <Binding Path="!EmailEqualsLastSentMail" />
+                    </MultiBinding>
+                </Button.IsVisible>
+            </Button>
+            <Button Command="{Binding Path=ResendActivationCommand}"
+                    CommandParameter="{Binding ElementName=Email, Path=Text}">
+                <Button.IsVisible>
+                    <MultiBinding Converter="{converters:AllTrueConverter}">
+                        <Binding Path="WaitingForActivation" />
+                        <Binding Path="EmailEqualsLastSentMail" />
+                    </MultiBinding>
+                </Button.IsVisible>
+                <TextBlock ui:Translator.Key="RESEND_ACTIVATION">
+                    <TextBlock Text="{Binding TimeToEndTimeoutString}" />
+                </TextBlock>
+            </Button>
+
+            <Panel IsVisible="{Binding LastError, Converter={converters:NullToVisibilityConverter}}">
+                <TextBlock Classes="subtext" Text="{ui:Translate Key=LOGIN_LINK_INFO}"
+                           FontSize="{DynamicResource FontSizeNormal}">
+                    <TextBlock.IsVisible>
+                        <MultiBinding Converter="{converters:OneTrueConverter}">
+                            <Binding Path="NotLoggedIn" />
+                            <Binding Path="!EmailEqualsLastSentMail" />
+                        </MultiBinding>
+                    </TextBlock.IsVisible>
+                </TextBlock>
+                <TextBlock Text="✓" Foreground="{DynamicResource ThemeAccent2Brush}"
+                           FontSize="{DynamicResource FontSizeNormal}">
+                    <TextBlock.IsVisible>
+                        <MultiBinding Converter="{converters:AllTrueConverter}">
+                            <Binding Path="WaitingForActivation" />
+                            <Binding Path="EmailEqualsLastSentMail" />
+                        </MultiBinding>
+                    </TextBlock.IsVisible>
+                    <Run />
+                    <TextBlock ui:Translator.Key="EMAIL_SENT" />
+                </TextBlock>
+            </Panel>
+            <TextBlock Text="✕" IsVisible="{Binding !!LastError}" TextWrapping="Wrap"
+                       Foreground="{DynamicResource ErrorOnDarkBrush}">
+                <Run Text=""/>
+                <Run ui:Translator.LocalizedString="{Binding LastError}" />
+            </TextBlock>
+        </StackPanel>
+        <StackPanel IsVisible="{Binding IsLoggedIn}" Margin="5" Spacing="12" Orientation="Vertical">
+            <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
+                <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand"
+                                 ui:Translator.TooltipKey="AVATAR_INFO">
+                    <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
+                </HyperlinkButton>
+            </Border>
+            <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}"
+                       ui:Translator.Key="LOGGED_IN_AS">
+                <Run Text="" />
+                <Run Text="{Binding User.Username}" />
+            </TextBlock>
+            <Button
+                Content="{ui:Translate Key=LOGOUT}"
+                Command="{Binding LogoutCommand}" />
+        </StackPanel>
+    </Panel>
 </UserControl>
 </UserControl>

+ 0 - 12
src/PixiEditor/Views/Auth/LoginForm.axaml.cs

@@ -1,23 +1,11 @@
-using System.Windows.Input;
-using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
 
 
 namespace PixiEditor.Views.Auth;
 namespace PixiEditor.Views.Auth;
 
 
 public partial class LoginForm : UserControl
 public partial class LoginForm : UserControl
 {
 {
-    public static readonly StyledProperty<ICommand> RequestLoginCommandProperty = AvaloniaProperty.Register<LoginForm, ICommand>(
-        nameof(RequestLoginCommand));
-
-    public ICommand RequestLoginCommand
-    {
-        get => GetValue(RequestLoginCommandProperty);
-        set => SetValue(RequestLoginCommandProperty, value);
-    }
-
     public LoginForm()
     public LoginForm()
     {
     {
         InitializeComponent();
         InitializeComponent();
     }
     }
 }
 }
-

+ 6 - 19
src/PixiEditor/Views/Auth/LoginPopup.axaml

@@ -9,26 +9,13 @@
                          xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                          xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                          mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                          mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                          x:Class="PixiEditor.Views.Auth.LoginPopup"
                          x:Class="PixiEditor.Views.Auth.LoginPopup"
-                         Width="400" Height="300"
-                         Title="LoginPopup">
+                         CanMinimize="False"
+                         CanResize="False"
+                         Width="320" Height="190"
+                         ui:Translator.Key="LOGIN_WINDOW_TITLE">
     <Design.DataContext>
     <Design.DataContext>
         <subViewModels:UserViewModel />
         <subViewModels:UserViewModel />
     </Design.DataContext>
     </Design.DataContext>
-    <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5" Margin="10" MinWidth="300">
-        <auth:LoginForm IsVisible="{Binding !IsLoggedIn}" RequestLoginCommand="{Binding Path=RequestLoginCommand}" />
-        <TextBlock Text="Email sent! Check your inbox." IsVisible="{Binding WaitingForActivation}" />
-        <TextBlock Text="Logged in as " IsVisible="{Binding IsLoggedIn}">
-            <Run Text="{Binding User.Email}" />
-        </TextBlock>
-        <Button IsVisible="{Binding WaitingForActivation}" Width="100"
-                Command="{Binding Path=ResendActivationCommand}" HorizontalAlignment="Right">
-            <TextBlock Text="Resend" Width="100">
-                <TextBlock Text="{Binding TimeToEndTimeoutString}"/>
-            </TextBlock>
-        </Button>
-        <Button IsVisible="{Binding IsLoggedIn}"
-                Command="{Binding Path=LogoutCommand}" Content="Logout" HorizontalAlignment="Right" />
-        <TextBlock ui:Translator.LocalizedString="{Binding LastError}" IsVisible="{Binding !!LastError}"
-                   Foreground="{DynamicResource ErrorBrush}" />
-    </StackPanel>
+
+    <auth:LoginForm Margin="15" DataContext="{Binding}" />
 </dialogs:PixiEditorPopup>
 </dialogs:PixiEditorPopup>

+ 28 - 0
src/PixiEditor/Views/Auth/LoginPopup.axaml.cs

@@ -1,3 +1,4 @@
+using System.ComponentModel;
 using Avalonia;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
 using Avalonia.Input;
 using Avalonia.Input;
@@ -14,6 +15,33 @@ public partial class LoginPopup : PixiEditorPopup
         InitializeComponent();
         InitializeComponent();
     }
     }
 
 
+    protected override void OnDataContextChanged(EventArgs e)
+    {
+        base.OnDataContextChanged(e);
+        if (DataContext is UserViewModel vm)
+        {
+            vm.PropertyChanged += VmOnPropertyChanged;
+            Height = vm.IsLoggedIn ? 245 : 190;
+        }
+    }
+
+    protected override void OnClosed(EventArgs e)
+    {
+        base.OnClosed(e);
+        if (DataContext is UserViewModel vm)
+        {
+            vm.PropertyChanged -= VmOnPropertyChanged;
+        }
+    }
+
+    private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName == nameof(UserViewModel.IsLoggedIn))
+        {
+            Height = (DataContext as UserViewModel)?.IsLoggedIn == true ? 245 : 190;
+        }
+    }
+
     protected override async void OnGotFocus(GotFocusEventArgs e)
     protected override async void OnGotFocus(GotFocusEventArgs e)
     {
     {
         if (DataContext is UserViewModel { WaitingForActivation: true } vm)
         if (DataContext is UserViewModel { WaitingForActivation: true } vm)

+ 9 - 7
src/PixiEditor/Views/Auth/UserAvatarToggle.axaml

@@ -22,25 +22,27 @@
                     <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
                     <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
                 </Button.Content>
                 </Button.Content>
                 <Button.Styles>
                 <Button.Styles>
+                    <Style Selector="FlyoutPresenter">
+                        <Setter Property="Background" Value="{DynamicResource ThemeBackgroundBrush}" />
+                    </Style>
                     <Style Selector="FlyoutPresenter.arrow">
                     <Style Selector="FlyoutPresenter.arrow">
                         <Setter Property="Cursor" Value="Arrow" />
                         <Setter Property="Cursor" Value="Arrow" />
                     </Style>
                     </Style>
                 </Button.Styles>
                 </Button.Styles>
                 <Button.Flyout>
                 <Button.Flyout>
                     <Flyout>
                     <Flyout>
-                        <StackPanel IsVisible="{Binding IsLoggedIn}" Margin="5" Spacing="5" Orientation="Vertical">
+                        <StackPanel IsVisible="{Binding IsLoggedIn}" Margin="5" Spacing="12" Orientation="Vertical">
                             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
                             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
                                 <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand" ui:Translator.TooltipKey="AVATAR_INFO">
                                 <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand" ui:Translator.TooltipKey="AVATAR_INFO">
                                     <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
                                     <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
                                 </HyperlinkButton>
                                 </HyperlinkButton>
                             </Border>
                             </Border>
-                            <TextBlock ui:Translator.Key="LOGGED_IN_AS">
-                                <Run Text=" " />
-                                <Run Text="{Binding User.Email}" />
+                            <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}" ui:Translator.Key="LOGGED_IN_AS">
+                                <Run Text="" />
+                                <Run Text="{Binding User.Username}" />
                             </TextBlock>
                             </TextBlock>
-                            <Button HorizontalAlignment="Right" ui:Translator.TooltipKey="LOGOUT" Classes="pixi-icon"
-                                    Content="{DynamicResource icon-logout}"
-                                    Foreground="{DynamicResource ErrorOnDarkBrush}"
+                            <Button
+                                    Content="{ui:Translate Key=LOGOUT}"
                                     Command="{Binding LogoutCommand}" />
                                     Command="{Binding LogoutCommand}" />
                         </StackPanel>
                         </StackPanel>
                     </Flyout>
                     </Flyout>