diff --git a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml index 8859eb9..111cdb0 100644 --- a/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml +++ b/.idea/.idea.Flawless-Version-Control/.idea/avalonia.xml @@ -5,7 +5,9 @@ + + diff --git a/.idea/.idea.Flawless-Version-Control/.idea/developer-tools.xml b/.idea/.idea.Flawless-Version-Control/.idea/developer-tools.xml new file mode 100644 index 0000000..5421cc5 --- /dev/null +++ b/.idea/.idea.Flawless-Version-Control/.idea/developer-tools.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Flawless-Version-Control.sln.DotSettings.user b/Flawless-Version-Control.sln.DotSettings.user index 739c965..90d91d6 100644 --- a/Flawless-Version-Control.sln.DotSettings.user +++ b/Flawless-Version-Control.sln.DotSettings.user @@ -16,6 +16,8 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/Flawless.Client/ApiHelper.cs b/Flawless.Client/ApiHelper.cs new file mode 100644 index 0000000..765f383 --- /dev/null +++ b/Flawless.Client/ApiHelper.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using Flawless.Client.Remote; +using Refit; + +namespace Flawless.Client; + +public static class ApiHelper +{ + private static IFlawlessServer? _gateway; + + public static void ClearGateway() + { + Status = null; + ServerUrl = null; + _gateway = null; + Token = null; + } + + public static async Task SetGatewayAsync(string host) + { + var setting = new RefitSettings + { + AuthorizationHeaderValueGetter = (req, ct) => ApiHelper.Token != null ? + Task.FromResult($"Bearer {ApiHelper.Token}") : Task.FromResult(string.Empty) + }; + + var tempGateway = RestService.For(host, setting); + Status = await tempGateway.Status(); + ServerUrl = host; + _gateway = tempGateway; + } + + public static string? ServerUrl { get; private set; } + + public static ServerStatusResponse? Status { get; private set; } + + public static TokenInfo? Token { get; set; } + + public static bool IsGatewayReady => _gateway != null; + public static IFlawlessServer Gateway => _gateway ?? throw new InvalidProgramException("Not set gateway yet!"); + + + public static bool RequireRefreshToken() + { + if (Token == null) return true; + if (DateTime.UtcNow.AddMinutes(1) > Token.Expiration) return true; + return false; + } + + public static async ValueTask TryRefreshTokenAsync() + { + if (Token == null) return false; + try { Token = await Gateway.Refresh(Token); } + catch (Exception e) + { + await Console.Error.WriteLineAsync(e.ToString()); + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/Flawless.Client/App.axaml.cs b/Flawless.Client/App.axaml.cs index 8889aed..89f3605 100644 --- a/Flawless.Client/App.axaml.cs +++ b/Flawless.Client/App.axaml.cs @@ -1,37 +1,25 @@ -using System; -using System.Net.Http; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Flawless.Client.Remote; using Flawless.Client.ViewModels; using Flawless.Client.Views; -using Refit; namespace Flawless.Client; public partial class App : Application { - public IFlawlessServer ApiGateway { get; private set; } - public override void Initialize() { AvaloniaXamlLoader.Load(this); - ApiGateway = RestService.For(new HttpClient(new AuthHeaderHandler()) - { - BaseAddress = new Uri("http://localhost:5256/"), - Timeout = TimeSpan.FromSeconds(60) - }); } public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow + desktop.MainWindow = new MainWindowView { - DataContext = new MainWindowViewModel(), + DataContext = new MainWindowViewModel() }; } diff --git a/Flawless.Client/AuthHeaderHandler.cs b/Flawless.Client/AuthHeaderHandler.cs deleted file mode 100644 index b506429..0000000 --- a/Flawless.Client/AuthHeaderHandler.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Flawless.Client.Remote; - -namespace Flawless.Client; - -public class AuthHeaderHandler : DelegatingHandler -{ - private string? AuthenticationHeader { get; set; } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var response = await SendCommandAsync(request, cancellationToken); - var retryCount = 0; - - while (response.Headers.TryGetValues("Token-Expired", out var expired) && expired.Any(s => s == "true")) - { - if (retryCount++ > 3) - { - AuthenticationHeader = null; - throw new TimeoutException("Too many retries, login info was cleared"); - } - - var refreshRequest = new HttpRequestMessage(HttpMethod.Post, "api/auth/refresh"); - var refresh = await base.SendAsync(refreshRequest, cancellationToken); - if (!response.IsSuccessStatusCode) throw new ApplicationException("Login is expired and require login!"); - - await using var st = await refresh.Content.ReadAsStreamAsync(cancellationToken); - var tk = await JsonSerializer.DeserializeAsync(st, cancellationToken: cancellationToken) - ?? throw new ApplicationException("Not able to refresh token, please login again!"); - - AuthenticationHeader = tk.Token; - response = await SendCommandAsync(request, cancellationToken); - } - - return response; - } - - private Task SendCommandAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Prefill this header - if (AuthenticationHeader != null) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AuthenticationHeader); - - return base.SendAsync(request, cancellationToken); - } -} \ No newline at end of file diff --git a/Flawless.Client/Flawless.Client.csproj b/Flawless.Client/Flawless.Client.csproj index 3609a1b..fdcdf74 100644 --- a/Flawless.Client/Flawless.Client.csproj +++ b/Flawless.Client/Flawless.Client.csproj @@ -27,6 +27,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Flawless.Client/Service/Remote_Generated.cs b/Flawless.Client/Service/Remote_Generated.cs index 67d5579..62ba23b 100644 --- a/Flawless.Client/Service/Remote_Generated.cs +++ b/Flawless.Client/Service/Remote_Generated.cs @@ -36,6 +36,12 @@ namespace Flawless.Client.Remote [Post("/api/admin/user/reset_password")] Task ResetPassword([Body] ResetPasswordRequest body, CancellationToken cancellationToken = default); + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/auth/status")] + Task Status(CancellationToken cancellationToken = default); + /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. [Post("/api/auth/register")] @@ -304,6 +310,15 @@ namespace Flawless.Client.Remote } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ServerStatusResponse + { + + [JsonPropertyName("allowPublicRegister")] + public bool AllowPublicRegister { get; set; } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")] public partial class TokenInfo { @@ -312,6 +327,9 @@ namespace Flawless.Client.Remote [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] public string Token { get; set; } + [JsonPropertyName("expiration")] + public System.DateTimeOffset? Expiration { get; set; } + } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")] diff --git a/Flawless.Client/ViewModels/LoginViewModel.cs b/Flawless.Client/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..c064f5d --- /dev/null +++ b/Flawless.Client/ViewModels/LoginViewModel.cs @@ -0,0 +1,76 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using Flawless.Client.Remote; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using Refit; + +namespace Flawless.Client.ViewModels; + +public partial class LoginViewModel : UserControlViewModelBase +{ + [Reactive] private string _username = String.Empty; + + [Reactive] private string _password = String.Empty; + + [Reactive] private string _issue = String.Empty; + + private MainWindowViewModel _main; + + public ReactiveCommand LoginCommand { get; } + + public ReactiveCommand RegisterCommand { get; } + + public ReactiveCommand ChooseServerCommand { get; } + + public LoginViewModel(MainWindowViewModel main) + { + _main = main; + Title = $"Login into '{ApiHelper.ServerUrl}'"; + + var canLogin = this.WhenAnyValue( + x => x.Username, + x => x.Password, + (user, pass) => !string.IsNullOrEmpty(user) && user.Length > 3 && !string.IsNullOrEmpty(pass) && pass.Length >= 6 + ); + + LoginCommand = ReactiveCommand.CreateFromTask(OnLoginAsync, canLogin); + RegisterCommand = ReactiveCommand.Create(OnRegister); + ChooseServerCommand = ReactiveCommand.Create(OnChooseServer); + } + + private void OnChooseServer() + { + _main.RedirectToServerSetup(); + } + + private void OnRegister() + { + throw new NotImplementedException(); + } + + private async Task OnLoginAsync() + { + try + { + ApiHelper.Token = await ApiHelper.Gateway.Login(new LoginRequest + { + Username = _username, + Password = _password + }); + } + catch (ApiException ex) + { + await Console.Error.WriteLineAsync($"Login as '{Username}' Failed: {ex.Content}"); + Issue = ex.Content ?? String.Empty; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Login as '{Username}' Failed: {ex}"); + Issue = ex.Message; + } + + Console.WriteLine($"Login as '{Username}' success!"); + } +} \ No newline at end of file diff --git a/Flawless.Client/ViewModels/MainWindowViewModel.cs b/Flawless.Client/ViewModels/MainWindowViewModel.cs index a8791f5..fc43a40 100644 --- a/Flawless.Client/ViewModels/MainWindowViewModel.cs +++ b/Flawless.Client/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,25 @@ -namespace Flawless.Client.ViewModels; +using ReactiveUI.SourceGenerators; -public class MainWindowViewModel : ViewModelBase +namespace Flawless.Client.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase { - public string Greeting { get; } = "Welcome to Avalonia!"; + [Reactive(SetModifier = AccessModifier.Protected)] + private UserControlViewModelBase _currentView; + + + public MainWindowViewModel() + { + CurrentView = new ServerSetupViewModel(this); + } + + public void RedirectToLogin() + { + CurrentView = new LoginViewModel(this); + } + + public void RedirectToServerSetup() + { + CurrentView = new ServerSetupViewModel(this); + } } \ No newline at end of file diff --git a/Flawless.Client/ViewModels/ServerSetupViewModel.cs b/Flawless.Client/ViewModels/ServerSetupViewModel.cs new file mode 100644 index 0000000..00dafea --- /dev/null +++ b/Flawless.Client/ViewModels/ServerSetupViewModel.cs @@ -0,0 +1,59 @@ +using System; +using System.Reactive; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using Refit; + +namespace Flawless.Client.ViewModels; + +public partial class ServerSetupViewModel : UserControlViewModelBase +{ + [Reactive] private string _host = "http://localhost:5256/"; + + [Reactive] private string? _issue; + + private MainWindowViewModel _main; + + public ReactiveCommand SetHostCommand { get; } + + + private static readonly Regex httpRegex = new Regex( + @"^(?i)https?://(([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}|(\d{1,3}\.){3}\d{1,3})(:\d{1,5})?(/[\w\-\.~%!$&'()*+,;=:@/?#]*)?(?-i)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public ServerSetupViewModel(MainWindowViewModel main) + { + _main = main; + Title = "Connect to a Flawless Server"; + + // Must clear this gateway + if (ApiHelper.IsGatewayReady) ApiHelper.ClearGateway(); + + SetHostCommand = ReactiveCommand.CreateFromTask( + FetchServerDataAsync, + this.WhenAnyValue(x => x.Host, s => !string.IsNullOrWhiteSpace(s))); + } + + private async Task FetchServerDataAsync() + { + try + { + Issue = string.Empty; + await ApiHelper.SetGatewayAsync(Host); + _main.RedirectToLogin(); + } + catch (ApiException ex) + { + await Console.Error.WriteLineAsync("Can not connect to server: " + ex.ToString()); + Issue = ex.Content; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync("Can not connect to server: " + ex.ToString()); + Issue = ex.Message; + } + } + +} \ No newline at end of file diff --git a/Flawless.Client/ViewModels/ViewModelBase.cs b/Flawless.Client/ViewModels/ViewModelBase.cs index 1d53eb3..d95bd26 100644 --- a/Flawless.Client/ViewModels/ViewModelBase.cs +++ b/Flawless.Client/ViewModels/ViewModelBase.cs @@ -1,7 +1,15 @@ -using ReactiveUI; +using Avalonia.Controls; +using ReactiveUI; +using ReactiveUI.SourceGenerators; namespace Flawless.Client.ViewModels; -public class ViewModelBase : ReactiveObject +public abstract class ViewModelBase : ReactiveObject { -} \ No newline at end of file +} + +public abstract partial class UserControlViewModelBase : ViewModelBase +{ + [Reactive(SetModifier = AccessModifier.Protected)] + private string _title; +} diff --git a/Flawless.Client/Views/LoginView.axaml b/Flawless.Client/Views/LoginView.axaml new file mode 100644 index 0000000..e968f9d --- /dev/null +++ b/Flawless.Client/Views/LoginView.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + +