diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index 56a15fb0..0d11035a 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -5,10 +5,10 @@ namespace PostIt.Android; /// /// One-shot platform bootstrap. Called from -/// so that the shared -/// LoginPageViewModel sees the Android-specific redirect URI and a -/// working IBrowser (Chrome Custom Tabs) without referencing -/// Android APIs from the shared library. +/// so that the shared OIDC login +/// path sees the Android-specific redirect URI and a working +/// IBrowser (Chrome Custom Tabs) without referencing Android +/// APIs from the shared library. /// internal static class PlatformBootstrap { diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs index e93480fb..ad283ec1 100644 --- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -5,8 +5,8 @@ namespace PostIt.Desktop; /// /// One-shot platform bootstrap. Called from Program.Main so that -/// the shared LoginPageViewModel sees a working IBrowser -/// — the custom-scheme browser that hands the OIDC callback off to the +/// the shared OIDC login path sees a working IBrowser — the +/// custom-scheme browser that hands the OIDC callback off to the /// running instance through the named pipe. Desktop builds do NOT use /// a loopback HTTP listener: the postit:// scheme is registered /// with the OS at install time and the browser is whatever the user diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 134ae42f..d9938f72 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,6 @@ public partial class App : Application // Vues services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -71,7 +70,6 @@ public partial class App : Application services.AddSingleton(client); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -86,10 +84,9 @@ public partial class App : Application // Bind the canonical Settings to the static accessor so any // code path that can't easily take a constructor parameter - // (designer surfaces, Avalonia data templates, the - // LoginPage.axaml.cs fallback) still gets the same instance - // the rest of the app is using. Idempotent: re-binding from - // a second App boot (tests) is a no-op. + // (designer surfaces, Avalonia data templates) still gets + // the same instance the rest of the app is using. Idempotent: + // re-binding from a second App boot (tests) is a no-op. Settings.BindToServiceProvider(provider); Services = provider; @@ -125,6 +122,14 @@ public partial class App : Application _ = nav.PopToRootAsync(); }; + // When the user signs in interactively (Login button on + // the session banner), push MainPage on top of HomePage. + sessionStatus.LoginSucceeded += () => + { + var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; + _ = PushMainPageAsync(provider, w); + }; + window.Opened += async (_, _) => await BootAsync(provider, api, window); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) @@ -154,10 +159,22 @@ public partial class App : Application sessionStatus.Refresh(); if (!refreshed) return; + await PushMainPageAsync(provider, window).ConfigureAwait(true); + } + + /// + /// Resolve a fresh MainPage + VM from DI and push it on top + /// of the current navigation stack. Used both by + /// (silent refresh at boot) and by SessionStatusViewModel.LoginSucceeded + /// (interactive login from the banner). Pulled out as a helper so + /// the two callers can't drift apart. + /// + private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window) + { var mainVm = provider.GetRequiredService(); var mainPage = provider.GetRequiredService(); mainPage.DataContext = mainVm; - await window.NavRoot.PushAsync(mainPage); + await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true); } private bool TryHandOffCustomSchemeUrl() diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs index 258cd5b5..c867c63c 100644 --- a/src/PostIt/PostIt/Services/Platform.cs +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -7,8 +7,8 @@ namespace PostIt.Services; /// Authorization Code + PKCE flow. The shared PostIt library does /// not reference any UI framework; platform projects (PostIt.Android, /// PostIt.Desktop, PostIt.Browser) populate this class once at startup so -/// the shared LoginPageViewModel can drive a native browser without -/// taking a hard dependency on any specific UI toolkit. +/// the shared OIDC login path can drive a native browser without taking +/// a hard dependency on any specific UI toolkit. /// public static class Platform { diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 0a42773b..1fe02e86 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -57,7 +57,7 @@ public class YavscApiClient : IAsyncDisposable /// /// True if a non-expired access token (or a refreshable bundle) is - /// already in memory. UI uses this to skip the LoginPage on warm + /// already in memory. UI uses this to skip the login flow on warm /// starts. /// public bool HasValidSession @@ -74,9 +74,9 @@ public class YavscApiClient : IAsyncDisposable /// /// The current access token, or null if no session is active. - /// Surfaced so the LoginPageViewModel can mirror it onto its own - /// observable property (and so the OIDC id_token / claims can be - /// shown in the UI). + /// Surfaced so consumers (e.g. HomePage) can mirror it onto + /// their own observable properties and so the OIDC id_token / claims + /// can be shown in the UI. /// public string? CurrentAccessToken => _tokens?.AccessToken; @@ -87,9 +87,7 @@ public class YavscApiClient : IAsyncDisposable /// Optional sink for the discrete phases of /// the flow; the UI uses this to render a debug-friendly status /// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode - /// → Success / Error). The same caller can also rely on - /// for the human - /// text (URLs, error detail). + /// → Success / Error). public async Task LoginInteractiveAsync( IProgress? progress = null, CancellationToken ct = default) diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index e6d0e91a..ba34bf7c 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -27,7 +27,6 @@ public class ViewLocator : IDataTemplate { MainPageViewModel => _services.GetRequiredService(), SettingsPageViewModel => _services.GetRequiredService(), - LoginPageViewModel => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs deleted file mode 100644 index a6ed595d..00000000 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.Input; -using IdentityModel.OidcClient.Browser; -using PostIt.Services; - -namespace PostIt.ViewModels; - -public partial class LoginPageViewModel : ViewModelBase -{ - private const string SettingsFileName = "postit-settings.json"; - - [Obsolete("Password grant is not used; IdentityModel.OidcClient performs PKCE.")] - public string Password { get; set; } = string.Empty; - - [Obsolete("User-entered email is not used; the IdP login UI collects it.")] - public string UserEmail { get; set; } = string.Empty; - - [Obsolete("No local credential persistence in the current build.")] - public bool RememberMe { get; set; } - - /// - /// URL of the Yavsc.Org account-registration page. - /// Derived from 's Authority. - /// Empty when the authority is not configured. - /// - public string RegisterUrl => - BuildExternalUrl("/Account/Register"); - - /// - /// URL of the Yavsc.Org password-reset page (open to anonymous users). - /// Derived from 's Authority. - /// Empty when the authority is not configured. - /// - public string ForgotPasswordUrl => - BuildExternalUrl("/Account/ForgotPassword"); - - public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl); - public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl); - - /// - /// Canonical authority with any trailing - /// slash removed. Used as the base for both the OIDC discovery URL and the - /// human-facing Account URLs (Register / Forgot password). Empty when the - /// authority is not configured. - /// - public string ExternalUrl => BuildExternalUrl(string.Empty); - - /// - /// OIDC discovery URL the client actually calls during login: - /// ExternalUrl + "/.well-known/openid-configuration". Surfaced in - /// on failure so the operator can copy it - /// verbatim and verify reachability from a browser. - /// - public string DiscoveryUrl => - string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration"; - - /// - /// True when the settings file is missing or Authentication.Authority - /// is empty. The LoginPage surfaces a banner in that case and disables - /// the Register / Forgot password buttons. - /// - public bool ConfigMissing => - string.IsNullOrWhiteSpace(Settings.Authentication?.Authority); - - /// - /// Localised banner shown when is true. - /// The path follows the XDG spec on Linux (where PostIt.Desktop runs): - /// the file is expected at ~/.config/PostIt/postit-settings.json. - /// - 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; - } - - /// - /// The access token of the most recent successful login, or null. - /// Kept on the VM so views can show "logged in as …" feedback; the - /// authoritative copy lives in the . - /// - private string? _accessToken; - public string? AccessToken - { - get => _accessToken; - private set => this.SetProperty(ref _accessToken, value); - } - - public override bool CanNavigateNext { get => false; protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); } - - public Settings Settings { get; } - - /// - /// Discrete phase of the OIDC flow the LoginPage is currently - /// showing. Surfaced in the UI as a one-line status (Discovering / - /// OpeningBrowser / AwaitingCallback / ExchangingCode / Success / - /// Error). Operators use this to debug the custom-scheme - /// callback hand-off: when AwaitingCallback never resolves, - /// the OS never re-launched PostIt with the postit:// URL. - /// - private OIDCLoginPhase _phase = OIDCLoginPhase.Idle; - public OIDCLoginPhase Phase - { - get => _phase; - private set - { - if (this.SetProperty(ref _phase, value)) - OnPropertyChanged(nameof(PhaseLabel)); - } - } - - /// - /// Human-readable label for . French to match - /// the rest of the UI. Computed once per phase change. - /// - public string PhaseLabel => _phase switch - { - OIDCLoginPhase.Idle => "En attente", - OIDCLoginPhase.Discovering => "Découverte OIDC…", - OIDCLoginPhase.OpeningBrowser => "Ouverture du navigateur…", - OIDCLoginPhase.AwaitingCallback => "En attente du callback postit://…", - OIDCLoginPhase.ExchangingCode => "Échange du code contre les jetons…", - OIDCLoginPhase.Success => "Connecté", - OIDCLoginPhase.Error => "Erreur", - _ => _phase.ToString(), - }; - - private string _statusMessage = "Ready"; - public string StatusMessage - { - get => _statusMessage; - private set => this.SetProperty(ref _statusMessage, value); - } - - private bool _isBusy; - public bool IsBusy - { - get => _isBusy; - private set => this.SetProperty(ref _isBusy, value); - } - - private bool _LoginSuccess; - public bool LoginSuccess { get => _isBusy; - private set => this.SetProperty(ref _LoginSuccess, value); } - - /// - /// Optional override used by tests. When set, this factory is called - /// instead of to obtain the - /// instance. - /// - public Func? BrowserFactoryOverride { get; set; } - - /// - /// Optional override used by tests. When set, this delegate replaces - /// the call to at the start of - /// , so tests can inject a Settings object - /// without it being overwritten by the user/embedded default. - /// - public Func? SettingsLoadOverride { get; set; } - - /// - /// Optional override used by tests. When set, the VM hands this - /// pre-built to itself instead of - /// constructing a fresh one. - /// - public YavscApiClient? ApiClientOverride { get; set; } - public Action LoginSucceeded { get; internal set; } - - private YavscApiClient? _api; - - /// - /// Designer / Avalonia-data-template fallback. Resolves the - /// canonical Settings singleton through the running App's DI - /// container. Throws when called outside a bound App (e.g. a - /// stray unit test instantiating the VM directly) so we cannot - /// silently end up with a second Settings instance racing the - /// singleton at runtime — that race is the exact bug that - /// crashed postit://callback re-launches. Tests that - /// don't want the DI bind pass an explicit Settings to - /// the parameterised constructor. The cross-thread crash is - /// also fixed at the Settings layer (thread-safe PropertyChanged - /// marshalling) so the duplicate-instance race is now caught - /// loudly instead of corrupting Avalonia state. - /// - public LoginPageViewModel() : this(Settings.RequireCurrent(), apiClient: null, browserFactoryOverride: null) - { - // Load settings eagerly so RegisterUrl / ForgotPasswordUrl are - // populated as soon as the page renders (XAML bindings fire - // before the user clicks Login). Settings.Load is synchronous - // on purpose; calling .GetAwaiter().GetResult() on it would - // deadlock the UI thread on the await inside the file read. - try { Settings.Load(); } - catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ } - } - - /// - /// Test-friendly constructor: caller supplies pre-loaded - /// , an optional - /// that bypasses the - /// static indirection, and an optional - /// pre-built for end-to-end - /// scenarios where the test owns the wiring. - /// - public LoginPageViewModel( - Settings settings, - Func? browserFactoryOverride = null, - YavscApiClient? apiClient = null) - { - Settings = settings; - BrowserFactoryOverride = browserFactoryOverride; - ApiClientOverride = apiClient; - StatusMessage = "Ready"; - } - - [RelayCommand] - public async Task LoginAsync() - { - try - { - IsBusy = true; - LoginSuccess = false; - if (SettingsLoadOverride is not null) - await SettingsLoadOverride().ConfigureAwait(false); - else - Settings.Load(); - - // Guard: refuse to call OidcClient when the authority is - // empty. IdentityModel would otherwise build a bogus - // authorize URL like "http://127.0.0.1:1/" from an empty - // Authority, which the browser refuses with a confusing - // "Cette adresse est interdite"-style message. Tell the - // operator exactly what to fix instead. - if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority)) - { - IsBusy = false; - StatusMessage = - $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority"; - return; - } - - // The platform project picks the right redirect URI and - // browser implementation; we don't reference any UI - // toolkit from here. - Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri) - ? Platform.DefaultRedirectUri - : Settings.RedirectUri; - - // Surface the discovery URL the client is about to call, - // so a failure (DNS, TLS, 404) can be diagnosed by - // pasting the URL straight into a browser. OidcClient - // computes the discovery URL as - // `Authority + /.well-known/openid-configuration`; we - // normalise the trailing slash here so the printed URL is - // exactly what IdentityModel will fetch. - if (!string.IsNullOrEmpty(DiscoveryUrl)) - StatusMessage = $"Discovering {DiscoveryUrl}"; - - // Build (or reuse) the API client. The browser override - // takes precedence: tests want to inject a fake browser - // and the production path uses Platform.CreateBrowser. - _api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore()); - - // Platform.CreateBrowser may still want to be customised - // per-call (e.g. between desktop and android), so route - // the interactive login through a callback that reuses - // BrowserFactoryOverride when present. - // - // The progress sink drives Phase / PhaseLabel; StatusMessage - // keeps the text detail (URLs, error messages). Same - // underlying flow, two views. - var progress = new Progress(p => Phase = p); - await LoginInteractiveCoreAsync(_api, progress); - - IsBusy = false; - AccessToken = _api.CurrentAccessToken; - StatusMessage = "Interactive token acquired."; - LoginSuccess = true; - LoginSucceeded?.Invoke(); - } - catch (Exception ex) - { - IsBusy = false; - var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty; - StatusMessage = $"Error: {ex.Message}{suffix}"; - } - } - - /// - /// Single entry point for the OIDC login: YavscApiClient owns the - /// browser choice, the OidcClient instance, the token persistence - /// and the refresh path. The VM is just a thin coordinator. - /// - private async Task LoginInteractiveCoreAsync( - YavscApiClient api, - IProgress? progress = null) - { - var original = Platform.CreateBrowser; - try - { - if (BrowserFactoryOverride is not null) - Platform.CreateBrowser = BrowserFactoryOverride; - - await api.LoginInteractiveAsync(progress).ConfigureAwait(false); - } - finally - { - Platform.CreateBrowser = original; - } - } - - /// - /// XDG-compliant path to the user settings file. Surfaced in the - /// "Configuration manquante" message so the operator knows exactly - /// which file to edit without having to dig through docs. - /// - private static string SettingsFileHint() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - return Path.Combine(appData, "PostIt", "postit-settings.json"); - } - - /// - /// Build the on-disk used by - /// . The token bundle lives in - /// ~/.config/PostIt/tokens.json on Linux; the same path - /// layout is used on every platform for predictability. - /// - private static TokenStore BuildTokenStore() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var path = Path.Combine(appData, "PostIt", "tokens.json"); - return new TokenStore(path); - } -} diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 0ebe73a9..9902008f 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Services; @@ -9,7 +11,11 @@ namespace PostIt.ViewModels; /// MainWindow.axaml. Mirrors 's /// session state ("Connecté" / "Déconnecté") and exposes a /// Logout command that purges the token store and asks the -/// navigation owner to route the user back to HomePage. +/// navigation owner to route the user back to HomePage, plus +/// a Login command that drives the OIDC interactive flow +/// and raises a event on success so +/// MainWindow can push MainPage on top of +/// HomePage. /// /// Construction is deferred until the API client exists; the /// App.axaml.cs wiring sets after building both, @@ -21,12 +27,29 @@ public partial class SessionStatusViewModel : ViewModelBase /// App.axaml.cs listens and swaps the navigation root. public event System.Action? LogoutCompleted; + /// Raised after acquired a valid session; + /// App.axaml.cs listens and pushes MainPage on top of + /// HomePage so the user lands on the blog editor. + public event System.Action? LoginSucceeded; + [ObservableProperty] public partial bool IsLoggedIn { get; private set; } + /// Inverse of , for XAML bindings + /// (the banner shows the Login button when the user is logged out). + /// Updated from . + [ObservableProperty] + public partial bool IsLoggedOut { get; private set; } = true; + [ObservableProperty] public partial string SessionLabel { get; private set; } = "Déconnecté"; + /// True while a Login flow is in flight; the Login button + /// binds IsEnabled to !IsBusy via + /// 's CanExecute. + [ObservableProperty] + public partial bool IsBusy { get; private set; } + /// The API client backing the banner. Set once at startup; /// the banner polls HasValidSession on demand rather than /// subscribing to a stream — the session state only changes at @@ -50,9 +73,58 @@ public partial class SessionStatusViewModel : ViewModelBase { var has = Api?.HasValidSession ?? false; IsLoggedIn = has; + IsLoggedOut = !has; SessionLabel = has ? "Connecté" : "Déconnecté"; } + /// + /// Override the banner label with an error message. Used when + /// an interactive login attempt fails so the operator sees + /// something on the persistent UI without us needing a + /// dedicated error page. The next call + /// reverts to "Connecté" / "Déconnecté". + /// + public void SetError(string message) + { + IsLoggedIn = false; + IsLoggedOut = true; + SessionLabel = message; + } + + /// + /// Drive the OIDC interactive login. On success, refreshes + /// the banner state and raises so + /// the navigation owner can push MainPage. On failure, + /// surfaces the error in the banner via . + /// + [RelayCommand(CanExecute = nameof(CanLogin))] + public async Task LoginAsync() + { + if (Api is null) return; + IsBusy = true; + try + { + await Api.LoginInteractiveAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + SetError($"Login failed: {ex.Message}"); + return; + } + finally + { + IsBusy = false; + } + + Refresh(); + if (Api.HasValidSession) + LoginSucceeded?.Invoke(); + } + + private bool CanLogin() => !IsBusy; + + partial void OnIsBusyChanged(bool value) => LoginCommand.NotifyCanExecuteChanged(); + [RelayCommand] public async System.Threading.Tasks.Task LogoutAsync() { diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/HomePage.axaml index bb7204d6..82473542 100644 --- a/src/PostIt/PostIt/Views/HomePage.axaml +++ b/src/PostIt/PostIt/Views/HomePage.axaml @@ -9,8 +9,5 @@ FontSize="22" FontWeight="SemiBold" HorizontalAlignment="Center"/> -