postIt #2
15 changed files with 119 additions and 542 deletions
PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
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 <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
commit
9e272a8147
|
|
@ -5,10 +5,10 @@ namespace PostIt.Android;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-shot platform bootstrap. Called from
|
/// One-shot platform bootstrap. Called from
|
||||||
/// <see cref="MainActivity.OnCreate"/> so that the shared
|
/// <see cref="MainActivity.OnCreate"/> so that the shared OIDC login
|
||||||
/// <c>LoginPageViewModel</c> sees the Android-specific redirect URI and a
|
/// path sees the Android-specific redirect URI and a working
|
||||||
/// working <c>IBrowser</c> (Chrome Custom Tabs) without referencing
|
/// <c>IBrowser</c> (Chrome Custom Tabs) without referencing Android
|
||||||
/// Android APIs from the shared library.
|
/// APIs from the shared library.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class PlatformBootstrap
|
internal static class PlatformBootstrap
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ namespace PostIt.Desktop;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
|
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
|
||||||
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c>
|
/// the shared OIDC login path sees a working <c>IBrowser</c> — the
|
||||||
/// — the custom-scheme browser that hands the OIDC callback off to the
|
/// custom-scheme browser that hands the OIDC callback off to the
|
||||||
/// running instance through the named pipe. Desktop builds do NOT use
|
/// running instance through the named pipe. Desktop builds do NOT use
|
||||||
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
|
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
|
||||||
/// with the OS at install time and the browser is whatever the user
|
/// with the OS at install time and the browser is whatever the user
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ public partial class App : Application
|
||||||
|
|
||||||
// Vues
|
// Vues
|
||||||
services.AddTransient<MainPage>();
|
services.AddTransient<MainPage>();
|
||||||
services.AddTransient<LoginPage>();
|
|
||||||
services.AddTransient<SettingsPage>();
|
services.AddTransient<SettingsPage>();
|
||||||
services.AddTransient<HomePage>();
|
services.AddTransient<HomePage>();
|
||||||
services.AddTransient<SignaturePage>();
|
services.AddTransient<SignaturePage>();
|
||||||
|
|
@ -71,7 +70,6 @@ public partial class App : Application
|
||||||
services.AddSingleton(client);
|
services.AddSingleton(client);
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<SettingsPageViewModel>();
|
services.AddTransient<SettingsPageViewModel>();
|
||||||
services.AddTransient<LoginPageViewModel>();
|
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
||||||
|
|
@ -86,10 +84,9 @@ public partial class App : Application
|
||||||
|
|
||||||
// Bind the canonical Settings to the static accessor so any
|
// Bind the canonical Settings to the static accessor so any
|
||||||
// code path that can't easily take a constructor parameter
|
// code path that can't easily take a constructor parameter
|
||||||
// (designer surfaces, Avalonia data templates, the
|
// (designer surfaces, Avalonia data templates) still gets
|
||||||
// LoginPage.axaml.cs fallback) still gets the same instance
|
// the same instance the rest of the app is using. Idempotent:
|
||||||
// the rest of the app is using. Idempotent: re-binding from
|
// re-binding from a second App boot (tests) is a no-op.
|
||||||
// a second App boot (tests) is a no-op.
|
|
||||||
Settings.BindToServiceProvider(provider);
|
Settings.BindToServiceProvider(provider);
|
||||||
|
|
||||||
Services = provider;
|
Services = provider;
|
||||||
|
|
@ -125,6 +122,14 @@ public partial class App : Application
|
||||||
_ = nav.PopToRootAsync();
|
_ = 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);
|
window.Opened += async (_, _) => await BootAsync(provider, api, window);
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||||
|
|
@ -154,10 +159,22 @@ public partial class App : Application
|
||||||
sessionStatus.Refresh();
|
sessionStatus.Refresh();
|
||||||
if (!refreshed) return;
|
if (!refreshed) return;
|
||||||
|
|
||||||
|
await PushMainPageAsync(provider, window).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
|
||||||
|
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
|
||||||
|
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
|
||||||
|
/// (interactive login from the banner). Pulled out as a helper so
|
||||||
|
/// the two callers can't drift apart.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window)
|
||||||
|
{
|
||||||
var mainVm = provider.GetRequiredService<MainPageViewModel>();
|
var mainVm = provider.GetRequiredService<MainPageViewModel>();
|
||||||
var mainPage = provider.GetRequiredService<MainPage>();
|
var mainPage = provider.GetRequiredService<MainPage>();
|
||||||
mainPage.DataContext = mainVm;
|
mainPage.DataContext = mainVm;
|
||||||
await window.NavRoot.PushAsync(mainPage);
|
await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryHandOffCustomSchemeUrl()
|
private bool TryHandOffCustomSchemeUrl()
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ namespace PostIt.Services;
|
||||||
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
|
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
|
||||||
/// not reference any UI framework; platform projects (PostIt.Android,
|
/// not reference any UI framework; platform projects (PostIt.Android,
|
||||||
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
|
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
|
||||||
/// the shared <c>LoginPageViewModel</c> can drive a native browser without
|
/// the shared OIDC login path can drive a native browser without taking
|
||||||
/// taking a hard dependency on any specific UI toolkit.
|
/// a hard dependency on any specific UI toolkit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Platform
|
public static class Platform
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if a non-expired access token (or a refreshable bundle) is
|
/// 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.
|
/// starts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasValidSession
|
public bool HasValidSession
|
||||||
|
|
@ -74,9 +74,9 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The current access token, or null if no session is active.
|
/// The current access token, or null if no session is active.
|
||||||
/// Surfaced so the LoginPageViewModel can mirror it onto its own
|
/// Surfaced so consumers (e.g. <c>HomePage</c>) can mirror it onto
|
||||||
/// observable property (and so the OIDC id_token / claims can be
|
/// their own observable properties and so the OIDC id_token / claims
|
||||||
/// shown in the UI).
|
/// can be shown in the UI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CurrentAccessToken => _tokens?.AccessToken;
|
public string? CurrentAccessToken => _tokens?.AccessToken;
|
||||||
|
|
||||||
|
|
@ -87,9 +87,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
/// <param name="progress">Optional sink for the discrete phases of
|
/// <param name="progress">Optional sink for the discrete phases of
|
||||||
/// the flow; the UI uses this to render a debug-friendly status
|
/// the flow; the UI uses this to render a debug-friendly status
|
||||||
/// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode
|
/// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode
|
||||||
/// → Success / Error). The same caller can also rely on
|
/// → Success / Error).</param>
|
||||||
/// <see cref="LoginPageViewModel.StatusMessage"/> for the human
|
|
||||||
/// text (URLs, error detail).</param>
|
|
||||||
public async Task LoginInteractiveAsync(
|
public async Task LoginInteractiveAsync(
|
||||||
IProgress<OIDCLoginPhase>? progress = null,
|
IProgress<OIDCLoginPhase>? progress = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ public class ViewLocator : IDataTemplate
|
||||||
{
|
{
|
||||||
MainPageViewModel => _services.GetRequiredService<MainPage>(),
|
MainPageViewModel => _services.GetRequiredService<MainPage>(),
|
||||||
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
|
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
|
||||||
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
|
|
||||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||||
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// URL of the Yavsc.Org account-registration page.
|
|
||||||
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
|
|
||||||
/// Empty when the authority is not configured.
|
|
||||||
/// </summary>
|
|
||||||
public string RegisterUrl =>
|
|
||||||
BuildExternalUrl("/Account/Register");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// URL of the Yavsc.Org password-reset page (open to anonymous users).
|
|
||||||
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
|
|
||||||
/// Empty when the authority is not configured.
|
|
||||||
/// </summary>
|
|
||||||
public string ForgotPasswordUrl =>
|
|
||||||
BuildExternalUrl("/Account/ForgotPassword");
|
|
||||||
|
|
||||||
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
|
|
||||||
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Canonical <see cref="Settings.Authentication"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
public string ExternalUrl => BuildExternalUrl(string.Empty);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// OIDC discovery URL the client actually calls during login:
|
|
||||||
/// <c>ExternalUrl + "/.well-known/openid-configuration"</c>. Surfaced in
|
|
||||||
/// <see cref="StatusMessage"/> on failure so the operator can copy it
|
|
||||||
/// verbatim and verify reachability from a browser.
|
|
||||||
/// </summary>
|
|
||||||
public string DiscoveryUrl =>
|
|
||||||
string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the settings file is missing or <c>Authentication.Authority</c>
|
|
||||||
/// is empty. The LoginPage surfaces a banner in that case and disables
|
|
||||||
/// the Register / Forgot password buttons.
|
|
||||||
/// </summary>
|
|
||||||
public bool ConfigMissing =>
|
|
||||||
string.IsNullOrWhiteSpace(Settings.Authentication?.Authority);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Localised banner shown when <see cref="ConfigMissing"/> is true.
|
|
||||||
/// The path follows the XDG spec on Linux (where PostIt.Desktop runs):
|
|
||||||
/// the file is expected at <c>~/.config/PostIt/postit-settings.json</c>.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="TokenStore"/>.
|
|
||||||
/// </summary>
|
|
||||||
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; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private OIDCLoginPhase _phase = OIDCLoginPhase.Idle;
|
|
||||||
public OIDCLoginPhase Phase
|
|
||||||
{
|
|
||||||
get => _phase;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (this.SetProperty(ref _phase, value))
|
|
||||||
OnPropertyChanged(nameof(PhaseLabel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Human-readable label for <see cref="Phase"/>. French to match
|
|
||||||
/// the rest of the UI. Computed once per phase change.
|
|
||||||
/// </summary>
|
|
||||||
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); }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional override used by tests. When set, this factory is called
|
|
||||||
/// instead of <see cref="Platform.CreateBrowser"/> to obtain the
|
|
||||||
/// <see cref="IBrowser"/> instance.
|
|
||||||
/// </summary>
|
|
||||||
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional override used by tests. When set, this delegate replaces
|
|
||||||
/// the call to <see cref="Settings.Load"/> at the start of
|
|
||||||
/// <see cref="LoginAsync"/>, so tests can inject a Settings object
|
|
||||||
/// without it being overwritten by the user/embedded default.
|
|
||||||
/// </summary>
|
|
||||||
public Func<Task>? SettingsLoadOverride { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional override used by tests. When set, the VM hands this
|
|
||||||
/// pre-built <see cref="YavscApiClient"/> to itself instead of
|
|
||||||
/// constructing a fresh one.
|
|
||||||
/// </summary>
|
|
||||||
public YavscApiClient? ApiClientOverride { get; set; }
|
|
||||||
public Action LoginSucceeded { get; internal set; }
|
|
||||||
|
|
||||||
private YavscApiClient? _api;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <c>postit://callback</c> re-launches. Tests that
|
|
||||||
/// don't want the DI bind pass an explicit <c>Settings</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Test-friendly constructor: caller supplies pre-loaded
|
|
||||||
/// <paramref name="settings"/>, an optional
|
|
||||||
/// <paramref name="browserFactoryOverride"/> that bypasses the
|
|
||||||
/// static <see cref="Platform"/> indirection, and an optional
|
|
||||||
/// pre-built <paramref name="apiClient"/> for end-to-end
|
|
||||||
/// scenarios where the test owns the wiring.
|
|
||||||
/// </summary>
|
|
||||||
public LoginPageViewModel(
|
|
||||||
Settings settings,
|
|
||||||
Func<IBrowser?>? 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<OIDCLoginPhase>(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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private async Task LoginInteractiveCoreAsync(
|
|
||||||
YavscApiClient api,
|
|
||||||
IProgress<OIDCLoginPhase>? 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static string SettingsFileHint()
|
|
||||||
{
|
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
|
||||||
return Path.Combine(appData, "PostIt", "postit-settings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the on-disk <see cref="TokenStore"/> used by
|
|
||||||
/// <see cref="YavscApiClient"/>. The token bundle lives in
|
|
||||||
/// <c>~/.config/PostIt/tokens.json</c> on Linux; the same path
|
|
||||||
/// layout is used on every platform for predictability.
|
|
||||||
/// </summary>
|
|
||||||
private static TokenStore BuildTokenStore()
|
|
||||||
{
|
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
|
||||||
var path = Path.Combine(appData, "PostIt", "tokens.json");
|
|
||||||
return new TokenStore(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
|
@ -9,7 +11,11 @@ namespace PostIt.ViewModels;
|
||||||
/// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s
|
/// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s
|
||||||
/// session state ("Connecté" / "Déconnecté") and exposes a
|
/// session state ("Connecté" / "Déconnecté") and exposes a
|
||||||
/// <c>Logout</c> command that purges the token store and asks the
|
/// <c>Logout</c> command that purges the token store and asks the
|
||||||
/// navigation owner to route the user back to <c>HomePage</c>.
|
/// navigation owner to route the user back to <c>HomePage</c>, plus
|
||||||
|
/// a <c>Login</c> command that drives the OIDC interactive flow
|
||||||
|
/// and raises a <see cref="LoginSucceeded"/> event on success so
|
||||||
|
/// <c>MainWindow</c> can push <c>MainPage</c> on top of
|
||||||
|
/// <c>HomePage</c>.
|
||||||
///
|
///
|
||||||
/// Construction is deferred until the API client exists; the
|
/// Construction is deferred until the API client exists; the
|
||||||
/// App.axaml.cs wiring sets <see cref="Api"/> after building both,
|
/// App.axaml.cs wiring sets <see cref="Api"/> after building both,
|
||||||
|
|
@ -21,12 +27,29 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
/// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary>
|
/// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary>
|
||||||
public event System.Action? LogoutCompleted;
|
public event System.Action? LogoutCompleted;
|
||||||
|
|
||||||
|
/// <summary>Raised after <see cref="LoginAsync"/> acquired a valid session;
|
||||||
|
/// <c>App.axaml.cs</c> listens and pushes <c>MainPage</c> on top of
|
||||||
|
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
|
||||||
|
public event System.Action? LoginSucceeded;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsLoggedIn { get; private set; }
|
public partial bool IsLoggedIn { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Inverse of <see cref="IsLoggedIn"/>, for XAML bindings
|
||||||
|
/// (the banner shows the Login button when the user is logged out).
|
||||||
|
/// Updated from <see cref="Refresh"/>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsLoggedOut { get; private set; } = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string SessionLabel { get; private set; } = "Déconnecté";
|
public partial string SessionLabel { get; private set; } = "Déconnecté";
|
||||||
|
|
||||||
|
/// <summary>True while a Login flow is in flight; the Login button
|
||||||
|
/// binds <c>IsEnabled</c> to <c>!IsBusy</c> via
|
||||||
|
/// <see cref="LoginCommand"/>'s <c>CanExecute</c>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; private set; }
|
||||||
|
|
||||||
/// <summary>The API client backing the banner. Set once at startup;
|
/// <summary>The API client backing the banner. Set once at startup;
|
||||||
/// the banner polls <c>HasValidSession</c> on demand rather than
|
/// the banner polls <c>HasValidSession</c> on demand rather than
|
||||||
/// subscribing to a stream — the session state only changes at
|
/// subscribing to a stream — the session state only changes at
|
||||||
|
|
@ -50,9 +73,58 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
var has = Api?.HasValidSession ?? false;
|
var has = Api?.HasValidSession ?? false;
|
||||||
IsLoggedIn = has;
|
IsLoggedIn = has;
|
||||||
|
IsLoggedOut = !has;
|
||||||
SessionLabel = has ? "Connecté" : "Déconnecté";
|
SessionLabel = has ? "Connecté" : "Déconnecté";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Refresh"/> call
|
||||||
|
/// reverts to "Connecté" / "Déconnecté".
|
||||||
|
/// </summary>
|
||||||
|
public void SetError(string message)
|
||||||
|
{
|
||||||
|
IsLoggedIn = false;
|
||||||
|
IsLoggedOut = true;
|
||||||
|
SessionLabel = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drive the OIDC interactive login. On success, refreshes
|
||||||
|
/// the banner state and raises <see cref="LoginSucceeded"/> so
|
||||||
|
/// the navigation owner can push <c>MainPage</c>. On failure,
|
||||||
|
/// surfaces the error in the banner via <see cref="SetError"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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]
|
[RelayCommand]
|
||||||
public async System.Threading.Tasks.Task LogoutAsync()
|
public async System.Threading.Tasks.Task LogoutAsync()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,5 @@
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
<Button Content="Login"
|
|
||||||
Click="OnLoginClick"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.Services;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -13,26 +8,4 @@ public partial class HomePage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLoginClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var vm = (HomePageViewModel)DataContext!;
|
|
||||||
// Resolve the next view-model from the same DI container that
|
|
||||||
// produced vm.Settings. Constructing them with `new` would
|
|
||||||
// instantiate a second Settings and reintroduce the
|
|
||||||
// postit://callback crash we just fixed in Settings.cs.
|
|
||||||
var services = (App.Current as App)?.Services
|
|
||||||
?? throw new System.InvalidOperationException(
|
|
||||||
"App.Services is not bound. OnFrameworkInitializationCompleted must run before any view handler.");
|
|
||||||
var loginVm = services.GetRequiredService<LoginPageViewModel>();
|
|
||||||
loginVm.LoginSucceeded += () =>
|
|
||||||
{
|
|
||||||
var client = services.GetRequiredService<BlogApiClient>();
|
|
||||||
Navigation?.PushAsync(new MainPage
|
|
||||||
{
|
|
||||||
DataContext = services.GetRequiredService<MainPageViewModel>()
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Navigation?.PushAsync(new LoginPage { DataContext = loginVm });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
x:Class="PostIt.Views.LoginPage"
|
|
||||||
x:DataType="vm:LoginPageViewModel"
|
|
||||||
Header="Login">
|
|
||||||
<Design.DataContext>
|
|
||||||
<vm:MainPageViewModel />
|
|
||||||
</Design.DataContext>
|
|
||||||
|
|
||||||
<StackPanel HorizontalAlignment="Stretch"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Spacing="20">
|
|
||||||
|
|
||||||
<Border IsVisible="{Binding ConfigMissing}"
|
|
||||||
Background="#FFF3CD"
|
|
||||||
BorderBrush="#E0A800"
|
|
||||||
BorderThickness="1"
|
|
||||||
CornerRadius="4"
|
|
||||||
Padding="10">
|
|
||||||
<TextBlock Text="{Binding ConfigMissingMessage}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Foreground="#7A5800"/>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<TextBlock Text="Sign In"
|
|
||||||
FontSize="24"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Button Content="Login"
|
|
||||||
Command="{Binding LoginAsync}"/>
|
|
||||||
|
|
||||||
<Button Content="Cancel"
|
|
||||||
Click="OnCancelClick"/>
|
|
||||||
|
|
||||||
<Button Content="Register a new account"
|
|
||||||
IsEnabled="{Binding HasRegisterUrl}"
|
|
||||||
Click="OnRegisterClick"/>
|
|
||||||
|
|
||||||
<Button Content="Forgot password?"
|
|
||||||
IsEnabled="{Binding HasForgotPasswordUrl}"
|
|
||||||
Click="OnForgotPasswordClick"/>
|
|
||||||
|
|
||||||
<TextBox Name="StatusText"
|
|
||||||
Text="{Binding StatusMessage}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
IsReadOnly="True"
|
|
||||||
BorderThickness="0"
|
|
||||||
Background="Transparent"/>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Phase indicator: a single-line label bound to the OIDC flow
|
|
||||||
phase (Discovering / OpeningBrowser / AwaitingCallback / …).
|
|
||||||
Operators use this to debug the postit:// callback hand-off:
|
|
||||||
if AwaitingCallback never advances to ExchangingCode, the OS
|
|
||||||
never re-launched PostIt with the callback URL. Kept as a
|
|
||||||
discrete control (not folded into StatusMessage) so the phase
|
|
||||||
always renders even when StatusMessage is empty or stale.
|
|
||||||
-->
|
|
||||||
<Border Background="#EEF2F7"
|
|
||||||
BorderBrush="#B0BEC5"
|
|
||||||
BorderThickness="1"
|
|
||||||
CornerRadius="4"
|
|
||||||
Padding="6,4">
|
|
||||||
<TextBlock Text="{Binding PhaseLabel}"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
Foreground="#37474F"/>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class LoginPage : ContentPage
|
|
||||||
{
|
|
||||||
public LoginPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
// HomePage pushes LoginPage via PushModalAsync(new LoginPage())
|
|
||||||
// without supplying a DataContext. Attach a freshly-built
|
|
||||||
// LoginPageViewModel whenever the caller hasn't wired one up,
|
|
||||||
// so XAML bindings and LoginAsyncCommand resolve. We resolve
|
|
||||||
// through the DI container (not `new LoginPageViewModel()`)
|
|
||||||
// so the LoginPageViewModel shares the canonical Settings
|
|
||||||
// singleton with the rest of the app — constructing a fresh
|
|
||||||
// VM here was the original source of the two-Settings
|
|
||||||
// postit://callback crash.
|
|
||||||
if (DataContext is null)
|
|
||||||
{
|
|
||||||
var services = (App.Current as App)?.Services
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"App.Services is not bound. OnFrameworkInitializationCompleted must run before any view handler.");
|
|
||||||
DataContext = services.GetRequiredService<LoginPageViewModel>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnCancelClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
// Cancel button dismisses all open modals
|
|
||||||
if (Navigation is not null)
|
|
||||||
await Navigation.PopAllModalsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRegisterClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
OpenExternalUrl((DataContext as LoginPageViewModel)?.RegisterUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnForgotPasswordClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
OpenExternalUrl((DataContext as LoginPageViewModel)?.ForgotPasswordUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OpenExternalUrl(string? url)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(url)) return;
|
|
||||||
// Desktop launcher: shell-execute the URL so the OS picks the right handler.
|
|
||||||
// Platform projects (PostIt.Android, PostIt.Browser) override this behavior
|
|
||||||
// when they plug into the LoginPage lifecycle.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"Failed to open external URL {url}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -80,8 +80,7 @@
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
||||||
<TextBox Grid.Row="1" Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" />
|
<TextBox Grid.Row="1" Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" />
|
||||||
<TextBox Grid.Row="2" Text="{Binding SelectedPost.AuthorId, Mode=TwoWay}" PlaceholderText="Author id" />
|
<AvaloniaEdit:TextEditor Grid.Row="2"
|
||||||
<AvaloniaEdit:TextEditor Grid.Row="3"
|
|
||||||
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
|
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
|
||||||
ShowLineNumbers="True"
|
ShowLineNumbers="True"
|
||||||
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
||||||
|
|
@ -90,7 +89,7 @@
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
HorizontalScrollBarVisibility="Auto" />
|
HorizontalScrollBarVisibility="Auto" />
|
||||||
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" />
|
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@
|
||||||
Root layout: persistent session banner on top, navigation
|
Root layout: persistent session banner on top, navigation
|
||||||
surface below. The banner is the single source of truth for
|
surface below. The banner is the single source of truth for
|
||||||
"Connecté / Déconnecté" and the logout button — visible on
|
"Connecté / Déconnecté" and the logout button — visible on
|
||||||
every page (HomePage, LoginPage, MainPage) so the user never
|
every page (HomePage, MainPage) so the user never has to dig
|
||||||
has to dig through a menu to find their session state.
|
through a menu to find their session state.
|
||||||
|
|
||||||
The navigation stack is built programmatically in
|
The navigation stack is built programmatically in
|
||||||
App.OnFrameworkInitializationCompleted: HomePage is the
|
App.OnFrameworkInitializationCompleted: HomePage is the
|
||||||
root, MainPage is pushed on top when the silent refresh
|
root, MainPage is pushed on top when the silent refresh
|
||||||
succeeds at boot.
|
succeeds at boot or after a fresh login from HomePage.
|
||||||
-->
|
-->
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<views:SessionStatusBanner x:Name="SessionBanner"
|
<views:SessionStatusBanner x:Name="SessionBanner"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@
|
||||||
Command="{Binding LogoutAsync}"
|
Command="{Binding LogoutAsync}"
|
||||||
IsVisible="{Binding IsLoggedIn}"
|
IsVisible="{Binding IsLoggedIn}"
|
||||||
DockPanel.Dock="Right"/>
|
DockPanel.Dock="Right"/>
|
||||||
|
<Button Content="Se connecter"
|
||||||
|
Command="{Binding LoginCommand}"
|
||||||
|
IsVisible="{Binding IsLoggedOut}"
|
||||||
|
DockPanel.Dock="Right"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue