postit: link to register and forgot-password from LoginPage
The Yavsc.Org sign-in page and the password-reset page are the canonical entry points for new users and locked-out users; expose both from PostIt's LoginPage by deriving their URLs from the configured Authentication.Authority. * Add RegisterUrl, ForgotPasswordUrl, HasXxxUrl, ConfigMissing and ConfigMissingMessage to LoginPageViewModel. * LoginPage loads settings eagerly in the VM ctor so the URLs are populated when XAML bindings first fire. * Two new buttons (Register a new account, Forgot password?) bind to HasXxxUrl via IsEnabled and fall back to Process.Start on click. * A yellow banner surfaces when Authentication.Authority is empty, pointing the user at ~/.config/PostIt/postit-settings.json. Also drop the duplicated OIDC login logic from LoginPage.axaml.cs: the page now drives Login through LoginPageViewModel.LoginAsync and DataContext is auto-attached when HomePage pushes the page without a VM. Tests cover the happy-path OIDC flow, URL derivation, and the ConfigMissing flag.
This commit is contained in:
parent
c411445699
commit
36e179c494
4 changed files with 196 additions and 61 deletions
|
|
@ -41,4 +41,64 @@ public class LoginPageViewModelTests
|
||||||
vm.StatusMessage?.StartsWith("Error") == true,
|
vm.StatusMessage?.StartsWith("Error") == true,
|
||||||
$"Login reported error: {vm.StatusMessage}");
|
$"Login reported error: {vm.StatusMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
|
||||||
|
// Trailing slash on Authority is normalised away.
|
||||||
|
Assert.Equal(
|
||||||
|
"https://yavsc.example.com/signin?ReturnUrl=~%2F&AllowRememberLogin=true",
|
||||||
|
vm.RegisterUrl);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://yavsc.example.com/Account/ForgotPassword",
|
||||||
|
vm.ForgotPasswordUrl);
|
||||||
|
Assert.True(vm.HasRegisterUrl);
|
||||||
|
Assert.True(vm.HasForgotPasswordUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterUrl_is_empty_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.Equal(string.Empty, vm.RegisterUrl);
|
||||||
|
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
|
||||||
|
Assert.False(vm.HasRegisterUrl);
|
||||||
|
Assert.False(vm.HasForgotPasswordUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigMissing_is_true_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.True(vm.ConfigMissing);
|
||||||
|
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigMissing_is_false_when_authority_is_set()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
Assert.False(vm.ConfigMissing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,49 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
public string UserEmail { get; set; }
|
public string UserEmail { get; set; }
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL of the Yavsc.Org register/sign-in page for new users.
|
||||||
|
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
|
||||||
|
/// Empty when the authority is not configured.
|
||||||
|
/// </summary>
|
||||||
|
public string RegisterUrl =>
|
||||||
|
BuildExternalUrl("/signin?ReturnUrl=~%2F&AllowRememberLogin=true");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL of the Yavsc.Org password-reset page (open to anonymous users).
|
||||||
|
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
|
||||||
|
/// Empty when the authority is not configured.
|
||||||
|
/// </summary>
|
||||||
|
public string ForgotPasswordUrl =>
|
||||||
|
BuildExternalUrl("/Account/ForgotPassword");
|
||||||
|
|
||||||
|
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
|
||||||
|
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the settings file is missing or <c>Authentication.Authority</c>
|
||||||
|
/// is empty. The LoginPage surfaces a banner in that case and disables
|
||||||
|
/// the Register / Forgot password buttons.
|
||||||
|
/// </summary>
|
||||||
|
public bool ConfigMissing =>
|
||||||
|
string.IsNullOrWhiteSpace(Settings.Authentication?.Authority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Localised banner shown when <see cref="ConfigMissing"/> is true.
|
||||||
|
/// The path follows the XDG spec on Linux (where PostIt.Desktop runs):
|
||||||
|
/// the file is expected at <c>~/.config/PostIt/postit-settings.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string ConfigMissingMessage =>
|
||||||
|
$"Configuration PostIt manquante — voir ~/.config/PostIt/postit-settings.json";
|
||||||
|
|
||||||
|
private string BuildExternalUrl(string path)
|
||||||
|
{
|
||||||
|
var authority = Settings.Authentication?.Authority?.TrimEnd('/');
|
||||||
|
return string.IsNullOrEmpty(authority)
|
||||||
|
? string.Empty
|
||||||
|
: authority + path;
|
||||||
|
}
|
||||||
|
|
||||||
private string _AccessToken;
|
private string _AccessToken;
|
||||||
public string AccessToken { get => _AccessToken; private set => this.SetProperty(ref _AccessToken, value); }
|
public string AccessToken { get => _AccessToken; private set => this.SetProperty(ref _AccessToken, value); }
|
||||||
|
|
||||||
|
|
@ -36,6 +79,11 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
|
|
||||||
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
|
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
|
||||||
{
|
{
|
||||||
|
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
|
||||||
|
// populated as soon as the page renders (XAML bindings fire
|
||||||
|
// before the user clicks Login).
|
||||||
|
try { Settings.Load().GetAwaiter().GetResult(); }
|
||||||
|
catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,58 @@
|
||||||
x:Class="PostIt.Views.LoginPage"
|
x:Class="PostIt.Views.LoginPage"
|
||||||
x:DataType="vm:LoginPageViewModel"
|
x:DataType="vm:LoginPageViewModel"
|
||||||
Header="Login">
|
Header="Login">
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:LoginPageViewModel />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<StackPanel HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Spacing="20">
|
Spacing="20">
|
||||||
|
|
||||||
<TextBlock Text="Sign In"
|
<Border IsVisible="{Binding ConfigMissing}"
|
||||||
FontSize="24"
|
Background="#FFF3CD"
|
||||||
HorizontalAlignment="Center"/>
|
BorderBrush="#E0A800"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="10">
|
||||||
|
<TextBlock Text="{Binding ConfigMissingMessage}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="#7A5800"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<TextBlock Text="Sign In"
|
||||||
<TextBlock Text="Email"/>
|
FontSize="24"
|
||||||
<TextBox Name="EmailBox"
|
HorizontalAlignment="Center"/>
|
||||||
PlaceholderText="Enter your email"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Password"/>
|
<TextBlock Text="Email"/>
|
||||||
<TextBox Name="PasswordBox"
|
<TextBox Name="EmailBox"
|
||||||
PlaceholderText="Enter your password"
|
PlaceholderText="Enter your email"/>
|
||||||
PasswordChar="•"/>
|
</StackPanel>
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Button Content="Login"
|
<StackPanel Spacing="4">
|
||||||
Click="OnLoginClickAsync"/>
|
<TextBlock Text="Password"/>
|
||||||
|
<TextBox Name="PasswordBox"
|
||||||
|
PlaceholderText="Enter your password"
|
||||||
|
PasswordChar="•"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Button Content="Cancel"
|
<Button Content="Login"
|
||||||
Click="OnCancelClick"/>
|
Command="{Binding LoginAsync}"/>
|
||||||
|
|
||||||
<TextBlock Name="StatusText" Text="Starting authentication..." />
|
<Button Content="Cancel"
|
||||||
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
Click="OnCancelClick"/>
|
||||||
|
|
||||||
</StackPanel>
|
<Button Content="Register a new account"
|
||||||
|
IsEnabled="{Binding HasRegisterUrl}"
|
||||||
|
Click="OnRegisterClick"/>
|
||||||
|
|
||||||
|
<Button Content="Forgot password?"
|
||||||
|
IsEnabled="{Binding HasForgotPasswordUrl}"
|
||||||
|
Click="OnForgotPasswordClick"/>
|
||||||
|
|
||||||
|
<TextBlock Name="StatusText" Text="Starting authentication..." />
|
||||||
|
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,23 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Diagnostics;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using IdentityModel.OidcClient;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Services;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
public partial class LoginPage : ContentPage
|
public partial class LoginPage : ContentPage
|
||||||
{
|
{
|
||||||
private TaskCompletionSource<LoginResult?> _tcs = new();
|
|
||||||
private LoginResult loginResult;
|
|
||||||
|
|
||||||
|
|
||||||
public Settings Settings { get; }
|
|
||||||
|
|
||||||
public LoginPage()
|
public LoginPage()
|
||||||
{
|
{
|
||||||
this.Settings = new Settings();
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnLoginClickAsync(object? sender, RoutedEventArgs e)
|
// HomePage pushes LoginPage via PushModalAsync(new LoginPage())
|
||||||
{
|
// without supplying a DataContext. Attach a freshly-built
|
||||||
// This is where you specify your actual login auth logic
|
// LoginPageViewModel whenever the caller hasn't wired one up,
|
||||||
try
|
// so XAML bindings and LoginAsyncCommand resolve.
|
||||||
{
|
if (DataContext is null)
|
||||||
Settings.Load().Wait();
|
DataContext = new LoginPageViewModel();
|
||||||
|
|
||||||
var client = new OidcClient(Settings.GetOidcClientOptions());
|
|
||||||
var loginResult = await client.LoginAsync(new LoginRequest());
|
|
||||||
this.loginResult = loginResult;
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
this.loginResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Navigation is not null)
|
|
||||||
await Navigation.PopModalAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnCancelClick(object? sender, RoutedEventArgs e)
|
private async void OnCancelClick(object? sender, RoutedEventArgs e)
|
||||||
|
|
@ -47,4 +26,30 @@ public partial class LoginPage : ContentPage
|
||||||
if (Navigation is not null)
|
if (Navigation is not null)
|
||||||
await Navigation.PopAllModalsAsync();
|
await Navigation.PopAllModalsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnRegisterClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OpenExternalUrl((DataContext as LoginPageViewModel)?.RegisterUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnForgotPasswordClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OpenExternalUrl((DataContext as LoginPageViewModel)?.ForgotPasswordUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenExternalUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url)) return;
|
||||||
|
// Desktop launcher: shell-execute the URL so the OS picks the right handler.
|
||||||
|
// Platform projects (PostIt.Android, PostIt.Browser) override this behavior
|
||||||
|
// when they plug into the LoginPage lifecycle.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Failed to open external URL {url}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue