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<ApplicationUser>.
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<T> 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).
85 lines
3.7 KiB
C#
85 lines
3.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
|
|
///
|
|
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
|
|
/// transport is delegated to <see cref="YavscApiClient"/>; this
|
|
/// class only maps paths to DTOs.</para>
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
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<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
|
|
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
|
|
|
|
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
|
|
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
|
|
|
|
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
|
|
=> _api.CallAsync<CircleDto?>(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);
|
|
|
|
/// <summary>
|
|
/// Returns the members of one of the caller's circles.
|
|
/// Returns null when the circle does not exist or is not
|
|
/// owned by the caller (the server scopes the endpoint
|
|
/// with a 404 in either case to avoid leaking existence
|
|
/// — this client flattens that into a null result).
|
|
/// </summary>
|
|
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
|
|
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
|
|
|
|
/// <summary>
|
|
/// Adds a Yavsc user (resolved client-side via
|
|
/// <c>/api/user-search</c>) to one of the caller's
|
|
/// circles. Returns null when the circle does not exist
|
|
/// or is not owned by the caller, or when the target
|
|
/// user does not exist. Throws on 409 (already a
|
|
/// member) — callers that want idempotent behaviour
|
|
/// can swallow the exception or dedupe beforehand.
|
|
/// </summary>
|
|
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
|
|
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
|
|
body: new { userId }, ct: ct);
|
|
|
|
/// <summary>
|
|
/// Removes a user from one of the caller's circles.
|
|
/// Returns null on success (the server returns 200 OK
|
|
/// with no body) or when the membership does not
|
|
/// exist — both treated as success by the caller.
|
|
/// </summary>
|
|
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
|
|
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
|
|
}
|