diff --git a/contrib/bruno/Get Posts.bru b/contrib/bruno/Get Posts.bru
deleted file mode 100644
index bafe95b1..00000000
--- a/contrib/bruno/Get Posts.bru
+++ /dev/null
@@ -1,16 +0,0 @@
-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.
diff --git a/contrib/bruno/Untitled.bru b/contrib/bruno/Untitled.bru
deleted file mode 100644
index 168811b2..00000000
--- a/contrib/bruno/Untitled.bru
+++ /dev/null
@@ -1,15 +0,0 @@
-info:
- name: Untitled
- type: http
- seq: 1
-
-http:
- method: GET
- url: ""
- auth: inherit
-
-settings:
- encodeUrl: true
- timeout: 0
- followRedirects: true
- maxRedirects: 5
diff --git a/contrib/bruno/blogs.yml b/contrib/bruno/blogs.yml
deleted file mode 100644
index 2767b01d..00000000
--- a/contrib/bruno/blogs.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-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
diff --git a/contrib/bruno/environments/Development.yml b/contrib/bruno/environments/Development.yml
deleted file mode 100644
index 0fd5430e..00000000
--- a/contrib/bruno/environments/Development.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-name: Development
-variables:
- - name: Blogs
- value: https://localhost:5003
- - name: Authority
- value: https://localhost:5001
diff --git a/contrib/bruno/environments/Production.yml b/contrib/bruno/environments/Production.yml
deleted file mode 100644
index fda7173b..00000000
--- a/contrib/bruno/environments/Production.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-name: Production
-variables:
- - name: Authority
- value: https://yavsc.pschneider.fr
- - name: Blogs
- value: https://blogs.pschneider.fr
diff --git a/contrib/bruno/opencollection.yml b/contrib/bruno/opencollection.yml
deleted file mode 100644
index 1d584584..00000000
--- a/contrib/bruno/opencollection.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-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
diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs
deleted file mode 100644
index 68fa514e..00000000
--- a/src/PostIt.Tests/BearerScopeTests.cs
+++ /dev/null
@@ -1,288 +0,0 @@
-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;
-
-///
-/// Diagnostic coverage for the 401 we're seeing in production when
-/// PostIt talks to Yavsc.Blogs. The hypothesis this file
-/// isolates: "the access token sent on the wire is missing the
-/// blogs scope that Yavsc.Blogs's BlogScope
-/// policy requires". The policy lives in
-/// Yavsc.Blogs/Program.cs as
-/// RequireClaim(JwtClaimTypes.Scope, "blogs").
-///
-///
-/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
-/// any network listener. The test fakes a single
-/// that captures the outbound
-/// request, deserialises the bearer JWT, and asserts the
-/// scope claim contains the segment the policy needs. This
-/// pins the client side of the contract so a future regression in
-/// or (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.
-///
-///
-public class BearerScopeTests
-{
- ///
- /// Hard-coded blogs scope string. Mirrors the value in
- /// Yavsc.Blogs/Program.cs's BlogScope policy; if
- /// the server ever moves to "blog.read" or similar this
- /// constant should be updated to match.
- ///
- 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).
- 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 -------------------------------------------------------
-
- ///
- /// Build an unsigned JWT carrying a single scope claim.
- /// Mirrors the read-only fallback in
- /// : base64url-decode
- /// the middle segment, parse JSON, read the scope string.
- /// The header and signature are placeholders — nobody in the
- /// test path verifies the signature.
- ///
- 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('/', '_');
- }
-
- ///
- /// Pull the scope 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.
- ///
- private static IReadOnlyCollection 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();
- }
- var raw = scopeEl.GetString() ?? string.Empty;
- return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- }
-
- ///
- /// Minimal that records the
- /// first request's Authorization 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.
- ///
- private sealed class CapturingHttpHandler : HttpMessageHandler
- {
- public string? Authorization { get; private set; }
- public Uri? RequestUri { get; private set; }
-
- protected override Task 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);
- }
- }
-
- ///
- /// Subclass of that routes HTTP
- /// traffic through a caller-supplied
- /// . The base ctor wires
- /// Http as new HttpClient(BearerTokenHandler(...));
- /// we don't replace that — we override the public call seam
- ///
- /// (declared virtual) and talk to our own HttpClient
- /// from there. The EnsureFreshToken / 401-retry path
- /// is intentionally not exercised here — that lives in
- /// YavscApiClientTests; isolating the bearer
- /// attachment is the whole point of this test.
- ///
- 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 CallAsync(
- 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(stream,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
- return Task.FromResult(dto!);
- }
- }
-}
diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs
new file mode 100644
index 00000000..7a8b579f
--- /dev/null
+++ b/src/PostIt.Tests/LoginPageViewModelTests.cs
@@ -0,0 +1,257 @@
+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 ?? ""}");
+ 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 ?? ""}");
+ }
+
+ [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);
+ }
+}
diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs
index 48569915..0be4410d 100644
--- a/src/PostIt.Tests/PostItViewModelTests.cs
+++ b/src/PostIt.Tests/PostItViewModelTests.cs
@@ -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()))
diff --git a/src/PostIt.Tests/SettingsLoadTests.cs b/src/PostIt.Tests/SettingsLoadTests.cs
index a9dd01d4..1a9697f7 100644
--- a/src/PostIt.Tests/SettingsLoadTests.cs
+++ b/src/PostIt.Tests/SettingsLoadTests.cs
@@ -27,7 +27,7 @@ public class SettingsLoadTests
return; // nothing to assert: user file wins.
}
- var settings = new PostIt.ViewModels.Settings();
+ var settings = new PostIt.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.ViewModels.Settings();
+ var settings = new PostIt.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",
- Scopes = new[] { "openid" },
+ ClientId = "postit-tests"
};
+ 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.Authentication.RedirectUri =
- global::AuthenticationSettings.DefaultDesktopRedirectUri;
-
- settings.BusinessApiUrl = flip
+ settings.RedirectUri = flip
+ ? PostIt.Settings.DefaultDesktopRedirectUri
+ : PostIt.Settings.DefaultLoopbackRedirectUri;
+ settings.ApiUrl = 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.Authentication.Scopes);
+ Assert.NotNull(settings.Scopes);
}
}
catch (Exception ex)
@@ -131,7 +131,7 @@ public class SettingsLoadTests
[Fact]
public void Load_is_idempotent_under_concurrent_calls()
{
- var settings = new PostIt.ViewModels.Settings
+ var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs
index c020fec9..03e2fa3f 100644
--- a/src/PostIt.Tests/YavscApiClientTests.cs
+++ b/src/PostIt.Tests/YavscApiClientTests.cs
@@ -12,7 +12,6 @@ using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
-using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
@@ -62,12 +61,6 @@ 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>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@@ -118,16 +111,16 @@ public class YavscApiClientTests
[Fact]
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
{
- var settings = new Settings
+ var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests",
- RedirectUri = "postit://callback",
- Scopes = new[] { "openid" },
},
- BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
+ RedirectUri = "postit://callback",
+ Scopes = new[] { "openid" },
+ ApiUrl = "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")));
@@ -162,30 +155,24 @@ public class YavscApiClientTests
// --- helpers --------------------------------------------------------
- private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
+ private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests",
- RedirectUri = authority.LoopbackRedirectUri,
- Scopes = new[] { "openid", "profile", "blog" }
},
- BusinessApiUrl = apiBaseUrl
+ RedirectUri = authority.LoopbackRedirectUri,
+ Scopes = new[] { "openid", "profile", "blog" },
+ ApiUrl = apiBaseUrl,
};
private static async Task LoginAndPersistAsync(
- Settings settings, OIDCStubAuthority authority, string tokensPath)
+ PostIt.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());
diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs
index 1563ec53..ad283ec1 100644
--- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs
+++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs
@@ -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 = AuthenticationSettings.DefaultDesktopRedirectUri;
+ Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit";
}
}
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index b474b37b..d9938f72 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -69,7 +69,7 @@ public partial class App : Application
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient();
- services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -130,22 +130,6 @@ public partial class App : Application
_ = PushMainPageAsync(provider, w);
};
- // When the user clicks the "Paramètres" button on the
- // session banner, push the SettingsPage on top of the
- // current navigation stack. Resolved from DI so the
- // ViewLocator + service-locator dance stays out of the
- // VM, and bound to the same Settings singleton the rest
- // of the app is using (the one we Load()'d at startup).
- // Two-way bindings on the page mutate that singleton
- // in place; callers re-read on next access.
- sessionStatus.OpenSettingsRequested += () =>
- {
- var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
- var settingsPage = provider.GetRequiredService();
- settingsPage.DataContext = provider.GetRequiredService();
- _ = w.NavRoot.PushAsync(settingsPage);
- };
-
window.Opened += async (_, _) => await BootAsync(provider, api, window);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs
index 370fd040..d489be60 100644
--- a/src/PostIt/PostIt/Services/BlogApiClient.cs
+++ b/src/PostIt/PostIt/Services/BlogApiClient.cs
@@ -40,11 +40,6 @@ public sealed class BlogApiClient
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
-
- // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
- // trailing slash so relative paths ("posts") resolve correctly.
- api.Http.BaseAddress = new Uri(api.Settings.BusinessApiUrl);
-
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
index b5b9808f..1fe02e86 100644
--- a/src/PostIt/PostIt/Services/YavscApiClient.cs
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -8,7 +8,6 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
-using PostIt.ViewModels;
namespace PostIt.Services;
@@ -30,10 +29,10 @@ public class YavscApiClient : IAsyncDisposable
// network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
- public Settings Settings { get; }
+ private readonly Settings _settings;
private readonly OidcClient _oidc;
private readonly TokenStore _store;
- public HttpClient Http { get; }
+ private readonly HttpClient _http;
private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1);
@@ -41,12 +40,17 @@ 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);
+ _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)
+ };
_tokens = store.Load();
}
@@ -97,7 +101,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
@@ -241,7 +245,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)
{
@@ -253,7 +257,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;
@@ -320,7 +324,7 @@ public class YavscApiClient : IAsyncDisposable
public ValueTask DisposeAsync()
{
- Http.Dispose();
+ _http.Dispose();
_refreshGate.Dispose();
return ValueTask.CompletedTask;
}
diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs
index 9ece1e45..4547c732 100644
--- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs
+++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs
@@ -3,41 +3,11 @@ using System;
public partial class AuthenticationSettings : ObservableObject
{
- ///
- /// Default custom-scheme redirect URI on Desktop. The OS routes the
- /// callback to the running PostIt instance via the named-pipe
- /// hand-off in
- /// (RFC 8252 §7.1). Production Desktop builds use this.
- ///
- public const string DefaultDesktopRedirectUri = "postit://callback";
- ///
- /// Redirect URI used by the Android app. The corresponding IntentFilter
- /// in PostIt.Android/Properties/AndroidManifest.xml must match.
- ///
- public const string AndroidRedirectUri = "android://postit-signin";
-
- public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
-
- public static string DefaultClientId { get; internal set; } = "postit";
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
-
- [ObservableProperty]
- public partial string[] Scopes { get; set; }
-
-
- ///
- /// OAuth redirect URI. Defaults to
- /// (custom URI scheme) which is the right answer for desktop
- /// production builds. Mobile platforms must set this to
- /// before calling LoginAsync.
- ///
- [ObservableProperty]
- public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
-
-}
+}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs
similarity index 56%
rename from src/PostIt/PostIt/ViewModels/Settings.cs
rename to src/PostIt/PostIt/Settings/Settings.cs
index 98085f0c..3cb40e38 100644
--- a/src/PostIt/PostIt/ViewModels/Settings.cs
+++ b/src/PostIt/PostIt/Settings/Settings.cs
@@ -1,29 +1,48 @@
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.ViewModels;
+namespace PostIt;
-public partial class Settings : ViewModelBase
+public partial class Settings : ObservableObject
{
const string SettingsFileName = "postit-settings.json";
+ ///
+ /// Legacy loopback redirect URI. The post-2026.6 production flow
+ /// uses the custom URI scheme (
+ /// on desktop, 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 postit-settings.json
+ /// at this URI, switch to postit://callback and remove the
+ /// matching entry from the Yavsc.Org server's allowed redirect URIs.
+ ///
+ public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
+
///
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in PostIt.Android/Properties/AndroidManifest.xml must match.
///
public const string AndroidRedirectUri = "android://postit-signin";
-
+ ///
+ /// Default custom-scheme redirect URI on Desktop. The OS routes the
+ /// callback to the running PostIt instance via the named-pipe
+ /// hand-off in
+ /// (RFC 8252 §7.1). Production Desktop builds use this.
+ ///
+ public const string DefaultDesktopRedirectUri = "postit://callback";
///
/// Process-wide canonical instance, wired up
@@ -88,62 +107,22 @@ public partial class Settings : ViewModelBase
public partial bool DarkMode { get; set; } = false;
[ObservableProperty]
- public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
+ public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
+
+ ///
+ /// OAuth redirect URI. Defaults to
+ /// (custom URI scheme) which is the right answer for desktop
+ /// production builds. Mobile platforms must set this to
+ /// before calling LoginAsync.
+ ///
+ [ObservableProperty]
+ public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
+
[ObservableProperty]
- public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
-
- ///
- /// Catch top-level mutations: the four ObservableProperty
- /// setters above all funnel through here, and we flip
- /// in lock-step. Sub-property mutations
- /// (e.g. Authentication.Authority) are caught by the
- /// subscription wired up in
- /// below. disables the flag during bulk
- /// hydration so the disk load itself does not count as a user
- /// edit.
- ///
- private void MarkDirty() => IsDirty = true;
-
- partial void OnDarkModeChanged(bool value) => MarkDirty();
- partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
- partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
-
- ///
- /// Authentication can be reassigned wholesale by
- /// ; on each reassignment we (re)wire a
- /// PropertyChanged 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").
- ///
- partial void OnAuthenticationChanged(AuthenticationSettings value)
- {
- if (value is not null)
- {
- value.PropertyChanged += (_, _) => MarkDirty();
- }
- MarkDirty();
- }
-
+ public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
- ///
- /// True when the in-memory state has drifted from the last
- /// or snapshot. The
- /// Settings page binds the Sauver button's IsEnabled to
- /// this flag, so it only enables when the user has actually
- /// touched something since the last load / save. Cleared by
- /// (and by ), set by
- /// every successful setter on the four top-level mutable
- /// properties and on the sub-properties of
- /// .
- ///
- [ObservableProperty]
- public partial bool IsDirty { get; private set; } = false;
-
///
/// Guards every mutation of the observable state. [ObservableProperty]
/// generates setters that call SetProperty(...) which fires
@@ -175,8 +154,8 @@ public partial class Settings : ViewModelBase
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
- RedirectUri = Authentication.RedirectUri,
- Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
+ RedirectUri = RedirectUri,
+ Scope = string.Join(' ', this.Scopes),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
// PKCE is enabled by default when no client_secret is provided.
@@ -189,48 +168,6 @@ public partial class Settings : ViewModelBase
}
}
- ///
- /// Scopes the PostIt client always requires from the OIDC provider,
- /// regardless of what the user has in their settings file.
- ///
- /// PostIt calls into the Blog API (and any other Yavsc API
- /// gated by an [Authorize("…Scope")] policy) and is silent
- /// about the contract: a missing scope here surfaces as a 401
- /// on the very first API call after login, with no obvious link
- /// to the settings. The "feature" scopes the user must opt into
- /// (e.g. blogs) are still their choice — we only force the
- /// structural ones that OIDC itself needs.
- ///
- private static readonly string[] BuiltInScopes = new[]
- {
- "openid", // OIDC: required for the id_token
- "profile", // OIDC: standard profile claims
- "offline_access" // OIDC: required to receive a refresh_token
- };
-
- ///
- /// Merge user-configured scopes with the built-in ones. User scopes
- /// come first (preserves author intent), then the built-ins, with
- /// duplicates removed case-sensitively. null or empty input
- /// is fine — we still emit the built-ins.
- ///
- internal static IEnumerable MergeScopes(string[]? userScopes)
- {
- var seen = new HashSet(StringComparer.Ordinal);
- if (userScopes is not null)
- {
- foreach (var s in userScopes)
- {
- if (string.IsNullOrWhiteSpace(s)) continue;
- if (seen.Add(s)) yield return s;
- }
- }
- foreach (var s in BuiltInScopes)
- {
- if (seen.Add(s)) yield return s;
- }
- }
-
internal void Load()
{
if (Loaded) return;
@@ -343,28 +280,10 @@ public partial class Settings : ViewModelBase
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
- 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;
- }
+ this.ApiUrl = settings.ApiUrl;
+ this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri;
+ this.Scopes = settings.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)
{
@@ -373,63 +292,30 @@ public partial class Settings : ViewModelBase
}
///
- /// Persist the current in-memory state to
- /// ~/.config/PostIt/postit-settings.json (Linux) /
- /// equivalent %APPDATA%\PostIt\postit-settings.json
- /// (Windows). Symmetrical to : same path,
- /// same directory creation, same 0600 file mode (POSIX)
- /// as TokenStore.Save. Clears
- /// on success.
- ///
- /// Synchronous on purpose: matches 's
- /// contract (the file is a few KiB at most, and the Avalonia
- /// UI thread cannot await here without risking the same
- /// deadlock 's docstring describes).
- ///
+ /// Marshals every
+ /// notification onto the Avalonia UI thread before it leaves this
+ /// instance. Without this, a background worker (OIDC discovery
+ /// running on a Task, the file I/O continuation in ,
+ /// any HTTP callback) would raise PropertyChanged from a
+ /// thread-pool thread and Avalonia's binding sink would then reach
+ /// into DataValidationErrors.SetErrors from off-thread,
+ /// blowing up with InvalidOperationException: The calling thread
+ /// cannot access this object because a different thread owns it.
+ /// We keep the mutation lock separate (above) and let the property
+ /// setters do their work synchronously — only the notification
+ /// fan-out is bounced to the UI thread.
///
- [RelayCommand(CanExecute = nameof(CanSave))]
- public void Save()
+ protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
{
- var configDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "PostIt");
- Directory.CreateDirectory(configDir);
- var configPath = Path.Combine(configDir, SettingsFileName);
-
- lock (_mutationGate)
+ if (UiDispatcher.IsOnUiThread)
{
- 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}");
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
- throw;
- }
+ base.OnPropertyChanged(e);
+ return;
}
+ // Capture by value: the args object is mutable in some binding
+ // sinks, and we don't want a background thread to keep mutating
+ // it after we hand it to the dispatcher.
+ var snapshot = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName);
+ UiDispatcher.Post(() => base.OnPropertyChanged(snapshot));
}
-
- private bool CanSave() => IsDirty;
-
- ///
- /// Re-notify the SaveCommand (generated by
- /// [RelayCommand] on ) so XAML
- /// re-evaluates CanExecute when the dirty flag flips
- /// outside the scope of a direct save (e.g. on
- /// / ).
- ///
- 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(); }
}
diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs
index e725d0d9..983cf13c 100644
--- a/src/PostIt/PostIt/ViewLocator.cs
+++ b/src/PostIt/PostIt/ViewLocator.cs
@@ -25,7 +25,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
MainPageViewModel => _services.GetRequiredService(),
- Settings => _services.GetRequiredService(),
+ SettingsPageViewModel => _services.GetRequiredService(),
HomePageViewModel => _services.GetRequiredService(),
SignaturePageViewModel => _services.GetRequiredService(),
null => new TextBlock { Text = "No view for " },
diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
index 74996bd9..073ebf7a 100644
--- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Styling;
@@ -18,7 +19,7 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
- public Settings SettingsModel { get; }
+ public SettingsPageViewModel SettingsModel { get; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
@@ -59,7 +60,7 @@ public partial class MainPageViewModel : ViewModelBase
public MainPageViewModel()
{
Init(null);
- SettingsModel = new Settings();
+ SettingsModel = new SettingsPageViewModel();
BlogClient = null;
}
@@ -93,7 +94,7 @@ public partial class MainPageViewModel : ViewModelBase
///
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{
- SettingsModel = new Settings();
+ SettingsModel = new SettingsPageViewModel();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
Init(settings);
diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs
index 55f2cab4..9902008f 100644
--- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs
@@ -32,15 +32,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// HomePage so the user lands on the blog editor.
public event System.Action? LoginSucceeded;
- /// Raised when the user clicks the "Paramètres" button on
- /// the session banner. App.axaml.cs listens and pushes
- /// SettingsPage (resolved from DI, bound to the canonical
- /// Settings singleton) on top of the current navigation
- /// stack. Same event pattern as and
- /// so the VM stays decoupled from
- /// NavigationPage / window lifetime.
- public event System.Action? OpenSettingsRequested;
-
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
@@ -142,11 +133,4 @@ public partial class SessionStatusViewModel : ViewModelBase
Refresh();
LogoutCompleted?.Invoke();
}
-
- [RelayCommand]
- public async System.Threading.Tasks.Task OpenSettingsCommand()
- {
- OpenSettingsRequested?.Invoke();
- await System.Threading.Tasks.Task.CompletedTask;
- }
}
diff --git a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs
new file mode 100644
index 00000000..67223d5f
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,18 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace PostIt.ViewModels;
+
+public partial class SettingsPageViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ public partial bool DarkMode { get; set; }
+
+ [ObservableProperty]
+ public partial string Authority { get; set; }
+
+ [ObservableProperty]
+ public partial string ClientId { get; set; }
+
+ public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
+ public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
+}
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
index 9c6a3f93..0af0e120 100644
--- a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
+++ b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml
@@ -21,9 +21,6 @@
Command="{Binding LoginCommand}"
IsVisible="{Binding IsLoggedOut}"
DockPanel.Dock="Right"/>
-
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml
index aeba19f0..e8d3a125 100644
--- a/src/PostIt/PostIt/Views/SettingsPage.axaml
+++ b/src/PostIt/PostIt/Views/SettingsPage.axaml
@@ -4,7 +4,7 @@
xmlns:controls="cl:avalonia.Controls"
x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels"
- x:DataType="vm:Settings"
+ x:DataType="vm:SettingsPageViewModel"
Width="400"
Height="300">
@@ -13,42 +13,12 @@
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
\ No newline at end of file
diff --git a/src/Yavsc.Blogs/appsettings-blogs.json b/src/Yavsc.Blogs/appsettings-blogs.json
index a33b8102..cac9f06b 100644
--- a/src/Yavsc.Blogs/appsettings-blogs.json
+++ b/src/Yavsc.Blogs/appsettings-blogs.json
@@ -8,26 +8,12 @@
"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.AspNetCore.Authentication": "Debug"
+ "Microsoft.Hosting.Lifetime": "Information"
}
},
- "AllowedHosts": "*",
- "Kestrel": {
- "Endpoints": {
- "Http": {
- "Url": "http://localhost:5002"
- },
- "Https": {
- "Url": "https://localhost:5003"
- }
- }
- }
+ "AllowedHosts": "*"
}
diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs
index 3abc723b..57e9f770 100644
--- a/src/Yavsc.Server/Helpers/ServiceExtensions.cs
+++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs
@@ -91,31 +91,7 @@ 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("ASPNETCORE_ENVIRONMENT") == "Development")
- {
- options.BackchannelHttpHandler = new HttpClientHandler
- {
- ServerCertificateCustomValidationCallback =
- (_, _, _, _) => true
- };
- }
configure?.Invoke(options);
});
}
-}
+}
\ No newline at end of file