1
0

feat: Add server choice and login gui.

This commit is contained in:
Ca2didi 2025-03-28 02:37:07 +08:00
parent cdcd1d62ab
commit ec75cf88f3
24 changed files with 397 additions and 123 deletions

View File

@ -5,7 +5,9 @@
<map> <map>
<entry key="Flawless.Client.Avanonia/Views/MainWindow.axaml" value="Flawless.Client.Avanonia/Flawless.Client.Avanonia.csproj" /> <entry key="Flawless.Client.Avanonia/Views/MainWindow.axaml" value="Flawless.Client.Avanonia/Flawless.Client.Avanonia.csproj" />
<entry key="Flawless.Client/App.axaml" value="Flawless.Client/Flawless.Client.csproj" /> <entry key="Flawless.Client/App.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/HelloWindowView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/MainWindow.axaml" value="Flawless.Client/Flawless.Client.csproj" /> <entry key="Flawless.Client/Views/MainWindow.axaml" value="Flawless.Client/Flawless.Client.csproj" />
<entry key="Flawless.Client/Views/MainWindowView.axaml" value="Flawless.Client/Flawless.Client.csproj" />
</map> </map>
</option> </option>
</component> </component>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeveloperToolsToolWindowSettingsV1" lastSelectedContentNodeId="base64-encoder-decoder">
<developerToolsConfigurations />
</component>
</project>

View File

@ -16,6 +16,8 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd991417b721d4ddab50a2b715d0ad696b138_003Fa2_003Fdb3874bc_003FIdentityUser_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd991417b721d4ddab50a2b715d0ad696b138_003Fa2_003Fdb3874bc_003FIdentityUser_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F61447741f88e235f7cd1a276ef5abe648b2dee4b210873893d178b861c9d0_003FJwtBearerHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F61447741f88e235f7cd1a276ef5abe648b2dee4b210873893d178b861c9d0_003FJwtBearerHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa6b7f037ba7b44df80b8d3aa7e58eeb2e8e938_003F28_003F6a41ec86_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa6b7f037ba7b44df80b8d3aa7e58eeb2e8e938_003F28_003F6a41ec86_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReactiveObject_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F404d064a80dc4960b93f90c9bd69770750810_003F5c_003F228dd86b_003FReactiveObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARefitSettings_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa2b3a0b86cd053ed984c171eb448b551b9a2a0c89c5a68191996cffac5d85d2b_003FRefitSettings_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASignInManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F40411c547364428dafc988a7615774e28b910_003F6d_003F1a409232_003FSignInManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASignInManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F40411c547364428dafc988a7615774e28b910_003F6d_003F1a409232_003FSignInManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStringValue_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee39d1e9346e41aa9d44f0e1b1c6630f76268_003F49_003Fb92346b2_003FStringValue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStringValue_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee39d1e9346e41aa9d44f0e1b1c6630f76268_003F49_003Fb92346b2_003FStringValue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>

View File

@ -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<string>($"Bearer {ApiHelper.Token}") : Task.FromResult(string.Empty)
};
var tempGateway = RestService.For<IFlawlessServer>(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<bool> 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;
}
}

View File

@ -1,37 +1,25 @@
using System;
using System.Net.Http;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Flawless.Client.Remote;
using Flawless.Client.ViewModels; using Flawless.Client.ViewModels;
using Flawless.Client.Views; using Flawless.Client.Views;
using Refit;
namespace Flawless.Client; namespace Flawless.Client;
public partial class App : Application public partial class App : Application
{ {
public IFlawlessServer ApiGateway { get; private set; }
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
ApiGateway = RestService.For<IFlawlessServer>(new HttpClient(new AuthHeaderHandler())
{
BaseAddress = new Uri("http://localhost:5256/"),
Timeout = TimeSpan.FromSeconds(60)
});
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindowView
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel()
}; };
} }

View File

@ -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<HttpResponseMessage> 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<TokenInfo>(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<HttpResponseMessage> SendCommandAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Prefill this header
if (AuthenticationHeader != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AuthenticationHeader);
return base.SendAsync(request, cancellationToken);
}
}

View File

@ -27,6 +27,10 @@
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.1" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.2.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageReference Include="ReactiveUI.SourceGenerators" Version="2.1.27">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Refit" Version="8.0.0" /> <PackageReference Include="Refit" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View File

@ -36,6 +36,12 @@ namespace Flawless.Client.Remote
[Post("/api/admin/user/reset_password")] [Post("/api/admin/user/reset_password")]
Task ResetPassword([Body] ResetPasswordRequest body, CancellationToken cancellationToken = default); Task ResetPassword([Body] ResetPasswordRequest body, CancellationToken cancellationToken = default);
/// <returns>OK</returns>
/// <exception cref="ApiException">Thrown when the request returns a non-success status code.</exception>
[Headers("Accept: text/plain, application/json, text/json")]
[Get("/api/auth/status")]
Task<ServerStatusResponse> Status(CancellationToken cancellationToken = default);
/// <returns>A <see cref="Task"/> that completes when the request is finished.</returns> /// <returns>A <see cref="Task"/> that completes when the request is finished.</returns>
/// <exception cref="ApiException">Thrown when the request returns a non-success status code.</exception> /// <exception cref="ApiException">Thrown when the request returns a non-success status code.</exception>
[Post("/api/auth/register")] [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))")] [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")]
public partial class TokenInfo public partial class TokenInfo
{ {
@ -312,6 +327,9 @@ namespace Flawless.Client.Remote
[System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)]
public string Token { get; set; } 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))")] [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")]

View File

@ -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<Unit, Unit> LoginCommand { get; }
public ReactiveCommand<Unit, Unit> RegisterCommand { get; }
public ReactiveCommand<Unit, Unit> 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!");
}
}

View File

@ -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);
}
} }

View File

@ -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<Unit, Unit> 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;
}
}
}

View File

@ -1,7 +1,15 @@
using ReactiveUI; using Avalonia.Controls;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace Flawless.Client.ViewModels; namespace Flawless.Client.ViewModels;
public class ViewModelBase : ReactiveObject public abstract class ViewModelBase : ReactiveObject
{ {
} }
public abstract partial class UserControlViewModelBase : ViewModelBase
{
[Reactive(SetModifier = AccessModifier.Protected)]
private string _title;
}

View File

@ -0,0 +1,27 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Flawless.Client.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="400"
x:Class="Flawless.Client.Views.LoginView"
x:DataType="vm:LoginViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:LoginViewModel/>
</Design.DataContext>
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Center" Spacing="10">
<Image Source="/Assets/avalonia-logo.ico" Margin="20 20" MaxWidth="150"/>
<TextBox Watermark="Username" Text="{Binding Username, Mode=TwoWay}"/>
<TextBox Watermark="Password" PasswordChar="*" Text="{Binding Password, Mode=TwoWay}"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Content="Login" Command="{Binding LoginCommand}"/>
<Button Content="Register" Command="{Binding RegisterCommand}"/>
<Button Content="Choose Server..." Command="{Binding ChooseServerCommand}"/>
</StackPanel>
<Label Content="{Binding Issue}" Foreground="Red"/>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Flawless.Client.Views;
public partial class LoginView : UserControl
{
public LoginView()
{
InitializeComponent();
}
}

View File

@ -1,20 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Flawless.Client.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Flawless.Client.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="Flawless.Client">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Window>

View File

@ -1,26 +0,0 @@
using System;
using System.Threading;
using Avalonia.Controls;
using Flawless.Client.Remote;
namespace Flawless.Client.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Test();
}
private async void Test()
{
var result = await (App.Current as App).ApiGateway.Login(new LoginRequest
{
Username = "cardidi",
Password = "8888"
}, CancellationToken.None);
Console.WriteLine(result);
}
}

View File

@ -0,0 +1,23 @@
<Window x:Class="Flawless.Client.Views.MainWindowView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Flawless.Client.ViewModels"
Title="{Binding CurrentView.Title}"
d:DesignHeight="400" d:DesignWidth="400"
x:CompileBindings="True"
x:DataType="vm:MainWindowViewModel"
CanResize="False"
Width="400" Height="400"
Icon="/Assets/avalonia-logo.ico"
mc:Ignorable="d">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<StackPanel Margin="30">
<TransitioningContentControl Content="{Binding CurrentView}" />
</StackPanel>
</Window>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Flawless.Client.Views;
public partial class MainWindowView : Window
{
public MainWindowView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Flawless.Client.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="400"
x:Class="Flawless.Client.Views.ServerSetupView"
x:DataType="vm:ServerSetupViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:ServerSetupViewModel/>
</Design.DataContext>
<StackPanel Spacing="10" HorizontalAlignment="Stretch" VerticalAlignment="Center">
<Image Source="/Assets/avalonia-logo.ico" Margin="20 20" MaxWidth="150"/>
<TextBox Watermark="Host" Text="{Binding Host, Mode=TwoWay}"/>
<Button Content="Connect" Command="{Binding SetHostCommand}"/>
<Label Content="{Binding Issue}" Foreground="Red"/>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Flawless.Client.Views;
public partial class ServerSetupView : UserControl
{
public ServerSetupView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,6 @@
namespace Flawless.Communication.Response;
public record ServerStatusResponse
{
public required bool AllowPublicRegister { get; set; }
}

View File

@ -3,4 +3,6 @@
public record TokenInfo public record TokenInfo
{ {
public required string Token { get; set; } public required string Token { get; set; }
public DateTime? Expiration { get; set; }
} }

View File

@ -21,6 +21,15 @@ public class AuthenticationController(
ILogger<AuthenticationController> logger) ILogger<AuthenticationController> logger)
: ControllerBase : ControllerBase
{ {
[HttpGet("status")]
public Task<ActionResult<ServerStatusResponse>> GetServerStatusAsync()
{
return Task.FromResult<ActionResult<ServerStatusResponse>>(Ok(new ServerStatusResponse()
{
AllowPublicRegister = true
}));
}
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult> PublicRegisterAsync(RegisterRequest request) public async Task<ActionResult> PublicRegisterAsync(RegisterRequest request)
@ -61,18 +70,19 @@ public class AuthenticationController(
var refreshToken = tokenService.GenerateRefreshToken(); var refreshToken = tokenService.GenerateRefreshToken();
var claims = await GetClaimsAsync(user, refreshToken); var claims = await GetClaimsAsync(user, refreshToken);
var jwtToken = tokenService.GenerateToken(claims); var jwtToken = tokenService.GenerateToken(claims);
var exp = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime);
var refKey = new AppUserRefreshKey var refKey = new AppUserRefreshKey
{ {
UserId = user.Id, UserId = user.Id,
RefreshToken = refreshToken, RefreshToken = refreshToken,
ExpireIn = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime), ExpireIn = exp,
}; };
dbContext.RefreshTokens.Add(refKey); dbContext.RefreshTokens.Add(refKey);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await userManager.AddLoginAsync(user, new UserLoginInfo("login-interface", "", null)); await userManager.AddLoginAsync(user, new UserLoginInfo("login-interface", "", null));
return Ok(new TokenInfo { Token = jwtToken }); return Ok(new TokenInfo { Token = jwtToken, Expiration = exp });
} }
if (result.IsLockedOut) if (result.IsLockedOut)
@ -106,6 +116,7 @@ public class AuthenticationController(
refreshToken = tokenService.GenerateRefreshToken(); refreshToken = tokenService.GenerateRefreshToken();
var claims = await GetClaimsAsync(user, refreshToken); var claims = await GetClaimsAsync(user, refreshToken);
var newJwtToken = tokenService.GenerateToken(claims); var newJwtToken = tokenService.GenerateToken(claims);
var exp = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime);
// Reassign a new key. // Reassign a new key.
set.Remove(tk); set.Remove(tk);
@ -113,10 +124,10 @@ public class AuthenticationController(
{ {
UserId = user.Id, UserId = user.Id,
RefreshToken = refreshToken, RefreshToken = refreshToken,
ExpireIn = DateTime.UtcNow.AddDays(tokenService.RefreshTokenLifeTime), ExpireIn = exp,
}); });
return Ok(new TokenInfo { Token = newJwtToken }); return Ok(new TokenInfo { Token = newJwtToken, Expiration = exp });
} }
finally finally
{ {

View File

@ -6,5 +6,5 @@ felling on deploy and manage.
# Create Interfaces # Create Interfaces
``` ```
refitter http://localhost:5256/swagger/v1/swagger.json --namespace "Flawless.Client.Remote" --multiple-interfaces ByTag --multiple-files --cancellation-tokens --contracts-namespac "Flawless.Communication" --output ".\Flawless.Client\Service\Remote" refitter http://localhost:5256/swagger/v1/swagger.json --namespace "Flawless.Client.Remote" --cancellation-tokens --contracts-namespac "Flawless.Communication" --output ".\Flawless.Client\Service\Remote_Generated.cs"
``` ```