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"/> -