From 4a6609e2f1497c2b3bcabf98c5d8055be7f41ba7 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 8 Jul 2026 19:03:54 +0100 Subject: [PATCH] =?UTF-8?q?PostIt:=20route=20Param=C3=A8tres=20button=20to?= =?UTF-8?q?=20SettingsPage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/PostIt/PostIt/App.axaml.cs | 18 ++- src/PostIt/PostIt/Services/BlogApiClient.cs | 5 + src/PostIt/PostIt/Services/YavscApiClient.cs | 22 ++-- .../PostIt/Settings/AuthenticationSettings.cs | 32 ++++- src/PostIt/PostIt/ViewLocator.cs | 2 +- .../PostIt/ViewModels/MainPageViewModel.cs | 7 +- .../ViewModels/SessionStatusViewModel.cs | 16 +++ .../{Settings => ViewModels}/Settings.cs | 118 ++++++++++-------- .../PostIt/ViewModels/SettingsViewModel.cs | 18 --- .../PostIt/Views/SessionStatusBanner.axaml | 3 + src/PostIt/PostIt/Views/SettingsPage.axaml | 22 +++- 11 files changed, 167 insertions(+), 96 deletions(-) rename src/PostIt/PostIt/{Settings => ViewModels}/Settings.cs (78%) delete mode 100644 src/PostIt/PostIt/ViewModels/SettingsViewModel.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index d9938f72..b474b37b 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -69,7 +69,7 @@ public partial class App : Application services.AddSingleton(api); services.AddSingleton(client); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -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.DataContext = provider.GetRequiredService(); + _ = w.NavRoot.PushAsync(settingsPage); + }; + window.Opened += async (_, _) => await BootAsync(provider, api, window); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs index d489be60..370fd040 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -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; } diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 1fe02e86..b5b9808f 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -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; } diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 4547c732..9ece1e45 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -3,11 +3,41 @@ using System; public partial class AuthenticationSettings : ObservableObject { + /// + /// Default custom-scheme redirect URI on Desktop. The OS routes the + /// callback to the running PostIt instance via the named-pipe + /// hand-off in + /// (RFC 8252 §7.1). Production Desktop builds use this. + /// + public const string DefaultDesktopRedirectUri = "postit://callback"; + /// + /// Redirect URI used by the Android app. The corresponding IntentFilter + /// in PostIt.Android/Properties/AndroidManifest.xml must match. + /// + 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; } -} \ No newline at end of file + + [ObservableProperty] + public partial string[] Scopes { get; set; } + + + /// + /// OAuth redirect URI. Defaults to + /// (custom URI scheme) which is the right answer for desktop + /// production builds. Mobile platforms must set this to + /// before calling LoginAsync. + /// + [ObservableProperty] + public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; + +} diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 983cf13c..e725d0d9 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -25,7 +25,7 @@ public class ViewLocator : IDataTemplate return data switch { MainPageViewModel => _services.GetRequiredService(), - SettingsPageViewModel => _services.GetRequiredService(), + Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index 073ebf7a..74996bd9 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -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 /// public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) { - SettingsModel = new SettingsPageViewModel(); + SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; Init(settings); diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 9902008f..55f2cab4 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -32,6 +32,15 @@ public partial class SessionStatusViewModel : ViewModelBase /// HomePage so the user lands on the blog editor. public event System.Action? LoginSucceeded; + /// Raised when the user clicks the "Paramètres" button on + /// the session banner. App.axaml.cs listens and pushes + /// SettingsPage (resolved from DI, bound to the canonical + /// Settings singleton) on top of the current navigation + /// stack. Same event pattern as and + /// so the VM stays decoupled from + /// NavigationPage / window lifetime. + 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; + } } diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs similarity index 78% rename from src/PostIt/PostIt/Settings/Settings.cs rename to src/PostIt/PostIt/ViewModels/Settings.cs index 3cb40e38..12ed2e02 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -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 /// public const string AndroidRedirectUri = "android://postit-signin"; - /// - /// Default custom-scheme redirect URI on Desktop. The OS routes the - /// callback to the running PostIt instance via the named-pipe - /// hand-off in - /// (RFC 8252 §7.1). Production Desktop builds use this. - /// - public const string DefaultDesktopRedirectUri = "postit://callback"; + /// /// Process-wide canonical 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/"; - - /// - /// OAuth redirect URI. Defaults to - /// (custom URI scheme) which is the right answer for desktop - /// production builds. Mobile platforms must set this to - /// before calling LoginAsync. - /// - [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; /// @@ -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 } } + /// + /// Scopes the PostIt client always requires from the OIDC provider, + /// regardless of what the user has in their settings file. + /// + /// PostIt calls into the Blog API (and any other Yavsc API + /// gated by an [Authorize("…Scope")] 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. blogs) are still their choice — we only force the + /// structural ones that OIDC itself needs. + /// + 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 + }; + + /// + /// 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. null or empty input + /// is fine — we still emit the built-ins. + /// + internal static IEnumerable MergeScopes(string[]? userScopes) + { + var seen = new HashSet(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 } } - /// - /// Marshals every - /// 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 , - /// any HTTP callback) would raise PropertyChanged from a - /// thread-pool thread and Avalonia's binding sink would then reach - /// into DataValidationErrors.SetErrors from off-thread, - /// blowing up with InvalidOperationException: The calling thread - /// cannot access this object because a different thread owns it. - /// 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. - /// - 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(); } } diff --git a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs deleted file mode 100644 index 67223d5f..00000000 --- a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs +++ /dev/null @@ -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(); } -} diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml index 0af0e120..9c6a3f93 100644 --- a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml +++ b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml @@ -21,6 +21,9 @@ Command="{Binding LoginCommand}" IsVisible="{Binding IsLoggedOut}" DockPanel.Dock="Right"/> +