PostIt: route Paramètres button to SettingsPage

The "Paramètres" button on SessionStatusBanner was wired to a stub
OpenSettingsCommand with a TODO. With the Settings model refactor
(VM consolidated to ViewModels/Settings.cs, SettingsViewModel.cs
dropped, App.axaml.cs registering Settings instead of the old VM),
the navigation is now plumbed end to end:

- SessionStatusViewModel gains an OpenSettingsRequested event
  alongside LogoutCompleted / LoginSucceeded, and the
  [RelayCommand] body just raises it. VM stays decoupled from
  NavigationPage and window lifetime, same pattern as the
  existing banner events.
- App.axaml.cs handles the event in the desktop branch: resolves
  SettingsPage (transient) and the canonical Settings singleton
  (the one we Load()'d at startup and bound via
  Settings.BindToServiceProvider) from DI, then PushAsync the
  page on top of the current NavRoot stack. Two-way bindings on
  SettingsPage mutate the singleton in place.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Existing CS8602 / NU1507 / CS8632 warnings unchanged.
This commit is contained in:
Paul Schneider 2026-07-08 19:03:54 +01:00
commit 4a6609e2f1
11 changed files with 166 additions and 95 deletions

View file

@ -69,7 +69,7 @@ public partial class App : Application
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient<MainPageViewModel>();
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<Settings>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
@ -130,6 +130,22 @@ public partial class App : Application
_ = PushMainPageAsync(provider, w);
};
// When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage on top of the
// current navigation stack. Resolved from DI so the
// ViewLocator + service-locator dance stays out of the
// VM, and bound to the same Settings singleton the rest
// of the app is using (the one we Load()'d at startup).
// Two-way bindings on the page mutate that singleton
// in place; callers re-read on next access.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = provider.GetRequiredService<SettingsPage>();
settingsPage.DataContext = provider.GetRequiredService<Settings>();
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(provider, api, window);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)

View file

@ -40,6 +40,11 @@ public sealed class BlogApiClient
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
api.Http.BaseAddress = new Uri(api.Settings.BusinessApiUrl);
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}

View file

@ -8,6 +8,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using PostIt.ViewModels;
namespace PostIt.Services;
@ -29,10 +30,10 @@ public class YavscApiClient : IAsyncDisposable
// network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
private readonly Settings _settings;
public Settings Settings {  get; }
private readonly OidcClient _oidc;
private readonly TokenStore _store;
private readonly HttpClient _http;
public HttpClient Http { get; }
private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1);
@ -40,17 +41,12 @@ public class YavscApiClient : IAsyncDisposable
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
_settings = settings;
Settings = settings;
_store = store;
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
_bearer = new BearerTokenHandler(this);
_http = new HttpClient(_bearer, disposeHandler: true)
{
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
BaseAddress = new Uri(settings.ApiUrl)
};
Http = new HttpClient(_bearer, disposeHandler: true);
_tokens = store.Load();
}
@ -101,7 +97,7 @@ public class YavscApiClient : IAsyncDisposable
throw new InvalidOperationException("No browser is available on this platform.");
}
var client = new OidcClient(_settings.GetOidcClientOptions(browser));
var client = new OidcClient(Settings.GetOidcClientOptions(browser));
// OidcClient.LoginAsync builds the authorize URL, calls
// IBrowser.InvokeAsync (which on desktop hands the user off
@ -245,7 +241,7 @@ public class YavscApiClient : IAsyncDisposable
using var req = new HttpRequestMessage(method, path);
if (body is not null)
req.Content = JsonContent.Create(body);
var response = await _http.SendAsync(req, ct).ConfigureAwait(false);
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
@ -257,7 +253,7 @@ public class YavscApiClient : IAsyncDisposable
using var retry = new HttpRequestMessage(method, path);
if (body is not null)
retry.Content = JsonContent.Create(body);
response = await _http.SendAsync(retry, ct).ConfigureAwait(false);
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
}
return response;
@ -324,7 +320,7 @@ public class YavscApiClient : IAsyncDisposable
public ValueTask DisposeAsync()
{
_http.Dispose();
Http.Dispose();
_refreshGate.Dispose();
return ValueTask.CompletedTask;
}

View file

@ -3,11 +3,41 @@ using System;
public partial class AuthenticationSettings : ObservableObject
{
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit";
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
}
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
}

View file

@ -25,7 +25,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
MainPageViewModel => _services.GetRequiredService<MainPage>(),
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
null => new TextBlock { Text = "No view for <null>" },

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Styling;
@ -19,7 +18,7 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public SettingsPageViewModel SettingsModel { get; }
public Settings SettingsModel { get; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
@ -60,7 +59,7 @@ public partial class MainPageViewModel : ViewModelBase
public MainPageViewModel()
{
Init(null);
SettingsModel = new SettingsPageViewModel();
SettingsModel = new Settings();
BlogClient = null;
}
@ -94,7 +93,7 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{
SettingsModel = new SettingsPageViewModel();
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
Init(settings);

View file

@ -32,6 +32,15 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
@ -133,4 +142,11 @@ public partial class SessionStatusViewModel : ViewModelBase
Refresh();
LogoutCompleted?.Invoke();
}
[RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand()
{
OpenSettingsRequested?.Invoke();
await System.Threading.Tasks.Task.CompletedTask;
}
}

View file

@ -2,17 +2,17 @@ using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")]
namespace PostIt;
namespace PostIt.ViewModels;
public partial class Settings : ObservableObject
public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
@ -36,13 +36,7 @@ public partial class Settings : ObservableObject
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
@ -107,20 +101,11 @@ public partial class Settings : ObservableObject
public partial bool DarkMode { get; set; } = false;
[ObservableProperty]
public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string[] Scopes { get; set; }
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
public bool Loaded { get; private set; } = false;
/// <summary>
@ -154,8 +139,8 @@ public partial class Settings : ObservableObject
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
RedirectUri = RedirectUri,
Scope = string.Join(' ', this.Scopes),
RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
// PKCE is enabled by default when no client_secret is provided.
@ -168,6 +153,48 @@ public partial class Settings : ObservableObject
}
}
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
/// about the contract: a missing scope here surfaces as a 401
/// on the very first API call after login, with no obvious link
/// to the settings. The "feature" scopes the user must opt into
/// (e.g. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
private static readonly string[] BuiltInScopes = new[]
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token
};
/// <summary>
/// Merge user-configured scopes with the built-in ones. User scopes
/// come first (preserves author intent), then the built-ins, with
/// duplicates removed case-sensitively. <c>null</c> or empty input
/// is fine — we still emit the built-ins.
/// </summary>
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
if (userScopes is not null)
{
foreach (var s in userScopes)
{
if (string.IsNullOrWhiteSpace(s)) continue;
if (seen.Add(s)) yield return s;
}
}
foreach (var s in BuiltInScopes)
{
if (seen.Add(s)) yield return s;
}
}
internal void Load()
{
if (Loaded) return;
@ -280,9 +307,17 @@ public partial class Settings : ObservableObject
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.ApiUrl = settings.ApiUrl;
this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri;
this.Scopes = settings.Scopes;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ?
AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority;
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
}
catch (Exception ex)
@ -291,31 +326,6 @@ public partial class Settings : ObservableObject
}
}
/// <summary>
/// Marshals every <see cref="ObservableObject.PropertyChanged"/>
/// notification onto the Avalonia UI thread before it leaves this
/// instance. Without this, a background worker (OIDC discovery
/// running on a Task, the file I/O continuation in <see cref="Load"/>,
/// any HTTP callback) would raise <c>PropertyChanged</c> from a
/// thread-pool thread and Avalonia's binding sink would then reach
/// into <c>DataValidationErrors.SetErrors</c> from off-thread,
/// blowing up with <c>InvalidOperationException: The calling thread
/// cannot access this object because a different thread owns it</c>.
/// We keep the mutation lock separate (above) and let the property
/// setters do their work synchronously — only the notification
/// fan-out is bounced to the UI thread.
/// </summary>
protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
{
if (UiDispatcher.IsOnUiThread)
{
base.OnPropertyChanged(e);
return;
}
// Capture by value: the args object is mutable in some binding
// sinks, and we don't want a background thread to keep mutating
// it after we hand it to the dispatcher.
var snapshot = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName);
UiDispatcher.Post(() => base.OnPropertyChanged(snapshot));
}
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -1,18 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public partial class SettingsPageViewModel : ViewModelBase
{
[ObservableProperty]
public partial bool DarkMode { get; set; }
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -21,6 +21,9 @@
Command="{Binding LoginCommand}"
IsVisible="{Binding IsLoggedOut}"
DockPanel.Dock="Right"/>
<Button Content="Paramètres"
Command="{Binding OpenSettingsCommand}"
DockPanel.Dock="Right"/>
</DockPanel>
</Border>
</UserControl>

View file

@ -4,7 +4,7 @@
xmlns:controls="cl:avalonia.Controls"
x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:SettingsPageViewModel"
x:DataType="vm:Settings"
Width="400"
Height="300">
<Grid>
@ -16,9 +16,23 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Authority"/>
<TextBox Grid.Row="1" x:Name="AuthorityTextBox" Text="{Binding Authority, Mode=TwoWay}"/>
<TextBox Grid.Row="1" x:Name="AuthorityTextBox"
Text="{Binding Authentication.Authority, Mode=TwoWay}"/>
<TextBlock Grid.Row="2" Text="ClientId"/>
<TextBox Grid.Row="3" x:Name="ClientIdTextBox" Text="{Binding ClientId, Mode=TwoWay}"/>
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="6" Text="Business API URL"/>
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Dark mode"/>
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
</Grid>
</ContentPage>
</ContentPage>