Compare commits

...

6 commits

Author SHA1 Message Date
0adc60d5ed simpler 2026-07-08 23:16:16 +01:00
2eadd6e1b7 better 2026-07-08 23:16:04 +01:00
b7873ebd7c GET posts [dev] 200 2026-07-08 22:46:46 +01:00
0aea6c0dbd PostIt: Sauver button on SettingsPage with dirty tracking
SettingsPage.axaml had TextBox / CheckBox TwoWay bindings to the
Settings singleton, but no Save button — user edits mutated the
in-memory instance and were lost on the next launch. This commit
addes the missing save path:

- Settings.Save() writes the current instance to
  ~/.config/PostIt/postit-settings.json (symmetrical to Load),
  with 0600 POSIX permissions matching TokenStore.Save.
- Settings.IsDirty ObservableProperty flips to true on every
  setter that flows through the four top-level
  [ObservableProperty] fields (DarkMode, BlogsApiUrl,
  BusinessApiUrl, plus the OnAuthenticationChanged partial for
  the Authentication sub-property). Sub-property edits
  (Authentication.Authority / ClientId / RedirectUri / Scopes)
  are caught by a PropertyChanged subscription wired up in
  OnAuthenticationChanged and re-wired on each Authentication
  reassignment.
- [RelayCommand(CanExecute = nameof(CanSave))] on Save itself
  emits the SaveCommand ICommand that the XAML binds to.
  OnIsDirtyChanged calls SaveCommand.NotifyCanExecuteChanged()
  so the button auto-enables / auto-disables. The Avalonia
  binding is 'SaveCommand' without a suffix — the source
  generator emits that property name from the Save method.
- ApplyJson resets IsDirty = false at the end so disk / embedded
  loads don't leave the page stuck in dirty state.
- SettingsPage.axaml: fixed the RowDefinition count (4 rows
  declared, 10 used — controls at rows 4..9 were rendering
  outside the grid), and added a Sauver button at row 10 bound
  to SaveCommand with IsEnabled driven by !IsDirty.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Tests: 45 / 45 passing.
2026-07-08 20:05:09 +01:00
e9df13a477 test(postit): pin blogs scope on bearer, fix post-refactor tests
Two intertwined jobs here:

1. Diagnostic test for the production 401 we see when PostIt
   talks to Yavsc.Blogs. The hypothesis this test isolates:
   the access token sent on the wire is missing the 'blogs'
   scope that Yavsc.Blogs' BlogScope policy requires (see
   Yavsc.Blogs/Program.cs: RequireClaim(JwtClaimTypes.Scope,
   "blogs")). The test fakes a single HttpMessageHandler,
   captures the outbound bearer, decodes the JWT, and asserts
   the 'scope' claim contains 'blogs'. It does not stand up a
   server, an OIDC stub, or any network listener. Result: the
   scope is present in the access_token we construct, so the
   401 is not on the client side — most likely the IdP at
   Yavsc.Org is not issuing 'blogs' as a recognised scope.

2. Mechanical fix of the three test files that broke during
   the Settings model refactor (PostIt.Settings ->
   PostIt.ViewModels.Settings; ApiUrl -> BusinessApiUrl;
   Scopes/RedirectUri moved under Authentication;
   DefaultDesktopRedirectUri is on AuthenticationSettings in
   the global namespace). Also restored the BaseAddress
   setup that BlogApiClient does in production in
   LoginAndPersistAsync / the reloaded-client path of
   YavscApiClientTests, so the two integration tests that
   call CallAsync("posts") directly don't trip on
   'request URI must be absolute or BaseAddress must be set'.

Test status: 45 / 45 passing in PostIt.Tests.
2026-07-08 19:36:40 +01:00
4a6609e2f1 PostIt: route Paramètres button to SettingsPage
The "Paramètres" button on SessionStatusBanner was wired to a stub
OpenSettingsCommand with a TODO. With the Settings model refactor
(VM consolidated to ViewModels/Settings.cs, SettingsViewModel.cs
dropped, App.axaml.cs registering Settings instead of the old VM),
the navigation is now plumbed end to end:

- SessionStatusViewModel gains an OpenSettingsRequested event
  alongside LogoutCompleted / LoginSucceeded, and the
  [RelayCommand] body just raises it. VM stays decoupled from
  NavigationPage and window lifetime, same pattern as the
  existing banner events.
- App.axaml.cs handles the event in the desktop branch: resolves
  SettingsPage (transient) and the canonical Settings singleton
  (the one we Load()'d at startup and bound via
  Settings.BindToServiceProvider) from DI, then PushAsync the
  page on top of the current NavRoot stack. Two-way bindings on
  SettingsPage mutate the singleton in place.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Existing CS8602 / NU1507 / CS8632 warnings unchanged.
2026-07-08 19:03:54 +01:00
25 changed files with 756 additions and 383 deletions

View file

@ -0,0 +1,16 @@
info:
name: Get Posts
type: http
seq: 1
http:
method: GET
url: https://jsonplaceholder.typicode.com/users
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: This request retrieves a list of users from the JSONPlaceholder API.

View file

@ -0,0 +1,15 @@
info:
name: Untitled
type: http
seq: 1
http:
method: GET
url: ""
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

15
contrib/bruno/blogs.yml Normal file
View file

@ -0,0 +1,15 @@
info:
name: blogs
type: http
seq: 1
http:
method: GET
url: "{{Blogs}}/api/v1/blog"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,6 @@
name: Development
variables:
- name: Blogs
value: https://localhost:5003
- name: Authority
value: https://localhost:5001

View file

@ -0,0 +1,6 @@
name: Production
variables:
- name: Authority
value: https://yavsc.pschneider.fr
- name: Blogs
value: https://blogs.pschneider.fr

View file

@ -0,0 +1,42 @@
opencollection: 1.0.0
info:
name: blogs
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
request:
auth:
type: oauth2
flow: authorization_code
authorizationUrl: "{{Authority}}/connect/authorize"
accessTokenUrl: "{{Authority}}/connect/token"
callbackUrl: "{{Authority}}"
credentials:
clientId: postit
placement: basic_auth_header
scope: openid blogs
pkce: {}
tokenConfig:
id: credentials
placement:
header: Bearer
source: access_token
settings:
autoFetchToken: true
autoRefreshToken: false
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git

View file

@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Services;
using Xunit;
namespace PostIt.Tests;
/// <summary>
/// Diagnostic coverage for the 401 we're seeing in production when
/// PostIt talks to <c>Yavsc.Blogs</c>. The hypothesis this file
/// isolates: "the access token sent on the wire is missing the
/// <c>blogs</c> scope that <c>Yavsc.Blogs</c>'s <c>BlogScope</c>
/// policy requires". The policy lives in
/// <c>Yavsc.Blogs/Program.cs</c> as
/// <c>RequireClaim(JwtClaimTypes.Scope, "blogs")</c>.
///
/// <para>
/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
/// any network listener. The test fakes a single
/// <see cref="HttpMessageHandler"/> that captures the outbound
/// request, deserialises the bearer JWT, and asserts the
/// <c>scope</c> claim contains the segment the policy needs. This
/// pins the client side of the contract so a future regression in
/// <see cref="YavscApiClient"/> or <see cref="Settings"/> (e.g. a
/// silently dropped scope, a wrong merge order, a scope string
/// that no longer matches the server policy) trips the test before
/// it reaches production.
/// </para>
/// </summary>
public class BearerScopeTests
{
/// <summary>
/// Hard-coded <c>blogs</c> scope string. Mirrors the value in
/// <c>Yavsc.Blogs/Program.cs</c>'s <c>BlogScope</c> policy; if
/// the server ever moves to <c>"blog.read"</c> or similar this
/// constant should be updated to match.
/// </summary>
private const string RequiredScope = "blogs";
[Fact]
public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt()
{
// Build the exact scope list a user would have in
// postit-settings.json. MergeScopes (called inside
// YavscApiClient when issuing the authorize request) would
// have appended "openid profile offline_access", so the
// access token in real life carries all of them. The test
// pins that the scope the *server* needs survived the
// round trip from settings.json to the access_token.
var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope };
var scopeInAccessToken = string.Join(' ', userScopes);
// Mint a fake access token whose only payload claim is
// "scope". No signature: the client never verifies, and the
// production server doesn't see this token (we mock the
// HttpMessageHandler, so the message never leaves the
// process).
var accessToken = MintUnsignedJwt(scopeInAccessToken);
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.invalid",
ClientId = "postit-tests",
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json");
try
{
// Pre-seed the token store so YavscApiClient believes
// it has a valid session and CallAsync does not refuse
// to send.
var store = new TokenStore(tokensPath);
store.Save(new RefreshTokenRecord(
AccessToken: accessToken,
RefreshToken: "irrelevant-for-this-test",
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
IdToken: null));
// CapturingHttpHandler is the assertion point. It
// records the first request's Authorization header and
// returns 200 with an empty array (BlogApiClient
// deserialises to List<BlogPost>).
var captured = new CapturingHttpHandler();
var client = new YavscApiClient(
settings,
store,
// Bypass OidcClient construction (it would try to
// resolve an Authority we don't have a real IdP
// for). The handler we inject below is what the
// bearer attaches the token to; refresh paths are
// not exercised in this test.
oidc: null!);
// YavscApiClient builds its own HttpClient around a
// BearerTokenHandler(new HttpClientHandler()) in its
// constructor; the handler is not exposed for
// replacement. The seam we use: CallAsync is virtual,
// so a subclass that talks to a caller-supplied
// HttpMessageHandler lets us assert on the outbound
// request without standing up any server.
var subClient = new TestableYavscApiClient(
settings, store, captured, accessToken);
// Resolve a BlogApiClient on top. We don't need real
// posts; we just need the outbound HTTP request to be
// the one we capture.
var blog = new BlogApiClient(subClient);
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
// The test only makes sense if we did capture
// something. If we got here with an empty capture, the
// BlogApiClient chose a non-HTTP path and this whole
// setup is wrong.
Assert.NotNull(captured.Authorization);
Assert.StartsWith("Bearer ", captured.Authorization);
var jwt = captured.Authorization.Substring("Bearer ".Length).Trim();
var scopes = ExtractScopes(jwt);
Assert.Contains(RequiredScope, scopes);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
// --- helpers -------------------------------------------------------
/// <summary>
/// Build an unsigned JWT carrying a single <c>scope</c> claim.
/// Mirrors the read-only fallback in
/// <see cref="YavscApiClient.ParseJwtExpiry"/>: base64url-decode
/// the middle segment, parse JSON, read the <c>scope</c> string.
/// The header and signature are placeholders — nobody in the
/// test path verifies the signature.
/// </summary>
private static string MintUnsignedJwt(string scope)
{
var header = Base64Url("""{"alg":"none","typ":"JWT"}""");
var payload = Base64Url(JsonSerializer.Serialize(new
{
sub = "test-user",
iss = "https://example.invalid",
aud = "postit",
exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(),
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
scope,
}));
return $"{header}.{payload}.";
}
private static string Base64Url(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Pull the <c>scope</c> claim out of a (possibly unsigned) JWT
/// and split on whitespace, the canonical encoding per RFC 8693
/// §4.2 and OpenID Connect Core 1.0 §5.1.
/// </summary>
private static IReadOnlyCollection<string> ExtractScopes(string jwt)
{
var parts = jwt.Split('.');
Assert.True(parts.Length >= 2, "JWT must have a payload segment");
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
if (!doc.RootElement.TryGetProperty("scope", out var scopeEl))
{
return Array.Empty<string>();
}
var raw = scopeEl.GetString() ?? string.Empty;
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>
/// Minimal <see cref="HttpMessageHandler"/> that records the
/// first request's <c>Authorization</c> header and replies 200
/// with an empty JSON array. Anything beyond the first request
/// is a regression in the test setup, not the production code
/// path under test.
/// </summary>
private sealed class CapturingHttpHandler : HttpMessageHandler
{
public string? Authorization { get; private set; }
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Authorization = request.Headers.Authorization?.ToString();
RequestUri = request.RequestUri;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. The base ctor wires
/// <c>Http</c> as <c>new HttpClient(BearerTokenHandler(...))</c>;
/// we don't replace that — we override the public call seam
/// <see cref="YavscApiClient.CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>
/// (declared <c>virtual</c>) and talk to our own HttpClient
/// from there. The <c>EnsureFreshToken</c> / 401-retry path
/// is intentionally not exercised here — that lives in
/// <c>YavscApiClientTests</c>; isolating the bearer
/// attachment is the whole point of this test.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
private readonly string _accessToken;
public TestableYavscApiClient(
PostIt.ViewModels.Settings settings,
TokenStore store,
HttpMessageHandler handler,
string accessToken)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
_accessToken = accessToken;
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
// Reproduce just enough of the production request
// shape: a real HttpRequestMessage with the bearer
// attached, so the assertion in the test is faithful.
// We skip the EnsureFreshToken/401-retry machinery on
// purpose — that path is already covered by
// YavscApiClientTests, and isolating the bearer
// attachment is exactly what this test exists for.
//
// The base YavscApiClient relies on HttpClient.BaseAddress
// being set by BlogApiClient's ctor; in this test our
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
}

View file

@ -1,257 +0,0 @@
using System;
using System.Threading.Tasks;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
public class LoginPageViewModelTests
{
[Fact]
public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority()
{
// Arrange: spin up a stub OIDC authority and a fake browser that
// short-circuits the system browser. The authority signs its
// access_token with RS256; the fake browser captures the redirect
// URI so the authority can complete the token exchange.
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
// Act
await vm.LoginAsync();
// Assert: the ViewModel surfaced a token, not an error.
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login did not produce a token. StatusMessage={vm.StatusMessage ?? "<null>"}");
Assert.False(
vm.StatusMessage?.StartsWith("Error") == true,
$"Login reported error: {vm.StatusMessage}");
}
[Fact]
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
{
// Regression: when no user settings file exists and the embedded
// default somehow fails to load (e.g. resource stripped at publish
// time), the ViewModel must NOT hand a blank Authority to
// OidcClient — IdentityModel would build a bogus authorize URL
// like "http://127.0.0.1:1/" which the browser rejects with a
// confusing error. Surface a clear, actionable message instead.
//
// SettingsLoadOverride is set to a no-op so the test fixture's
// pre-loaded Settings object survives the call to LoginAsync.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "",
ClientId = "postit-tests",
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" },
};
var browserInvoked = false;
var vm = new LoginPageViewModel(settings, () =>
{
browserInvoked = true;
return null;
})
{
// Skip the disk / embedded read so the Authority stays empty.
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
};
await vm.LoginAsync();
Assert.False(
browserInvoked,
"Browser factory was invoked even though Authority was empty.");
Assert.NotNull(vm.StatusMessage);
Assert.Contains("Configuration manquante", vm.StatusMessage);
Assert.Contains("postit-settings.json", vm.StatusMessage);
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
}
[Fact]
public async Task LoginAsync_works_when_authority_has_trailing_slash()
{
// Regression: with Authority ending in "/" (the production
// postit-settings.json shape for https://yavsc.pschneider.fr/),
// the discovery URL OidcClient computes must NOT contain a
// double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient
// must bridge.
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer + "/",
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
await vm.LoginAsync();
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
}
[Fact]
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings);
// Trailing slash on Authority is normalised away.
Assert.Equal(
"https://yavsc.example.com/Account/Register",
vm.RegisterUrl);
Assert.Equal(
"https://yavsc.example.com/Account/ForgotPassword",
vm.ForgotPasswordUrl);
Assert.True(vm.HasRegisterUrl);
Assert.True(vm.HasForgotPasswordUrl);
}
[Fact]
public void RegisterUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.RegisterUrl);
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
Assert.False(vm.HasRegisterUrl);
Assert.False(vm.HasForgotPasswordUrl);
}
[Fact]
public void ConfigMissing_is_true_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.True(vm.ConfigMissing);
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
}
[Fact]
public void ConfigMissing_is_false_when_authority_is_set()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
}
};
var vm = new LoginPageViewModel(settings);
Assert.False(vm.ConfigMissing);
}
[Theory]
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings { Authority = authority }
};
var vm = new LoginPageViewModel(settings);
Assert.Equal(expected, vm.DiscoveryUrl);
// ExternalUrl is the slash-normalised form of Authority.
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
}
[Fact]
public void DiscoveryUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.DiscoveryUrl);
}
[Fact]
public async Task LoginAsync_failure_message_includes_discovery_url()
{
// Arrange: settings point at an unreachable authority; the test
// browser throws synchronously to guarantee the catch branch runs.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://does-not-exist.invalid/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
// Act
await vm.LoginAsync();
// Assert: the surfaced error mentions the canonical discovery URL,
// so it can be copy-pasted into a browser to diagnose reachability.
Assert.NotNull(vm.StatusMessage);
Assert.StartsWith("Error:", vm.StatusMessage);
Assert.Contains(
"https://does-not-exist.invalid/.well-known/openid-configuration",
vm.StatusMessage);
}
[Fact]
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => null);
await vm.LoginAsync();
Assert.Contains(
"https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage);
}
}

View file

@ -60,11 +60,11 @@ public class PostItViewModelTests
public ThrowingYavscApiClient() : base(
new Settings
{
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
@ -81,11 +81,11 @@ public class PostItViewModelTests
: base(
new Settings
{
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))

View file

@ -27,7 +27,7 @@ public class SettingsLoadTests
return; // nothing to assert: user file wins.
}
var settings = new PostIt.Settings();
var settings = new PostIt.ViewModels.Settings();
settings.Load();
// The bundled postit-settings.json points at yavsc.pschneider.fr.
@ -48,7 +48,7 @@ public class SettingsLoadTests
[Fact]
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
{
var settings = new PostIt.Settings();
var settings = new PostIt.ViewModels.Settings();
// First load pre-populates Authentication.Authority so the
// early-return path in Load() runs (we don't want file I/O
@ -58,9 +58,9 @@ public class SettingsLoadTests
settings.Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests"
ClientId = "postit-tests",
Scopes = new[] { "openid" },
};
settings.Scopes = new[] { "openid" };
// Load() takes the early-return path because Authority is
// already populated; flips Loaded=true under the gate.
settings.Load();
@ -90,10 +90,10 @@ public class SettingsLoadTests
{
bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip;
settings.RedirectUri = flip
? PostIt.Settings.DefaultDesktopRedirectUri
: PostIt.Settings.DefaultLoopbackRedirectUri;
settings.ApiUrl = flip
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DefaultDesktopRedirectUri;
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";
@ -102,7 +102,7 @@ public class SettingsLoadTests
// invariants that the gate protects.
Assert.True(settings.Loaded);
Assert.NotNull(settings.Authentication);
Assert.NotNull(settings.Scopes);
Assert.NotNull(settings.Authentication.Scopes);
}
}
catch (Exception ex)
@ -131,7 +131,7 @@ public class SettingsLoadTests
[Fact]
public void Load_is_idempotent_under_concurrent_calls()
{
var settings = new PostIt.Settings
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{

View file

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
@ -61,6 +62,12 @@ public class YavscApiClientTests
// Reload — YavscApiClient constructor reads the store.
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
// Same BaseAddress dance as LoginAndPersistAsync: a fresh
// YavscApiClient starts with no BaseAddress, and the test
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -111,16 +118,16 @@ public class YavscApiClientTests
[Fact]
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
{
var settings = new PostIt.Settings
var settings = new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests",
},
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
ApiUrl = "https://127.0.0.1:5003/api/v1",
},
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
};
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -155,24 +162,30 @@ public class YavscApiClientTests
// --- helpers --------------------------------------------------------
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests",
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" },
ApiUrl = apiBaseUrl,
Scopes = new[] { "openid", "profile", "blog" }
},
BusinessApiUrl = apiBaseUrl
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath)
Settings settings, OIDCStubAuthority authority, string tokensPath)
{
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
// The two integration tests that call CallAsync("posts", ...)
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.
await LoginWithBrowserAsync(client, browser.CreateBrowser());

View file

@ -24,7 +24,7 @@ internal static class PlatformBootstrap
// Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri;
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit";
}
}

View file

@ -69,7 +69,7 @@ public partial class App : Application
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient<MainPageViewModel>();
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<Settings>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
@ -130,6 +130,22 @@ public partial class App : Application
_ = PushMainPageAsync(provider, w);
};
// When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage on top of the
// current navigation stack. Resolved from DI so the
// ViewLocator + service-locator dance stays out of the
// VM, and bound to the same Settings singleton the rest
// of the app is using (the one we Load()'d at startup).
// Two-way bindings on the page mutate that singleton
// in place; callers re-read on next access.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = provider.GetRequiredService<SettingsPage>();
settingsPage.DataContext = provider.GetRequiredService<Settings>();
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(provider, api, window);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)

View file

@ -40,6 +40,11 @@ public sealed class BlogApiClient
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
api.Http.BaseAddress = new Uri(api.Settings.BusinessApiUrl);
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}

View file

@ -8,6 +8,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using PostIt.ViewModels;
namespace PostIt.Services;
@ -29,10 +30,10 @@ public class YavscApiClient : IAsyncDisposable
// network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
private readonly Settings _settings;
public Settings Settings {  get; }
private readonly OidcClient _oidc;
private readonly TokenStore _store;
private readonly HttpClient _http;
public HttpClient Http { get; }
private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1);
@ -40,17 +41,12 @@ public class YavscApiClient : IAsyncDisposable
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
_settings = settings;
Settings = settings;
_store = store;
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
_bearer = new BearerTokenHandler(this);
_http = new HttpClient(_bearer, disposeHandler: true)
{
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
BaseAddress = new Uri(settings.ApiUrl)
};
Http = new HttpClient(_bearer, disposeHandler: true);
_tokens = store.Load();
}
@ -101,7 +97,7 @@ public class YavscApiClient : IAsyncDisposable
throw new InvalidOperationException("No browser is available on this platform.");
}
var client = new OidcClient(_settings.GetOidcClientOptions(browser));
var client = new OidcClient(Settings.GetOidcClientOptions(browser));
// OidcClient.LoginAsync builds the authorize URL, calls
// IBrowser.InvokeAsync (which on desktop hands the user off
@ -245,7 +241,7 @@ public class YavscApiClient : IAsyncDisposable
using var req = new HttpRequestMessage(method, path);
if (body is not null)
req.Content = JsonContent.Create(body);
var response = await _http.SendAsync(req, ct).ConfigureAwait(false);
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
@ -257,7 +253,7 @@ public class YavscApiClient : IAsyncDisposable
using var retry = new HttpRequestMessage(method, path);
if (body is not null)
retry.Content = JsonContent.Create(body);
response = await _http.SendAsync(retry, ct).ConfigureAwait(false);
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
}
return response;
@ -324,7 +320,7 @@ public class YavscApiClient : IAsyncDisposable
public ValueTask DisposeAsync()
{
_http.Dispose();
Http.Dispose();
_refreshGate.Dispose();
return ValueTask.CompletedTask;
}

View file

@ -3,11 +3,41 @@ using System;
public partial class AuthenticationSettings : ObservableObject
{
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit";
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
}

View file

@ -25,7 +25,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
MainPageViewModel => _services.GetRequiredService<MainPage>(),
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
null => new TextBlock { Text = "No view for <null>" },

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Styling;
@ -19,7 +18,7 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public SettingsPageViewModel SettingsModel { get; }
public Settings SettingsModel { get; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
@ -60,7 +59,7 @@ public partial class MainPageViewModel : ViewModelBase
public MainPageViewModel()
{
Init(null);
SettingsModel = new SettingsPageViewModel();
SettingsModel = new Settings();
BlogClient = null;
}
@ -94,7 +93,7 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{
SettingsModel = new SettingsPageViewModel();
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
Init(settings);

View file

@ -32,6 +32,15 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
@ -133,4 +142,11 @@ public partial class SessionStatusViewModel : ViewModelBase
Refresh();
LogoutCompleted?.Invoke();
}
[RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand()
{
OpenSettingsRequested?.Invoke();
await System.Threading.Tasks.Task.CompletedTask;
}
}

View file

@ -1,48 +1,29 @@
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")]
namespace PostIt;
namespace PostIt.ViewModels;
public partial class Settings : ObservableObject
public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Legacy loopback redirect URI. The post-2026.6 production flow
/// uses the custom URI scheme (<see cref="DefaultDesktopRedirectUri"/>
/// on desktop, <see cref="AndroidRedirectUri"/> on Android) so the
/// OS hands the callback to the running instance without a TCP
/// listener. The loopback constant stays here so test fixtures
/// (which spin up an in-process OidcStubAuthority) keep working,
/// but it is no longer used as a default anywhere in production.
/// If you are still pointing your production <c>postit-settings.json</c>
/// at this URI, switch to <c>postit://callback</c> and remove the
/// matching entry from the Yavsc.Org server's allowed redirect URIs.
/// </summary>
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
@ -107,21 +88,61 @@ public partial class Settings : ObservableObject
public partial bool DarkMode { get; set; } = false;
[ObservableProperty]
public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// Catch top-level mutations: the four ObservableProperty
/// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user
/// edit.
/// </summary>
private void MarkDirty() => IsDirty = true;
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
/// <c>PropertyChanged</c> listener so sub-property edits
/// (Authority, ClientId, RedirectUri, Scopes) are picked up
/// by the dirty tracker. We don't filter on PropertyName: any
/// nested setter is treated as a user edit, which matches the
/// user's mental model ("I typed in a field, the page is now
/// dirty").
/// </summary>
partial void OnAuthenticationChanged(AuthenticationSettings value)
{
if (value is not null)
{
value.PropertyChanged += (_, _) => MarkDirty();
}
MarkDirty();
}
public bool Loaded { get; private set; } = false;
/// <summary>
/// True when the in-memory state has drifted from the last
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
/// this flag, so it only enables when the user has actually
/// touched something since the last load / save. Cleared by
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
/// every successful setter on the four top-level mutable
/// properties and on the sub-properties of
/// <see cref="Authentication"/>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
[ObservableProperty]
public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
public partial bool IsDirty { get; private set; } = false;
/// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
@ -154,8 +175,8 @@ public partial class Settings : ObservableObject
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
RedirectUri = RedirectUri,
Scope = string.Join(' ', this.Scopes),
RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
// PKCE is enabled by default when no client_secret is provided.
@ -168,6 +189,48 @@ public partial class Settings : ObservableObject
}
}
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
/// about the contract: a missing scope here surfaces as a 401
/// on the very first API call after login, with no obvious link
/// to the settings. The "feature" scopes the user must opt into
/// (e.g. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
private static readonly string[] BuiltInScopes = new[]
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token
};
/// <summary>
/// Merge user-configured scopes with the built-in ones. User scopes
/// come first (preserves author intent), then the built-ins, with
/// duplicates removed case-sensitively. <c>null</c> or empty input
/// is fine — we still emit the built-ins.
/// </summary>
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
if (userScopes is not null)
{
foreach (var s in userScopes)
{
if (string.IsNullOrWhiteSpace(s)) continue;
if (seen.Add(s)) yield return s;
}
}
foreach (var s in BuiltInScopes)
{
if (seen.Add(s)) yield return s;
}
}
internal void Load()
{
if (Loaded) return;
@ -280,11 +343,29 @@ public partial class Settings : ObservableObject
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.ApiUrl = settings.ApiUrl;
this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri;
this.Scopes = settings.Scopes;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ?
AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority;
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
// A disk load (or an embedded-resource fallback) is the
// baseline, not a user edit. Clear the dirty flag last
// so the OnAuthenticationChanged / sub-property fan-out
// triggered by the assignments above doesn't leave it
// stuck at true.
IsDirty = false;
// Re-notify the command in case the button was bound
// before Load finished and the CanExecute cache is
// stale.
SaveCommand.NotifyCanExecuteChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}");
@ -292,30 +373,63 @@ public partial class Settings : ObservableObject
}
/// <summary>
/// Marshals every <see cref="ObservableObject.PropertyChanged"/>
/// notification onto the Avalonia UI thread before it leaves this
/// instance. Without this, a background worker (OIDC discovery
/// running on a Task, the file I/O continuation in <see cref="Load"/>,
/// any HTTP callback) would raise <c>PropertyChanged</c> from a
/// thread-pool thread and Avalonia's binding sink would then reach
/// into <c>DataValidationErrors.SetErrors</c> from off-thread,
/// blowing up with <c>InvalidOperationException: The calling thread
/// cannot access this object because a different thread owns it</c>.
/// We keep the mutation lock separate (above) and let the property
/// setters do their work synchronously — only the notification
/// fan-out is bounced to the UI thread.
/// Persist the current in-memory state to
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
/// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
/// (Windows). Symmetrical to <see cref="Load"/>: same path,
/// same directory creation, same <c>0600</c> file mode (POSIX)
/// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
/// on success.
///
/// <para>Synchronous on purpose: matches <see cref="Load"/>'s
/// contract (the file is a few KiB at most, and the Avalonia
/// UI thread cannot await here without risking the same
/// deadlock <see cref="Load"/>'s docstring describes).
/// </para>
/// </summary>
protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
[RelayCommand(CanExecute = nameof(CanSave))]
public void Save()
{
if (UiDispatcher.IsOnUiThread)
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
Directory.CreateDirectory(configDir);
var configPath = Path.Combine(configDir, SettingsFileName);
lock (_mutationGate)
{
base.OnPropertyChanged(e);
return;
try
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true,
});
File.WriteAllText(configPath, json);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
File.SetUnixFileMode(configPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
IsDirty = false;
Console.WriteLine($"💾 Settings saved to {configPath}");
}
// Capture by value: the args object is mutable in some binding
// sinks, and we don't want a background thread to keep mutating
// it after we hand it to the dispatcher.
var snapshot = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName);
UiDispatcher.Post(() => base.OnPropertyChanged(snapshot));
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
throw;
}
}
}
private bool CanSave() => IsDirty;
/// <summary>
/// Re-notify the <c>SaveCommand</c> (generated by
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
/// / <see cref="ApplyJson"/>).
/// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -1,18 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public partial class SettingsPageViewModel : ViewModelBase
{
[ObservableProperty]
public partial bool DarkMode { get; set; }
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -21,6 +21,9 @@
Command="{Binding LoginCommand}"
IsVisible="{Binding IsLoggedOut}"
DockPanel.Dock="Right"/>
<Button Content="Paramètres"
Command="{Binding OpenSettingsCommand}"
DockPanel.Dock="Right"/>
</DockPanel>
</Border>
</UserControl>

View file

@ -4,7 +4,7 @@
xmlns:controls="cl:avalonia.Controls"
x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:SettingsPageViewModel"
x:DataType="vm:Settings"
Width="400"
Height="300">
<Grid>
@ -13,12 +13,42 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Authority"/>
<TextBox Grid.Row="1" x:Name="AuthorityTextBox" Text="{Binding Authority, Mode=TwoWay}"/>
<TextBox Grid.Row="1" x:Name="AuthorityTextBox"
Text="{Binding Authentication.Authority, Mode=TwoWay}"/>
<TextBlock Grid.Row="2" Text="ClientId"/>
<TextBox Grid.Row="3" x:Name="ClientIdTextBox" Text="{Binding ClientId, Mode=TwoWay}"/>
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="6" Text="Business API URL"/>
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Dark mode"/>
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
IsEnabled driven by the inverse of IsDirty so the button
auto-disables when there's nothing to persist. -->
<Button Grid.Row="10" Content="Sauver"
HorizontalAlignment="Right"
Margin="0,12,0,0"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsDirty}"/>
</Grid>
</ContentPage>

View file

@ -8,12 +8,26 @@
"https://localhost:5005"
]
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5002"
},
"Https": {
"Url": "https://localhost:5003"
}
}
}
}

View file

@ -91,6 +91,30 @@ public static class ServiceExtensions
RoleClaimType = YavscConstants.RoleClaimType
};
options.MapInboundClaims = true;
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs
// on https://localhost:5001 with a self-signed dev cert.
// The default .NET HttpClient rejects self-signed certs, so
// JwtBearer's backchannel silently fails to fetch the OIDC
// discovery + JWKS. With an empty ValidIssuer, every token
// is rejected with IDX10204 ("ValidIssuer is null or
// whitespace"). Telling the backchannel to skip TLS
// validation unblocks discovery in dev. Production uses a
// real CA-signed cert and the default validation path; the
// override is gated on HostingEnvironment == Development
// and only fires when the consumer opt-in via the
// 'Yavsc:Dev:TlsInsecure' configuration flag (default
// false), so a misconfigured production environment cannot
// silently downgrade TLS.
if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development")
{
options.BackchannelHttpHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
(_, _, _, _) => true
};
}
configure?.Invoke(options);
});
}