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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
new file mode 100644
index 00000000..5bca562e
--- /dev/null
+++ b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
@@ -0,0 +1,54 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Interactivity;
+using PostIt.Services;
+using PostIt.ViewModels;
+
+namespace PostIt.Views;
+
+///
+/// Modal "add a member to a circle" page. Hosted by
+/// CirclesPage; the caller passes the resolved
+/// via the constructor.
+///
+/// The dialog raises Confirmed on its ViewModel
+/// when the user picks a result and clicks "Ajouter"; the
+/// hosting page subscribes to that event and calls
+/// CircleApiClient.AddMemberAsync with the target
+/// circle id. The dialog itself does not know the circle id
+/// by design.
+///
+public partial class AddCircleMemberDialog : ContentPage
+{
+ public AddCircleMemberDialog()
+ {
+ InitializeComponent();
+ }
+
+ public AddCircleMemberDialog(IUserDirectory directory)
+ {
+ InitializeComponent();
+ DataContext = new AddCircleMemberDialogViewModel(directory);
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ ///
+ /// Subscribe a handler to be notified when the user
+ /// confirms a selection. Returns the underlying VM so
+ /// the caller can also drive further state (clear the
+ /// selection, close the dialog, refresh its own list).
+ ///
+ public AddCircleMemberDialogViewModel? ViewModel
+ => DataContext as AddCircleMemberDialogViewModel;
+
+ private void OnCloseClicked(object? sender, RoutedEventArgs e)
+ {
+ // Same light-modal pattern as PostAclDialog: rely on
+ // the system back gesture or the navigation host's
+ // "pop" — the ContentPage doesn't own the back stack.
+ }
+}
diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/CirclesPage.axaml
index d9320eb2..3e156940 100644
--- a/src/PostIt/PostIt/Views/CirclesPage.axaml
+++ b/src/PostIt/PostIt/Views/CirclesPage.axaml
@@ -6,7 +6,7 @@
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
-
+
@@ -16,46 +16,91 @@
Command="{Binding StartCreateCommand}"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ wants a
+ // void return, so wrap the async VM method in a fire-
+ // and-forget helper.
+ var directory = services.GetRequiredService();
+ var dialog = new AddCircleMemberDialog(directory);
+ dialog.ViewModel!.Confirmed += async (sender, picked) =>
+ await _vm.OnAddMemberConfirmedAsync(sender, picked);
+
+ if (this.VisualRoot is MainWindow window)
+ _ = window.NavRoot.PushAsync(dialog);
}
private void InitializeComponent()
diff --git a/src/Yavsc.Api.Client/CircleApiClient.cs b/src/Yavsc.Api.Client/CircleApiClient.cs
index a8b04a40..0b7fb301 100644
--- a/src/Yavsc.Api.Client/CircleApiClient.cs
+++ b/src/Yavsc.Api.Client/CircleApiClient.cs
@@ -50,4 +50,36 @@ public sealed class CircleApiClient
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
+
+ ///
+ /// 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).
+ ///
+ public Task?> GetMembersAsync(long id, CancellationToken ct = default)
+ => _api.CallAsync?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
+
+ ///
+ /// Adds a Yavsc user (resolved client-side via
+ /// /api/user-search) 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.
+ ///
+ public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
+ body: new { userId }, ct: ct);
+
+ ///
+ /// 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.
+ ///
+ public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
}
diff --git a/src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs b/src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs
new file mode 100644
index 00000000..6373ab66
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs
@@ -0,0 +1,21 @@
+namespace Yavsc.Api.Client.Dtos;
+
+///
+/// Wire format for GET /api/circle/{id}/members.
+///
+/// Mirrors the server-side
+/// Yavsc.Blogs.Controllers.CircleMemberDto. Intentionally
+/// stops short of the Email field that
+/// carries — the circle
+/// membership UI only needs a name and an avatar to render the
+/// list. If the future ACL UI wants contact details, it can
+/// fall back to 's other
+/// endpoints rather than widening this shape.
+///
+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; }
+}
diff --git a/src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs b/src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
new file mode 100644
index 00000000..4e9bfb00
--- /dev/null
+++ b/src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
@@ -0,0 +1,199 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Models;
+using Yavsc.Models.Relationship;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Blogs.Tests;
+
+///
+/// Behavioural tests for the circle-members endpoints on
+/// CircleApiController:
+/// GET /api/circle/{id}/members,
+/// POST /api/circle/{id}/members,
+/// DELETE /api/circle/{id}/members/{userId}.
+///
+/// Same fixture as :
+/// provides an in-memory
+/// ApplicationDbContext, JWT bearer auth with HS256,
+/// and the production BlogScope policy. Tests use
+/// TestTokenIssuer to mint tokens whose sub
+/// claim identifies the caller.
+///
+/// Test users (alice, bob) are seeded
+/// directly via :
+/// the Blogs fixture doesn't stand up
+/// UserManager<ApplicationUser>, so we go
+/// through the DbContext the same way the production code
+/// would.
+///
+[Collection("JwtClaimMapping")]
+public sealed class CircleMembersApiTests : IClassFixture
+{
+ private readonly BlogsWebServerFixture _fixture;
+
+ public CircleMembersApiTests(BlogsWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ /// Reset the in-memory database and seed
+ /// alice + bob. UseInMemoryDatabase
+ /// shares its store across the fixture lifetime, so each
+ /// test starts from a clean slate.
+ private void ResetDatabaseWithUsers()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureDeleted();
+ db.Database.EnsureCreated();
+
+ db.Users.Add(new ApplicationUser
+ {
+ Id = "alice",
+ UserName = "alice",
+ Email = "alice@example.com",
+ EmailConfirmed = true,
+ FullName = "Alice Dupont",
+ Avatar = "/avatars/alice.png",
+ });
+ db.Users.Add(new ApplicationUser
+ {
+ Id = "bob",
+ UserName = "bob",
+ Email = "bob@example.com",
+ EmailConfirmed = true,
+ FullName = "Bob Martin",
+ Avatar = "/avatars/bob.png",
+ });
+ db.SaveChanges();
+ }
+
+ /// Create a circle owned by
+ /// directly in the in-memory store and return its server-assigned
+ /// id. The tests below use this to bypass the controller's POST
+ /// (which is already covered by other tests on the branch);
+ /// the focus here is the members endpoints.
+ private long SeedCircle(string ownerId, string name)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var circle = new Circle { OwnerId = ownerId, Name = name };
+ db.Circle.Add(circle);
+ db.SaveChanges();
+ return circle.Id;
+ }
+
+ private string MembersUrl(long circleId)
+ => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/circle/{circleId}/members";
+
+ private HttpClient NewClient(string subject)
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue(
+ "Bearer", TestTokenIssuer.Issue(subject));
+ return http;
+ }
+
+ [Fact]
+ public async Task GetMembers_returns_200_with_empty_list_when_no_members()
+ {
+ ResetDatabaseWithUsers();
+ var circleId = SeedCircle("alice", "Famille");
+ using var http = NewClient("alice");
+
+ var response = await http.GetAsync(MembersUrl(circleId));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
+ Assert.Equal(0, doc.RootElement.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task PostMember_returns_201_then_Get_returns_the_member()
+ {
+ ResetDatabaseWithUsers();
+ var circleId = SeedCircle("alice", "Famille");
+ using var http = NewClient("alice");
+
+ var postResponse = await http.PostAsJsonAsync(
+ MembersUrl(circleId),
+ new { userId = "bob" });
+
+ Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
+
+ var getResponse = await http.GetAsync(MembersUrl(circleId));
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
+ Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
+ Assert.Equal(1, doc.RootElement.GetArrayLength());
+ var member = doc.RootElement[0];
+ Assert.Equal("bob", member.GetProperty("id").GetString());
+ Assert.Equal("bob", member.GetProperty("userName").GetString());
+ Assert.Equal("Bob Martin", member.GetProperty("fullName").GetString());
+ }
+
+ [Fact]
+ public async Task PostMember_returns_409_when_user_already_in_circle()
+ {
+ ResetDatabaseWithUsers();
+ var circleId = SeedCircle("alice", "Famille");
+ using var http = NewClient("alice");
+
+ var first = await http.PostAsJsonAsync(
+ MembersUrl(circleId),
+ new { userId = "bob" });
+ Assert.Equal(HttpStatusCode.Created, first.StatusCode);
+
+ var second = await http.PostAsJsonAsync(
+ MembersUrl(circleId),
+ new { userId = "bob" });
+ Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeleteMember_returns_200_then_Get_does_not_include_member()
+ {
+ ResetDatabaseWithUsers();
+ var circleId = SeedCircle("alice", "Famille");
+ using var http = NewClient("alice");
+
+ await http.PostAsJsonAsync(MembersUrl(circleId), new { userId = "bob" });
+
+ var deleteResponse = await http.DeleteAsync(
+ $"{MembersUrl(circleId)}/bob");
+ Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
+
+ var getResponse = await http.GetAsync(MembersUrl(circleId));
+ using var doc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync());
+ Assert.Equal(0, doc.RootElement.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task GetMembers_returns_404_when_circle_not_owned_by_caller()
+ {
+ ResetDatabaseWithUsers();
+ // Alice's circle, Bob tries to read its members.
+ var circleId = SeedCircle("alice", "Famille");
+ using var http = NewClient("bob");
+
+ var response = await http.GetAsync(MembersUrl(circleId));
+
+ // 404, not 403 — the controller deliberately avoids leaking
+ // the existence of someone else's circle.
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs
index 368b488a..c35da7c5 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)
@@ -170,6 +169,143 @@ namespace Yavsc.Blogs.Controllers
return Ok(circle);
}
+ ///
+ /// 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.
+ ///
+ // GET: api/circle/5/members
+ [HttpGet("{id}/members")]
+ public async Task 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);
+ }
+
+ ///
+ /// Adds a Yavsc user to one of the caller's circles. The
+ /// body carries the user id (resolved client-side via the
+ /// central /api/user-search 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.
+ ///
+ /// Returns 409 Conflict if the user is already a
+ /// member of the circle; the client treats this as a
+ /// no-op success.
+ ///
+ // POST: api/circle/5/members
+ // body: { "userId": "..." }
+ [HttpPost("{id}/members")]
+ public async Task 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);
+ }
+
+ ///
+ /// 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).
+ ///
+ // DELETE: api/circle/5/members/tester
+ [HttpDelete("{id}/members/{userId}")]
+ public async Task 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)
@@ -184,4 +320,30 @@ namespace Yavsc.Blogs.Controllers
return _context.Circle.Count(e => e.Id == id) > 0;
}
}
+
+ ///
+ /// Wire shape for GET /api/circle/{id}/members.
+ /// Mirrors but stops
+ /// short of the Email field — circle membership UI only
+ /// needs to render a name and an avatar, not contact
+ /// details.
+ ///
+ 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; }
+ }
+
+ ///
+ /// Wire shape for POST /api/circle/{id}/members.
+ /// The body is intentionally tiny: the client resolves
+ /// the user id via /api/user-search before
+ /// posting, so all we need is the resolved id.
+ ///
+ public sealed class AddCircleMemberDto
+ {
+ public string UserId { get; set; } = string.Empty;
+ }
}