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:
Paul Schneider 2026-06-20 21:49:32 +01:00
commit 36e179c494
4 changed files with 196 additions and 61 deletions

View file

@ -41,4 +41,64 @@ public class LoginPageViewModelTests
vm.StatusMessage?.StartsWith("Error") == true,
$"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);
}
}

View file

@ -12,6 +12,49 @@ public partial class LoginPageViewModel : ViewModelBase
public string UserEmail { 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;
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)
{
// 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>

View file

@ -4,11 +4,25 @@
x:Class="PostIt.Views.LoginPage"
x:DataType="vm:LoginPageViewModel"
Header="Login">
<Design.DataContext>
<vm:LoginPageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="20">
<Border IsVisible="{Binding ConfigMissing}"
Background="#FFF3CD"
BorderBrush="#E0A800"
BorderThickness="1"
CornerRadius="4"
Padding="10">
<TextBlock Text="{Binding ConfigMissingMessage}"
TextWrapping="Wrap"
Foreground="#7A5800"/>
</Border>
<TextBlock Text="Sign In"
FontSize="24"
HorizontalAlignment="Center"/>
@ -27,11 +41,19 @@
</StackPanel>
<Button Content="Login"
Click="OnLoginClickAsync"/>
Command="{Binding LoginAsync}"/>
<Button Content="Cancel"
Click="OnCancelClick"/>
<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}" />

View file

@ -1,44 +1,23 @@
using System;
using System.Threading.Tasks;
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Interactivity;
using IdentityModel.OidcClient;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class LoginPage : ContentPage
{
private TaskCompletionSource<LoginResult?> _tcs = new();
private LoginResult loginResult;
public Settings Settings { get; }
public LoginPage()
{
this.Settings = new Settings();
InitializeComponent();
}
private async void OnLoginClickAsync(object? sender, RoutedEventArgs e)
{
// This is where you specify your actual login auth logic
try
{
Settings.Load().Wait();
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();
// HomePage pushes LoginPage via PushModalAsync(new LoginPage())
// without supplying a DataContext. Attach a freshly-built
// LoginPageViewModel whenever the caller hasn't wired one up,
// so XAML bindings and LoginAsyncCommand resolve.
if (DataContext is null)
DataContext = new LoginPageViewModel();
}
private async void OnCancelClick(object? sender, RoutedEventArgs e)
@ -47,4 +26,30 @@ public partial class LoginPage : ContentPage
if (Navigation is not null)
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}");
}
}
}