From ef59cd17356cf9b2d6cb74b529a6a76a886953bc Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 14:02:07 +0100 Subject: [PATCH 1/2] fix(circle-api): use User.GetUserId() for owner scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scoping that landed in e376aed8 ("restrict Circle + BlogAcl reads and writes to caller's own data") reads the caller's uid with User.FindFirstValue(ClaimTypes.NameIdentifier). That works when the JWT bearer middleware remaps the "sub" claim to the long ClaimTypes.NameIdentifier URI — which is the default behaviour. But the BlogsWebServerFixture test host and any host that sets MapInboundClaims = false (preserved here to keep "sub" as "sub" for the resource-based ownership checks in BlogSpotService) end up with no ClaimTypes.NameIdentifier claim at all, only "sub". On those hosts, every OwnerId == uid filter silently returns nothing, so the controller responds 404 even for the caller's own circles. Switch to the canonical User.GetUserId() extension helper (Yavsc.Server.Helpers.UserHelpers), which tries "sub" first, then ClaimTypes.NameIdentifier, then "nameid". This aligns CircleApiController with BlogApiController (which already uses GetUserId()) and restores correct behaviour on hosts that run with MapInboundClaims = false. Drop the now-unused System.Security.Claims using. No behavioural change for production: there, MapInboundClaims remains true, ClaimTypes.NameIdentifier is populated, and GetUserId() returns the same value as FindFirstValue would have. --- src/Yavsc.Blogs/Controllers/CircleApiController.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index 368b488a..71ae997e 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,5 +1,4 @@ using System.Linq; -using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; @@ -27,7 +26,7 @@ namespace Yavsc.Blogs.Controllers [HttpGet] public IEnumerable GetCircle() { - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var uid = User.GetUserId(); return _context.Circle.Where(c => c.OwnerId == uid); } @@ -43,7 +42,7 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var uid = User.GetUserId(); Circle circle = await _context.Circle.SingleOrDefaultAsync( m => m.Id == id && m.OwnerId == uid); @@ -74,7 +73,7 @@ namespace Yavsc.Blogs.Controllers return BadRequest(); } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var uid = User.GetUserId(); var existing = await _context.Circle.SingleOrDefaultAsync( c => c.Id == id && c.OwnerId == uid); if (existing is null) @@ -118,7 +117,7 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var uid = User.GetUserId(); circle.OwnerId = uid; _context.Circle.Add(circle); @@ -156,7 +155,7 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var uid = User.GetUserId(); Circle circle = await _context.Circle.SingleOrDefaultAsync( m => m.Id == id && m.OwnerId == uid); if (circle == null) From 5e3d361f88e4879dc3dd1e1114e69033231ca4f5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 14:10:12 +0100 Subject: [PATCH 2/2] feat(acl): server endpoints + client + UI for circle membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip out a long-standing hole in the ACL feature: until this commit a circle on Yavsc was an empty named bucket. You could create 'Famille' and grant it on a post, but the circle carried no members, so 'Famille' authorised no one. The MVC admin controller (Yavsc.Org.Controllers.CircleMembersController) existed but had no REST counterpart, so PostIt — which only talks to the Yavsc.Blogs API — had no way to manage membership at all. This commit closes that gap end-to-end: Server (Yavsc.Blogs) - GET /api/circle/{id}/members list members - POST /api/circle/{id}/members add a user (body { userId }) - DELETE /api/circle/{id}/members/{userId} remove a user All three are scoped to caller == circle.OwnerId; non-owned circles return 404 (not 403) to avoid leaking existence, in line with the rest of the controller. - Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the wire shapes. CircleMemberDto mirrors UserSearchResultDto minus Email — membership UI doesn't need contact details. Tests (Yavsc.Blogs.Tests) - CircleMembersApiTests: 5 [Fact] covering empty list, add+get, duplicate add → 409, remove, and cross-owner 404. Test users (alice, bob) are seeded directly through the in-memory DbContext — the Blogs fixture doesn't stand up UserManager. Client (Yavsc.Api.Client) - CircleMemberDto + 3 methods on CircleApiClient: GetMembersAsync, AddMemberAsync, RemoveMemberAsync. All match the server's contract: 404 flattens to null, 409 surfaces as an exception (callers can dedupe beforehand if they want idempotent behaviour). UI (PostIt) - CirclesPageViewModel gains a Members ObservableCollection that auto-loads on SelectedCircle change (via the partial setter generated by [ObservableProperty]). Commands: LoadMembersAsync, OpenAddMember (raises an event the view subscribes to), OnAddMemberConfirmedAsync (called by the view when the dialog confirms a selection), RemoveMemberAsync. 409 (already a member) is detected from the exception message and surfaced as a friendly status rather than an error — a likely race when the same user gets added twice through two UI paths. - CirclesPage layout is now two-pane (circles + editor on the left, members of the selected circle on the right). The member pane has an 'Ajouter un membre' button that opens AddCircleMemberDialog. Code-behind wires the dialog's Confirmed event back into the VM via an async lambda wrapper (EventHandler wants void, the VM method is async Task). - AddCircleMemberDialog is a ContentPage (light modal, same pattern as PostAclDialog). Its ViewModel consumes IUserDirectory — the abstraction introduced by 04a31709 to fix the 'user search should not be IContactService' confusion. The dialog raises Confirmed with the picked UserSummary; the host (CirclesPage) is responsible for calling CircleApiClient.AddMemberAsync. Out of scope (tracked in MEMORY.md, 2026-08-18): - i18n: all visible text still hard-coded French. - XAML accessibility audit of pre-existing pages. - Avalonia.Headless UI tests of the new navigation flow. Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests green (was 15; +5 for CircleMembersApiTests), 44/44 Yavsc.Org.Tests green (no regression). --- .../AddCircleMemberDialogViewModel.cs | 118 +++++++++++ .../PostIt/ViewModels/CirclesPageViewModel.cs | 157 +++++++++++++- .../PostIt/Views/AddCircleMemberDialog.axaml | 57 +++++ .../Views/AddCircleMemberDialog.axaml.cs | 54 +++++ src/PostIt/PostIt/Views/CirclesPage.axaml | 119 +++++++---- src/PostIt/PostIt/Views/CirclesPage.axaml.cs | 41 ++++ src/Yavsc.Api.Client/CircleApiClient.cs | 32 +++ src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs | 21 ++ .../CircleMembersApiTests.cs | 199 ++++++++++++++++++ .../Controllers/CircleApiController.cs | 163 ++++++++++++++ 10 files changed, 923 insertions(+), 38 deletions(-) create mode 100644 src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs create mode 100644 src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml create mode 100644 src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs create mode 100644 src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs create mode 100644 src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs diff --git a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs new file mode 100644 index 00000000..a721d738 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// View model for the "add a Yavsc user to a circle" modal. +/// +/// Resolves users through +/// (which delegates to /api/user-search); the caller +/// (CirclesPage) decides whether to add the picked user to +/// the circle by calling +/// +/// (which is bound to the dialog's "Ajouter" button). +/// +/// The dialog itself doesn't know the target +/// CircleId: that's set by the caller via the +/// constructor and the dialog only triggers +/// against the +/// string. The "Add" command +/// returns the picked via the +/// event, and the hosting +/// CirclesPage then calls +/// . +/// +public partial class AddCircleMemberDialogViewModel : ViewModelBase +{ + private readonly IUserDirectory _directory; + + [ObservableProperty] + public partial string SearchQuery { get; set; } = string.Empty; + + [ObservableProperty] + public partial ObservableCollection Results { get; set; } = new(); + + [ObservableProperty] + public partial UserSummary? Selected { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + /// + /// Raised when the user confirms a selection. The hosting + /// CirclesPage subscribes to this event and calls + /// CircleApiClient.AddMemberAsync with the target + /// circle id + the picked user's id. The dialog itself + /// does not know the circle id by design: separation of + /// concerns — the modal is a user picker, not a + /// "circle joiner" form. + /// + public event EventHandler? Confirmed; + + public AddCircleMemberDialogViewModel(IUserDirectory directory) + { + _directory = directory ?? throw new ArgumentNullException(nameof(directory)); + } + + 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(); } + + /// + /// Search the directory for users matching the current + /// . Triggered explicitly via the + /// "Rechercher" button — no debouncing, so the caller + /// stays in control of how often the network is hit. + /// + [RelayCommand] + public async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(SearchQuery)) + { + Results.Clear(); + StatusMessage = "Tapez un nom ou un email"; + return; + } + + IsBusy = true; + try + { + var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true); + Results = new ObservableCollection(hits ?? Array.Empty()); + StatusMessage = $"{Results.Count} résultat(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + /// + /// Raise for the currently selected + /// user. No-op when no selection has been made — keeps the + /// UI from firing an event with a null payload. + /// + [RelayCommand] + public void Add() + { + if (Selected is null) + { + StatusMessage = "Sélectionnez un utilisateur"; + return; + } + Confirmed?.Invoke(this, Selected); + } +} diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs index 17d4c4be..c017c426 100644 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -1,8 +1,10 @@ using System; using System.Collections.ObjectModel; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using PostIt.Services; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; @@ -11,13 +13,24 @@ 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). +/// since the BlogAcl fix on this branch), plus membership +/// management on the currently selected circle. /// /// 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. +/// +/// When the user selects a circle in the list, +/// fetches its members into +/// . The "Add a member" command +/// () is a UI event the view +/// raises to open AddCircleMemberDialog; the dialog +/// raises a Confirmed event back, which the page's +/// code-behind forwards here via +/// . The "remove" +/// command is per-row and runs inline. /// public partial class CirclesPageViewModel : ViewModelBase { @@ -37,12 +50,26 @@ public partial class CirclesPageViewModel : ViewModelBase [ObservableProperty] public partial bool DraftPublic { get; set; } + /// Members of the currently selected circle. Empty + /// when no circle is selected or after a refresh that + /// produced an empty list. Updated by + /// . + [ObservableProperty] + public partial ObservableCollection Members { get; set; } = new(); + [ObservableProperty] public partial bool IsBusy { get; set; } [ObservableProperty] public partial string StatusMessage { get; set; } = string.Empty; + /// + /// Raised when the user wants to add a member to the + /// currently selected circle. The view listens to this + /// event and opens AddCircleMemberDialog. + /// + public event EventHandler? AddMemberRequested; + public CirclesPageViewModel(CircleApiClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); @@ -51,6 +78,24 @@ public partial class CirclesPageViewModel : ViewModelBase 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(); } + /// + /// Partial property setter: when the selected circle + /// changes, refresh the members list. The setter is + /// invoked by the [ObservableProperty] source generator + /// for both user selections and programmatic resets. + /// + partial void OnSelectedCircleChanged(CircleDto? value) + { + Members = new ObservableCollection(); + if (value is not null) + { + // Fire-and-forget: load members in the background. + // Errors are routed to StatusMessage inside + // LoadMembersAsync. + _ = LoadMembersAsync(value.Id); + } + } + [RelayCommand] public async Task RefreshAsync() { @@ -71,6 +116,33 @@ public partial class CirclesPageViewModel : ViewModelBase } } + /// + /// Load the members of one of the caller's circles. The + /// server scopes the endpoint with a 404 when the circle + /// doesn't belong to the caller (mirroring the rest of the + /// circle API); that case flattens to an empty list here. + /// + [RelayCommand] + public async Task LoadMembersAsync(long circleId) + { + IsBusy = true; + try + { + var list = await _client.GetMembersAsync(circleId); + Members = new ObservableCollection(list ?? new()); + StatusMessage = $"{Members.Count} membre(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + Members = new ObservableCollection(); + } + finally + { + IsBusy = false; + } + } + [RelayCommand] public void StartCreate() { @@ -141,6 +213,12 @@ public partial class CirclesPageViewModel : ViewModelBase { await _client.DeleteCircleAsync(circle.Id); StatusMessage = $"Cercle « {circle.Name} » supprimé"; + // If the deleted circle was the selected one, + // clear the selection so the Members view goes + // empty too (the partial setter on + // SelectedCircle will reset Members). + if (SelectedCircle?.Id == circle.Id) + SelectedCircle = null; await RefreshAsync(); } catch (Exception ex) @@ -152,4 +230,81 @@ public partial class CirclesPageViewModel : ViewModelBase IsBusy = false; } } + + /// + /// Fire the event so + /// the view opens AddCircleMemberDialog. The view + /// forwards the dialog's Confirmed event back to + /// . + /// + [RelayCommand] + public void OpenAddMember() + { + if (SelectedCircle is null) + { + StatusMessage = "Sélectionnez d'abord un cercle"; + return; + } + AddMemberRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Called by the view when the dialog confirms a + /// selection. Adds the picked user to the currently + /// selected circle and refreshes the members list. + /// + public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked) + { + if (SelectedCircle is null || picked is null) return; + IsBusy = true; + try + { + await _client.AddMemberAsync(SelectedCircle.Id, picked.Id); + StatusMessage = $"« {picked.DisplayName} » ajouté au cercle"; + await LoadMembersAsync(SelectedCircle.Id); + } + catch (Exception ex) + { + // 409 (already a member) is a likely race — surface + // it as a friendly status, not an error. The + // server returns 409 for "already a member"; + // YavscApiClient surfaces that as an exception + // today; future refactors could route 409 into a + // typed result, but for now the message string is + // distinctive enough. + var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict") + ? "Déjà membre du cercle" + : $"Erreur: {ex.Message}"; + StatusMessage = msg; + } + finally + { + IsBusy = false; + } + } + + /// + /// Per-row "remove" command. Updates the local + /// collection in place so the UI doesn't flash. + /// + [RelayCommand] + public async Task RemoveMemberAsync(CircleMemberDto? member) + { + if (member is null || SelectedCircle is null) return; + IsBusy = true; + try + { + await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id); + Members.Remove(member); + StatusMessage = $"« {member.UserName} » retiré du cercle"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } } diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml new file mode 100644 index 00000000..2c13e99c --- /dev/null +++ b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml @@ -0,0 +1,57 @@ + + + + + + +