feat(acl): server endpoints + client + UI for circle membership

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).
This commit is contained in:
Paul Schneider 2026-08-18 14:10:12 +01:00
commit 5e3d361f88
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
10 changed files with 923 additions and 38 deletions

View file

@ -169,6 +169,143 @@ namespace Yavsc.Blogs.Controllers
return Ok(circle);
}
/// <summary>
/// Returns the members of one of the caller's circles.
/// Returns 404 (not 403) when the circle does not exist
/// or is not owned by the caller, mirroring the scoping
/// of the rest of this controller.
/// </summary>
// GET: api/circle/5/members
[HttpGet("{id}/members")]
public async Task<IActionResult> GetMembers([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
var members = await _context.CircleMembers
.Where(m => m.CircleId == id)
.Select(m => new CircleMemberDto
{
Id = m.MemberId,
UserName = m.Member.UserName ?? string.Empty,
FullName = m.Member.FullName,
Avatar = m.Member.Avatar,
})
.ToListAsync();
return Ok(members);
}
/// <summary>
/// Adds a Yavsc user to one of the caller's circles. The
/// body carries the user id (resolved client-side via the
/// central <c>/api/user-search</c> endpoint). Returns
/// 404 (not 403) when the circle does not exist or is not
/// owned by the caller, and 404 when the target user does
/// not exist, so the caller can't probe whether an email
/// belongs to a real account.
///
/// <para>Returns 409 Conflict if the user is already a
/// member of the circle; the client treats this as a
/// no-op success.</para>
/// </summary>
// POST: api/circle/5/members
// body: { "userId": "..." }
[HttpPost("{id}/members")]
public async Task<IActionResult> AddMember(
[FromRoute] long id,
[FromBody] AddCircleMemberDto body)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
// Reject unknown user ids the same way as an unknown
// circle: 404. Probing the user table by id should not
// be possible through this endpoint.
var userExists = await _context.Users.AnyAsync(u => u.Id == body.UserId);
if (!userExists)
{
return NotFound();
}
// Idempotency: re-adding an existing member is a
// 409, not a silent success. Clients that don't
// dedupe beforehand will at least get an actionable
// status code rather than a misleading "created".
var alreadyMember = await _context.CircleMembers.AnyAsync(
m => m.CircleId == id && m.MemberId == body.UserId);
if (alreadyMember)
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
_context.CircleMembers.Add(new CircleMember
{
CircleId = id,
MemberId = body.UserId,
});
await _context.SaveChangesAsync(User.GetUserId());
return CreatedAtRoute("GetCircle", new { id }, body);
}
/// <summary>
/// Removes a user from one of the caller's circles.
/// Returns 404 when the circle does not exist or is not
/// owned by the caller, mirroring the rest of this
/// controller's scoping. Returns 404 when the user is
/// not a member of the circle (idempotent: removing a
/// non-member is the same as having nothing to remove).
/// </summary>
// DELETE: api/circle/5/members/tester
[HttpDelete("{id}/members/{userId}")]
public async Task<IActionResult> RemoveMember(
[FromRoute] long id,
[FromRoute] string userId)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid);
if (!ownsIt)
{
return NotFound();
}
var membership = await _context.CircleMembers.SingleOrDefaultAsync(
m => m.CircleId == id && m.MemberId == userId);
if (membership is null)
{
return NotFound();
}
_context.CircleMembers.Remove(membership);
await _context.SaveChangesAsync(User.GetUserId());
return Ok();
}
protected override void Dispose(bool disposing)
{
if (disposing)
@ -183,4 +320,30 @@ namespace Yavsc.Blogs.Controllers
return _context.Circle.Count(e => e.Id == id) > 0;
}
}
/// <summary>
/// Wire shape for <c>GET /api/circle/{id}/members</c>.
/// Mirrors <see cref="UserSearchResultDto"/> but stops
/// short of the Email field — circle membership UI only
/// needs to render a name and an avatar, not contact
/// details.
/// </summary>
public sealed class CircleMemberDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
}
/// <summary>
/// Wire shape for <c>POST /api/circle/{id}/members</c>.
/// The body is intentionally tiny: the client resolves
/// the user id via <c>/api/user-search</c> before
/// posting, so all we need is the resolved id.
/// </summary>
public sealed class AddCircleMemberDto
{
public string UserId { get; set; } = string.Empty;
}
}