From 40e5630cfc0c0af2c155ea85f5d12159ff15f52d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:34:48 +0100 Subject: [PATCH 01/77] refactor(blogacl): move BlogAcl + Circle controllers from Yavsc.Api to Yavsc.Blogs These two controllers belong to the Blogs subsystem (their routes /api/blogacl and /api/circle are blog-domain concerns, not the generic Api surface). Moving them next to BlogApiController keeps related code together and prepares the PostIt client to consume them through the same BlogsApiUrl base address as the existing BlogApiClient. Mechanical changes only: - Namespace Yavsc.Controllers -> Yavsc.Blogs.Controllers - Drop unused 'using Yavsc.Helpers;' (no symbol in the new compilation unit depends on it; the build confirms it was dead since the controllers were first written) - Fix typo in CircleApiController route: 'api/cirle' -> 'api/circle' (any client trying to call the documented route was hitting 404) No functional changes to authorization or query shape. The known security gaps in these controllers (GetBlogACL and GetCircle return unfiltered collections, DeleteCircle has no ownership check) are deliberately left untouched in this commit and will be addressed in a follow-up. --- .../Controllers}/BlogAclApiController.cs | 5 ++--- .../Controllers}/CircleApiController.cs | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) rename src/{Yavsc.Api/Controllers/Relationship => Yavsc.Blogs/Controllers}/BlogAclApiController.cs (98%) rename src/{Yavsc.Api/Controllers/Relationship => Yavsc.Blogs/Controllers}/CircleApiController.cs (98%) diff --git a/src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs similarity index 98% rename from src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs rename to src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index 6e8b905c..3fbd89cf 100644 --- a/src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -1,12 +1,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Access; using Yavsc.Server.Helpers; -namespace Yavsc.Controllers +namespace Yavsc.Blogs.Controllers { [Produces("application/json")] [Route("api/blogacl")] @@ -86,7 +85,7 @@ namespace Yavsc.Controllers } private bool CheckOwner (long circleId) { - + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var circle = _context.Circle.First(c=>c.Id==circleId); _context.Entry(circle).State = EntityState.Detached; diff --git a/src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs similarity index 98% rename from src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs rename to src/Yavsc.Blogs/Controllers/CircleApiController.cs index 7a8b4deb..b5434f83 100644 --- a/src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Relationship; using Yavsc.Server.Helpers; -namespace Yavsc.Controllers +namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route("api/cirle")] + [Route("api/circle")] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; From e376aed887f42df1cf58426829b25de84820608a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:36:20 +0100 Subject: [PATCH 02/77] fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the data-leak holes that survived the move of these controllers from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its membership should never be visible, modifiable, or deletable by anyone other than its owner. BlogAclApiController: - GetBlogACL() was returning the full table; now filters by Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed) so EF Core can push the filter into SQL instead of materialising the whole table. - Other endpoints (GetById, Put, Post, Delete) already enforced ownership; left as is. CircleApiController: - GetCircle() (no id) now filters by OwnerId. - GetCircle(id) now requires c.Id == id && c.OwnerId == uid; returns 404 (not 403) on miss to avoid leaking the existence of someone else's circle. - PutCircle verifies the existing record is owned by the caller, then forces circle.OwnerId = uid on the body (the client's value is ignored). Returns ChallengeResult when the caller doesn't own the record. - PostCircle forces circle.OwnerId = uid (was trusting the body). - DeleteCircle now filters by OwnerId; 404 on miss. All checks use the same source of truth (User.FindFirstValue( ClaimTypes.NameIdentifier)) that the existing BlogAclApiController authz code already relies on. --- .../Controllers/BlogAclApiController.cs | 13 ++++- .../Controllers/CircleApiController.cs | 58 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index 3fbd89cf..aa81f9d5 100644 --- a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,11 +19,19 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/BlogAclApi + /// + /// Returns the ACL entries for the caller's own blog posts. + /// Blog posts (and therefore their ACLs) are private to their + /// author — the API never exposes another user's ACL. + /// + // GET: api/blogacl [HttpGet] public IEnumerable GetBlogACL() { - return _context.CircleAuthorizationToBlogPost; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.CircleAuthorizationToBlogPost + .Include(a => a.Allowed) + .Where(a => a.Allowed.OwnerId == uid); } // GET: api/BlogAclApi/5 diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index b5434f83..368b488a 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,3 +1,5 @@ +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; @@ -17,14 +19,22 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/CircleApi + /// + /// Returns the caller's own circles. Circles are personal — + /// the API never exposes another user's circles, even by id. + /// + // GET: api/circle [HttpGet] public IEnumerable GetCircle() { - return _context.Circle; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.Circle.Where(c => c.OwnerId == uid); } - // GET: api/CircleApi/5 + /// + /// Returns a single circle only when it belongs to the caller. + /// + // GET: api/circle/5 [HttpGet("{id}", Name = "GetCircle")] public async Task GetCircle([FromRoute] long id) { @@ -33,7 +43,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { @@ -43,7 +55,12 @@ namespace Yavsc.Blogs.Controllers return Ok(circle); } - // PUT: api/CircleApi/5 + /// + /// Replaces a circle. The caller must own it; the server + /// reasserts ownership regardless of any OwnerId the client + /// tries to put in the body. + /// + // PUT: api/circle/5 [HttpPut("{id}")] public async Task PutCircle([FromRoute] long id, [FromBody] Circle circle) { @@ -57,6 +74,16 @@ namespace Yavsc.Blogs.Controllers return BadRequest(); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var existing = await _context.Circle.SingleOrDefaultAsync( + c => c.Id == id && c.OwnerId == uid); + if (existing is null) + { + return new ChallengeResult(); + } + + // Force OwnerId to the caller; the body value is ignored. + circle.OwnerId = uid; _context.Entry(circle).State = EntityState.Modified; try @@ -78,7 +105,11 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/CircleApi + /// + /// Creates a circle owned by the caller. The server overwrites + /// any OwnerId the client sends in the body. + /// + // POST: api/circle [HttpPost] public async Task PostCircle([FromBody] Circle circle) { @@ -87,6 +118,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + circle.OwnerId = uid; + _context.Circle.Add(circle); try { @@ -107,7 +141,13 @@ namespace Yavsc.Blogs.Controllers return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle); } - // DELETE: api/CircleApi/5 + /// + /// Deletes a circle only if the caller owns it. Returns 404 + /// (not 403) when the circle does not exist or is not owned + /// by the caller, to avoid leaking the existence of someone + /// else's circle. + /// + // DELETE: api/circle/5 [HttpDelete("{id}")] public async Task DeleteCircle([FromRoute] long id) { @@ -116,7 +156,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { return NotFound(); From 0e95e28327da24a27e06bbdcc8f0c3827427b0b1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:45:45 +0100 Subject: [PATCH 03/77] refactor(model): move BlogPost DTO from PostIt.Models to Yavsc.Blogspot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlogPost is shared between the server (Yavsc.Server/Models/Blog/ BlogPost.cs is the EF entity) and any client that talks to the blogs API. Keeping the client-side DTO in PostIt.Models made sense when there was only one consumer; now that the Yavsc.Api.Client project is about to host BlogApiClient alongside CircleApiClient and BlogAclApiClient, the DTO has to live in a layer both the client project and PostIt can reference without inverting the dependency. Yavsc.Abstract is the existing home for cross-tier interfaces and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser). Yavsc.Blogspot is the sub-namespace already used by the matching interface, so the new concrete class follows. Why not move Circle and CircleAuthorizationToBlogPost at the same time? Both depend on the concrete ApplicationUser class (via the Owner and Target/Allowed navigation properties) which lives in Yavsc.Server. Moving them would mean either dragging ApplicationUser into the abstract layer (huge blast radius — auth, billing, chat, etc.) or weakening the navigation properties (breaks EF Core shaping). They're staying where they are; the new Yavsc.Api.Client will get DTO counterparts instead. Updated call sites: - 4 .cs files: replace 'using PostIt.Models;' with 'using Yavsc.Blogspot;' where the file was actually using BlogPost. Files that only used SignaturePadData keep their 'using PostIt.Models;' — that type stays put. - 1 .axaml file: xmlns:models="using:PostIt.Models" -> xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for the post list in MainPage). Build + tests green (51/51). --- src/PostIt.Tests/BlogApiTestFakes.cs | 2 +- src/PostIt.Tests/MainPageSaveTests.cs | 2 +- src/PostIt.Tests/PostItViewModelTests.cs | 2 +- src/PostIt/PostIt/Services/BlogApiClient.cs | 2 +- src/PostIt/PostIt/ViewModels/MainPageViewModel.cs | 2 +- src/PostIt/PostIt/Views/MainPage.axaml | 2 +- .../Blogspot}/BlogPost.cs | 15 +++++++-------- 7 files changed, 13 insertions(+), 14 deletions(-) rename src/{PostIt/PostIt/Models => Yavsc.Abstract/Blogspot}/BlogPost.cs (65%) diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs index 755ce105..3f8b98b0 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -1,4 +1,4 @@ -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using Yavsc.Models; diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index c76115d7..cea3e83f 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -2,7 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index 48569915..d8de8025 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,4 +1,4 @@ -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs index 5e927b97..f2061927 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using PostIt.Models; +using Yavsc.Blogspot; namespace PostIt.Services; diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e7ea26a0..e8024d7d 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; namespace PostIt.ViewModels; diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 5c42e27c..1ab8d905 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:PostIt.ViewModels" - xmlns:models="using:PostIt.Models" + xmlns:models="using:Yavsc.Blogspot" xmlns:views="using:PostIt.Views" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" diff --git a/src/PostIt/PostIt/Models/BlogPost.cs b/src/Yavsc.Abstract/Blogspot/BlogPost.cs similarity index 65% rename from src/PostIt/PostIt/Models/BlogPost.cs rename to src/Yavsc.Abstract/Blogspot/BlogPost.cs index e62fcea2..854aa29f 100644 --- a/src/PostIt/PostIt/Models/BlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/BlogPost.cs @@ -1,9 +1,8 @@ using System; using Yavsc.Abstract.Identity; using Yavsc.Abstract.Identity.Security; -using Yavsc.Blogspot; -namespace PostIt.Models; +namespace Yavsc.Blogspot; public class BlogPost : IBlogPost { @@ -13,12 +12,12 @@ public class BlogPost : IBlogPost public string Article { get; set ; } public string Photo { get; set ; } - public long Id { get; set ; } - public DateTime DateCreated { get; set ; } - public string UserCreated { get; set ; } - public DateTime DateModified { get; set ; } - public string UserModified { get; set ; } - public string Title { get; set ; } + public long Id { get; set; } + public DateTime DateCreated { get; set; } + public string UserCreated { get; set; } + public DateTime DateModified { get; set; } + public string UserModified { get; set; } + public string Title { get; set; } public bool AuthorizeCircle(long circleId) { From ab40af8ef1fbdfdd309493f43e9da31c40eaa187 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:50:24 +0100 Subject: [PATCH 04/77] refactor(api-client): introduce IYavscApiClient abstraction in Yavsc.Api.Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yavsc.Api.Client is the new home for high-level HTTP clients (BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It depends on the host application's transport layer, but the host (PostIt) is a UI app with OIDC, settings, and an ApplicationData directory — none of which the abstract client library should know about. The IYavscApiClient interface captures just the transport surface those clients need: - HttpClient (so the client can configure BaseAddress) - CallAsync and CallAsync (the JSON over HTTP verb) It deliberately leaves out LoginAsync / TrySilentLoginAsync / CurrentAccessToken / HasValidSession / Settings — those are authentication and configuration concerns, not transport. They stay on the concrete YavscApiClient in PostIt.Services. The concrete YavscApiClient now implements IYavscApiClient; the existing public surface is unchanged (no breaking changes for existing call sites in PostIt or the tests). This commit only lays the foundation. The actual high-level clients (Blog/Circle/BlogAcl) land in a follow-up commit that re-uses this interface, so this one stays a small, reviewable refactor. --- src/PostIt/PostIt/PostIt.csproj | 1 + src/PostIt/PostIt/Services/YavscApiClient.cs | 3 +- src/Yavsc.Api.Client/IYavscApiClient.cs | 62 ++++++++++++++++++++ src/Yavsc.Api.Client/Yavsc.Api.Client.csproj | 29 +++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Yavsc.Api.Client/IYavscApiClient.cs create mode 100644 src/Yavsc.Api.Client/Yavsc.Api.Client.csproj diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index d9cf96d3..e4d51a88 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -25,6 +25,7 @@ + diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 9ae1453b..b611fe02 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using PostIt.ViewModels; +using Yavsc.Api.Client; namespace PostIt.Services; @@ -24,7 +25,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public class YavscApiClient : IAsyncDisposable +public class YavscApiClient : IYavscApiClient, IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs new file mode 100644 index 00000000..209ec07d --- /dev/null +++ b/src/Yavsc.Api.Client/IYavscApiClient.cs @@ -0,0 +1,62 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Yavsc.Api.Client; + +/// +/// Transport surface that the high-level clients +/// (, , +/// ) need to do their work. +/// +/// This is intentionally a thin, transport-only contract. It +/// does not include the OIDC login / refresh / logout surface — +/// that lives on the concrete YavscApiClient in the +/// consuming application and is wired by the application +/// composition root. Splitting the two keeps Yavsc.Api.Client +/// usable from any host (a CLI, a unit test, a future iOS +/// client) without dragging OIDC, identity, and a Settings +/// POMVO everywhere. +/// +/// Implementations are expected to: +/// +/// Attach a Bearer access token to every outbound request. +/// Silently refresh the token on a 401 and retry once. +/// Serialise the request body as JSON and deserialise the +/// response body with case-insensitive property matching. +/// +/// +/// The exception contract on non-2xx responses is +/// with a message that includes +/// the response body (capped), so callers can surface the +/// server-side validation problem to the UI without losing +/// context. +/// +public interface IYavscApiClient : IAsyncDisposable +{ + /// + /// The configured . Clients set its + /// BaseAddress in their constructors to point at the + /// API host they target. + /// + HttpClient Http { get; } + + /// Call a JSON endpoint with a typed return value. + /// HTTP verb. + /// Path relative to . + /// Optional request body, serialised as JSON. + /// Cancellation token. + Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default); + + /// Call a JSON endpoint that returns no useful body (DELETE, 204, etc.). + Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default); +} diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj new file mode 100644 index 00000000..5376856d --- /dev/null +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + Yavsc.Api.Client + Yavsc.Api.Client + enable + latest + true + + Thin HTTP clients for the Yavsc API. Each client is a DTO↔path + mapper; all transport concerns (base URL, JSON, Bearer auth, + silent refresh on 401) are delegated to YavscApiClient, which + lives in the consuming application (PostIt). + + https://github.com/pazof/yavsc + true + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 + + + + + + + + From f835ad42a14a6cd7961e813026bc9b7610a63235 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:50:35 +0100 Subject: [PATCH 05/77] feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients Creates the high-level HTTP client library the PostIt UI will consume to manage blog posts, circles, and per-post ACLs. Clients in this commit: - BlogApiClient (moved from PostIt/Services; same public surface, now depends on IYavscApiClient instead of the concrete class). - CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes the blogs base URL explicitly in its constructor so it doesn't need to know about PostIt's Settings type. - BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl. Same conventions as CircleApiClient. DTOs (Yavsc.Api.Client.Dtos): - CircleDto: id, name, ownerId, public. Stops short of the navigation properties on the server-side Circle (Owner, Members), which depend on ApplicationUser and other server types we don't want to drag into the client. - CircleAuthorizationDto: circleId, blogPostId, comment. Same reason: the server entity has Target and Allowed navigation properties the client never needs. The clients now require the caller to pass the blogs base URL explicitly in the constructor (previously the BlogApiClient sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that field is PostIt-specific). The one production call site (App.axaml.cs) and four test call sites are updated to pass the URL. Build + 51/51 tests green. The IYavscApiClient abstraction was landed in the previous commit so this one could be a pure addition + relocation. --- src/PostIt.Tests/BearerScopeTests.cs | 5 +- src/PostIt.Tests/MainPageSaveTests.cs | 3 +- src/PostIt.Tests/PostItViewModelTests.cs | 5 +- src/PostIt.Tests/YavscApiClientTests.cs | 3 ++ src/PostIt/PostIt/App.axaml.cs | 3 +- .../PostIt/ViewModels/MainPageViewModel.cs | 1 + src/Yavsc.Api.Client/BlogAclApiClient.cs | 49 +++++++++++++++++ .../BlogApiClient.cs | 19 ++++--- src/Yavsc.Api.Client/CircleApiClient.cs | 53 +++++++++++++++++++ .../Dtos/CircleAuthorizationDto.cs | 19 +++++++ src/Yavsc.Api.Client/Dtos/CircleDto.cs | 23 ++++++++ 11 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 src/Yavsc.Api.Client/BlogAclApiClient.cs rename src/{PostIt/PostIt/Services => Yavsc.Api.Client}/BlogApiClient.cs (78%) create mode 100644 src/Yavsc.Api.Client/CircleApiClient.cs create mode 100644 src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs create mode 100644 src/Yavsc.Api.Client/Dtos/CircleDto.cs diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs index 68fa514e..1f47a176 100644 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt.Tests/BearerScopeTests.cs @@ -8,6 +8,9 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using PostIt.Services; using Xunit; @@ -119,7 +122,7 @@ public class BearerScopeTests // 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); + var blog = new BlogApiClient(subClient, "http://localhost/"); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index cea3e83f..350a71f1 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -40,7 +41,7 @@ public class MainPageSaveTests // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index d8de8025..b964a18e 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,4 +1,5 @@ using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; @@ -14,7 +15,7 @@ public class PostItViewModelTests // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); - var blog = new BlogApiClient(fakeApi); + var blog = new BlogApiClient(fakeApi, "http://localhost/"); var viewModel = new MainPageViewModel(blog); viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); @@ -46,7 +47,7 @@ public class PostItViewModelTests new() { Id = 2, Title = "World" } }; var api = new StubYavscApiClient(expected); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var posts = await blog.GetPostsAsync(); diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index c020fec9..e54bc541 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -8,6 +8,9 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b5740f2f..4a250ebe 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; using PostIt.Services; +using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; @@ -55,7 +56,7 @@ public partial class App : Application "PostIt", "tokens.json")); var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api); + var client = new BlogApiClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e8024d7d..a9864db0 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; namespace PostIt.ViewModels; diff --git a/src/Yavsc.Api.Client/BlogAclApiClient.cs b/src/Yavsc.Api.Client/BlogAclApiClient.cs new file mode 100644 index 00000000..71263ca4 --- /dev/null +++ b/src/Yavsc.Api.Client/BlogAclApiClient.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/blogacl on the Yavsc Blogs server. +/// +/// Each grants a single +/// Circle access to a single BlogPost. The server +/// scopes every endpoint to the caller's uid: only the author of +/// the underlying blog post can list, create, modify, or delete +/// its ACL entries. +/// +public sealed class BlogAclApiClient +{ + private const string Path = "blogacl"; + + private readonly IYavscApiClient _api; + + public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + public Task> GetMyAclAsync(CancellationToken ct = default) + => _api.CallAsync>(HttpMethod.Get, Path, ct: ct); + + public Task GetAclAsync(long circleId, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{Path}/{circleId}", ct: ct); + + public Task GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, Path, body: acl, ct: ct); + + public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct); + + public Task RevokeAsync(long circleId, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct); +} diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs similarity index 78% rename from src/PostIt/PostIt/Services/BlogApiClient.cs rename to src/Yavsc.Api.Client/BlogApiClient.cs index f2061927..537124a7 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/Yavsc.Api.Client/BlogApiClient.cs @@ -5,15 +5,16 @@ using System.Threading; using System.Threading.Tasks; using Yavsc.Blogspot; -namespace PostIt.Services; +namespace Yavsc.Api.Client; /// /// High-level client for the Blog subsystem of the Yavsc API /// (deployed at https://blogs.pschneider.fr). All transport /// concerns — base URL, JSON serialisation, Bearer auth, silent /// refresh on 401, request body shaping — are delegated to -/// . This class is a thin DTO↔path -/// mapper, nothing more. +/// , which lives in the consuming +/// application (PostIt). This class is a thin DTO↔path mapper, +/// nothing more. /// /// URL convention. 's /// BaseAddress already terminates with /api/v1/ @@ -34,16 +35,20 @@ public sealed class BlogApiClient { private const string DefaultPathPrefix = "blog"; - private readonly YavscApiClient _api; + private readonly IYavscApiClient _api; + private readonly Uri _baseAddress; private readonly string _pathPrefix; - public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) + public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) { _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); - // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the + // 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.BlogsApiUrl); + _baseAddress = new Uri(blogsBaseAddress); + api.Http.BaseAddress = _baseAddress; _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; } diff --git a/src/Yavsc.Api.Client/CircleApiClient.cs b/src/Yavsc.Api.Client/CircleApiClient.cs new file mode 100644 index 00000000..a8b04a40 --- /dev/null +++ b/src/Yavsc.Api.Client/CircleApiClient.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/circle on the Yavsc Blogs server. +/// +/// Same conventions as : all +/// transport is delegated to ; this +/// class only maps paths to DTOs. +/// +/// The server now (since the BlogAcl fix on this branch) +/// scopes every read and write to the caller's uid. There is no +/// way for the client to read or modify another user's circles +/// — the route will return 404 (not 403) when the circle exists +/// but belongs to someone else, to avoid leaking its existence. +/// +public sealed class CircleApiClient +{ + private const string Path = "circle"; + + private readonly IYavscApiClient _api; + + public CircleApiClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + public Task> GetMyCirclesAsync(CancellationToken ct = default) + => _api.CallAsync>(HttpMethod.Get, Path, ct: ct); + + public Task GetCircleAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{Path}/{id}", ct: ct); + + public Task CreateCircleAsync(CircleDto circle, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, Path, body: circle, ct: ct); + + public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct); + + public Task DeleteCircleAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct); +} diff --git a/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs new file mode 100644 index 00000000..f5d1e50e --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs @@ -0,0 +1,19 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/blogacl and friends. +/// +/// The server-side +/// Yavsc.Models.Access.CircleAuthorizationToBlogPost EF entity +/// carries virtual navigation properties (Target, +/// Allowed) that pull in the full BlogPost and Circle graphs. +/// The client never needs them: when showing the ACL of a post, the +/// UI already has the post, and the circles are looked up by id +/// against the list returned by GET /api/circle. +/// +public sealed class CircleAuthorizationDto +{ + public long CircleId { get; set; } + public long BlogPostId { get; set; } + public bool Comment { get; set; } +} diff --git a/src/Yavsc.Api.Client/Dtos/CircleDto.cs b/src/Yavsc.Api.Client/Dtos/CircleDto.cs new file mode 100644 index 00000000..ed6980e2 --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/CircleDto.cs @@ -0,0 +1,23 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/circle and friends. +/// +/// Field names match the JSON the server emits (camelCase via +/// the default policy), so no +/// [JsonPropertyName] attributes are required. +/// +/// Mirrors the server-side Yavsc.Models.Relationship.Circle +/// EF entity but stops short of the navigation properties +/// (Owner, Members) which depend on +/// ApplicationUser and other server-only types. The client +/// only ever needs the id, name, and owner of a circle to drive +/// the UI. +/// +public sealed class CircleDto +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string OwnerId { get; set; } = string.Empty; + public bool Public { get; set; } +} From a5887a2387c5f3e5033d6447a490322a5ea8c674 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:51:51 +0100 Subject: [PATCH 06/77] feat(postit): wire Circle + BlogAcl clients in the DI container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.axaml.cs is the composition root for PostIt. It now also builds and registers: - CircleApiClient (singleton) — backed by the same YavscApiClient and the same blogs base URL as BlogApiClient - BlogAclApiClient (singleton) — same shape - IYavscApiClient -> YavscApiClient mapping (singleton). The concrete class is still resolvable as YavscApiClient; the new registration makes the same instance available as IYavscApiClient so future consumers (and unit tests) can take the interface without coupling to the concrete type. The 3 high-level clients are singletons: they hold no mutable state of their own, just a reference to YavscApiClient and a base URL. Reusing the same instance across requests is what the HttpClient inside YavscApiClient was already designed for. --- src/PostIt/PostIt/App.axaml.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 4a250ebe..033dbd09 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -57,6 +57,8 @@ public partial class App : Application var api = new YavscApiClient(settings, tokenStore); var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); @@ -79,8 +81,11 @@ public partial class App : Application // ViewModels services.AddSingleton(settings); - services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(api); services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); services.AddTransient(); services.AddTransient(); services.AddTransient(); From 0e7576857d70d85666300a55faeb2900f04f1972 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:06:57 +0100 Subject: [PATCH 07/77] feat(postit): UI for managing Circles + per-post ACL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing the user-facing surface for the BlogAcl work. The user can now: 1. Open the 'Mes cercles' page (a new 'Mes cercles' button on the main page) and create / edit / delete their own circles. The page lists circles in an ObservableCollection bound to a ListBox; per-row buttons drive StartEdit and Delete; the bottom editor pushes new / edited circles via the Save command. 2. With a post selected, click the new 'ACL' button to open a modal 'PostAclDialog' for that post. The modal shows the current ACL entries (filtered server-side by Allowed.OwnerId == caller) and a dropdown of the caller's circles to add. Each entry has a 'Revoke' button. Both pages follow the same pattern: - ViewModel uses [ObservableProperty] for state and [RelayCommand] for verbs; IsBusy drives a ProgressBar overlay; StatusMessage surfaces server feedback. - View follows the XAML-Background/Foreground lesson (no hard-coded colours), so dark mode works without contrast surprises. - Code-behind is minimal — just AvaloniaXamlLoader.Load — because navigation is driven by RelayCommand + event (ManageAclRequested, OpenCirclesRequested) that the MainPage code-behind handles via its DataContextChanged handler. The 'complete' scope (c) of this commit was confirmed by Paul. Three follow-up tracks are deliberately out of scope and tracked in MEMORY.md (2026-08-18): - i18n: no .resx / IStringLocalizer today; all visible text is hard-coded French. - Avalonia.Headless UI tests: only ViewModel-level coverage is feasible today; full navigation tests are a separate effort. - XAML accessibility audit of pre-existing pages (Settings, MainPage) that predate the Background/Foreground lesson. Build + 51/51 tests green. --- src/PostIt/PostIt/App.axaml.cs | 2 + .../PostIt/ViewModels/CirclesPageViewModel.cs | 155 +++++++++++++++++ .../PostIt/ViewModels/MainPageViewModel.cs | 28 ++++ .../ViewModels/PostAclDialogViewModel.cs | 157 ++++++++++++++++++ src/PostIt/PostIt/Views/CirclesPage.axaml | 66 ++++++++ src/PostIt/PostIt/Views/CirclesPage.axaml.cs | 18 ++ src/PostIt/PostIt/Views/MainPage.axaml | 2 + src/PostIt/PostIt/Views/MainPage.axaml.cs | 52 ++++++ src/PostIt/PostIt/Views/PostAclDialog.axaml | 65 ++++++++ .../PostIt/Views/PostAclDialog.axaml.cs | 54 ++++++ 10 files changed, 599 insertions(+) create mode 100644 src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs create mode 100644 src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs create mode 100644 src/PostIt/PostIt/Views/CirclesPage.axaml create mode 100644 src/PostIt/PostIt/Views/CirclesPage.axaml.cs create mode 100644 src/PostIt/PostIt/Views/PostAclDialog.axaml create mode 100644 src/PostIt/PostIt/Views/PostAclDialog.axaml.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 033dbd09..e59e0d33 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -78,6 +78,7 @@ public partial class App : Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -89,6 +90,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs new file mode 100644 index 00000000..17d4c4be --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Mes cercles" page. CRUD on the caller's own +/// circles (the server scopes every endpoint to the caller's uid +/// since the BlogAcl fix on this branch). +/// +/// The view lists circles in , supports +/// create / edit via , and exposes +/// per-item Delete and per-item edit commands. +/// drives a progress overlay during API calls; +/// surfaces success / error feedback in the view footer. +/// +public partial class CirclesPageViewModel : ViewModelBase +{ + private readonly CircleApiClient _client; + + [ObservableProperty] + public partial ObservableCollection Circles { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircle { get; set; } + + /// Editor buffer for the new / edited circle's name. + [ObservableProperty] + public partial string DraftName { get; set; } = string.Empty; + + /// Editor buffer for the new / edited circle's visibility flag. + [ObservableProperty] + public partial bool DraftPublic { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public CirclesPageViewModel(CircleApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var list = await _client.GetMyCirclesAsync(); + Circles = new ObservableCollection(list ?? new()); + StatusMessage = $"{Circles.Count} cercle(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void StartCreate() + { + SelectedCircle = null; + DraftName = string.Empty; + DraftPublic = false; + StatusMessage = "Nouveau cercle"; + } + + [RelayCommand] + public void StartEdit(CircleDto? circle) + { + if (circle is null) return; + SelectedCircle = circle; + DraftName = circle.Name; + DraftPublic = circle.Public; + StatusMessage = $"Édition de « {circle.Name} »"; + } + + [RelayCommand] + public async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(DraftName)) + { + StatusMessage = "Le nom est obligatoire"; + return; + } + + IsBusy = true; + try + { + if (SelectedCircle is null) + { + var created = await _client.CreateCircleAsync(new CircleDto + { + Name = DraftName.Trim(), + Public = DraftPublic, + }); + StatusMessage = created is null + ? "Création échouée" + : $"Cercle « {created.Name} » créé"; + } + else + { + SelectedCircle.Name = DraftName.Trim(); + SelectedCircle.Public = DraftPublic; + await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle); + StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour"; + } + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task DeleteAsync(CircleDto? circle) + { + if (circle is null) return; + IsBusy = true; + try + { + await _client.DeleteCircleAsync(circle.Id); + StatusMessage = $"Cercle « {circle.Name} » supprimé"; + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index a9864db0..ddf6a732 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -317,4 +317,32 @@ public partial class MainPageViewModel : ViewModelBase /// forced the buggy "draft with empty title" branch. private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + + /// + /// Raised when the user asks to open the "manage ACL" dialog for + /// the currently selected post. The MainPage code-behind + /// listens to this event and pushes a PostAclDialog on the + /// navigation stack. The VM itself can't navigate directly + /// because the navigation surface (NavigationPage) lives + /// in the View layer. + /// + public event EventHandler? ManageAclRequested; + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public void ManageAcl() + { + if (SelectedPost is null) return; + ManageAclRequested?.Invoke(this, SelectedPost); + } + + /// + /// Raised when the user asks to open the circles page (full + /// CRUD on their own circles). Same routing as + /// . + /// + public event EventHandler? OpenCirclesRequested; + + [RelayCommand] + public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs new file mode 100644 index 00000000..476a6b9a --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Gérer l'ACL" modal of a single blog post. +/// +/// Loads the caller's circles once on construct (the dropdown +/// only shows circles the user owns), then keeps an in-memory list +/// of the ACL entries for the post. / +/// are the only mutating verbs; both +/// refresh the list afterwards so the UI stays in sync with the +/// server. +/// +/// The server is the source of truth: it scopes every +/// endpoint to the caller's uid and rejects ACL grants on posts +/// the caller doesn't own. This VM does not re-validate that — +/// any 403 / 404 will surface as an exception caught by the +/// command and routed to . +/// +public partial class PostAclDialogViewModel : ViewModelBase +{ + private readonly BlogAclApiClient _aclClient; + private readonly CircleApiClient _circleClient; + + /// The post whose ACL is being edited. Set by the + /// caller (MainPage) when opening the dialog. + public BlogPost Post { get; } + + [ObservableProperty] + public partial ObservableCollection MyCircles { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection AclEntries { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircleToAdd { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public PostAclDialogViewModel( + BlogPost post, + BlogAclApiClient aclClient, + CircleApiClient circleClient) + { + Post = post ?? throw new ArgumentNullException(nameof(post)); + _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); + _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + [RelayCommand] + public async Task LoadAsync() + { + IsBusy = true; + try + { + // Load circles and ACL entries in parallel — both are + // independent reads on the same host. The caller's uid + // is implicit in both endpoints. + var circlesTask = _circleClient.GetMyCirclesAsync(); + var aclTask = _aclClient.GetMyAclAsync(); + await Task.WhenAll(circlesTask, aclTask); + + var circles = circlesTask.Result ?? new List(); + MyCircles = new ObservableCollection(circles); + + var allAcl = aclTask.Result ?? new List(); + AclEntries = new ObservableCollection( + allAcl.Where(a => a.BlogPostId == Post.Id)); + + StatusMessage = $"{AclEntries.Count} autorisation(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task AddAsync() + { + if (SelectedCircleToAdd is null) + { + StatusMessage = "Sélectionnez un cercle à ajouter"; + return; + } + + IsBusy = true; + try + { + var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + { + CircleId = SelectedCircleToAdd.Id, + BlogPostId = Post.Id, + Comment = false, + }); + if (created is not null) + { + AclEntries.Add(created); + StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; + } + else + { + StatusMessage = "Autorisation refusée par le serveur"; + } + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task RevokeAsync(CircleAuthorizationDto? acl) + { + if (acl is null) return; + IsBusy = true; + try + { + await _aclClient.RevokeAsync(acl.CircleId); + AclEntries.Remove(acl); + StatusMessage = "Autorisation révoquée"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/CirclesPage.axaml new file mode 100644 index 00000000..d9320eb2 --- /dev/null +++ b/src/PostIt/PostIt/Views/CirclesPage.axaml @@ -0,0 +1,66 @@ + + + + + +