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"/> +