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,
|
||||
$"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 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>
|
||||
|
|
|
|||
|
|
@ -4,36 +4,58 @@
|
|||
x:Class="PostIt.Views.LoginPage"
|
||||
x:DataType="vm:LoginPageViewModel"
|
||||
Header="Login">
|
||||
<Design.DataContext>
|
||||
<vm:LoginPageViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="20">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="20">
|
||||
|
||||
<TextBlock Text="Sign In"
|
||||
FontSize="24"
|
||||
HorizontalAlignment="Center"/>
|
||||
<Border IsVisible="{Binding ConfigMissing}"
|
||||
Background="#FFF3CD"
|
||||
BorderBrush="#E0A800"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="10">
|
||||
<TextBlock Text="{Binding ConfigMissingMessage}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#7A5800"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Email"/>
|
||||
<TextBox Name="EmailBox"
|
||||
PlaceholderText="Enter your email"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Sign In"
|
||||
FontSize="24"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Password"/>
|
||||
<TextBox Name="PasswordBox"
|
||||
PlaceholderText="Enter your password"
|
||||
PasswordChar="•"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Email"/>
|
||||
<TextBox Name="EmailBox"
|
||||
PlaceholderText="Enter your email"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Login"
|
||||
Click="OnLoginClickAsync"/>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Password"/>
|
||||
<TextBox Name="PasswordBox"
|
||||
PlaceholderText="Enter your password"
|
||||
PasswordChar="•"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Cancel"
|
||||
Click="OnCancelClick"/>
|
||||
<Button Content="Login"
|
||||
Command="{Binding LoginAsync}"/>
|
||||
|
||||
<TextBlock Name="StatusText" Text="Starting authentication..." />
|
||||
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
||||
<Button Content="Cancel"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue