From 5e3d361f88e4879dc3dd1e1114e69033231ca4f5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 14:10:12 +0100 Subject: [PATCH] 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 @@ + + + + + + +