From 9e272a814768d37ea75b1fc9a12ae75541d8f72e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 7 Jul 2026 20:47:00 +0100 Subject: [PATCH] PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login flow no longer needs a dedicated page. The OIDC interactive login now lives on the persistent SessionStatusBanner, alongside 'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync command. On success the VM raises LoginSucceeded and App.axaml.cs pushes MainPage on top of HomePage — same path BootAsync already takes when the silent refresh succeeds at boot, so the two flows can't drift apart (PushMainPageAsync helper, single source of truth). MainPage no longer shows an editable AuthorId field: the server infers the author from the bearer token, so the client-side control was misleading at best. The detail grid drops from 5 rows to 4. Removed: - Views/LoginPage.axaml + .axaml.cs - ViewModels/LoginPageViewModel.cs - HomePage Login button + OnLoginClick code-behind - DI registrations for LoginPage / LoginPageViewModel - ViewLocator mapping - dangling LoginPage* cref / comments in Platform.cs, PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml, YavscApiClient.cs --- .../PostIt.Android/PlatformBootstrap.cs | 8 +- .../PostIt.Desktop/PlatformBootstrap.cs | 4 +- src/PostIt/PostIt/App.axaml.cs | 31 +- src/PostIt/PostIt/Services/Platform.cs | 4 +- src/PostIt/PostIt/Services/YavscApiClient.cs | 12 +- src/PostIt/PostIt/ViewLocator.cs | 1 - .../PostIt/ViewModels/LoginPageViewModel.cs | 341 ------------------ .../ViewModels/SessionStatusViewModel.cs | 74 +++- src/PostIt/PostIt/Views/HomePage.axaml | 3 - src/PostIt/PostIt/Views/HomePage.axaml.cs | 27 -- src/PostIt/PostIt/Views/LoginPage.axaml | 75 ---- src/PostIt/PostIt/Views/LoginPage.axaml.cs | 66 ---- src/PostIt/PostIt/Views/MainPage.axaml | 5 +- src/PostIt/PostIt/Views/MainWindow.axaml | 6 +- .../PostIt/Views/SessionStatusBanner.axaml | 4 + 15 files changed, 119 insertions(+), 542 deletions(-) delete mode 100644 src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs delete mode 100644 src/PostIt/PostIt/Views/LoginPage.axaml delete mode 100644 src/PostIt/PostIt/Views/LoginPage.axaml.cs 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"/> -