diff --git a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs
deleted file mode 100644
index a721d738..00000000
--- a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-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 c017c426..17d4c4be 100644
--- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs
@@ -1,10 +1,8 @@
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;
@@ -13,24 +11,13 @@ 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), plus membership
-/// management on the currently selected circle.
+/// since the BlogAcl fix on this branch).
///
/// 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
{
@@ -50,26 +37,12 @@ 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));
@@ -78,24 +51,6 @@ 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()
{
@@ -116,33 +71,6 @@ 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()
{
@@ -213,12 +141,6 @@ 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)
@@ -230,81 +152,4 @@ 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
deleted file mode 100644
index 2c13e99c..00000000
--- a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
deleted file mode 100644
index 5bca562e..00000000
--- a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-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 3e156940..d9320eb2 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,91 +16,46 @@
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 0b7fb301..a8b04a40 100644
--- a/src/Yavsc.Api.Client/CircleApiClient.cs
+++ b/src/Yavsc.Api.Client/CircleApiClient.cs
@@ -50,36 +50,4 @@ 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
deleted file mode 100644
index 6373ab66..00000000
--- a/src/Yavsc.Api.Client/Dtos/CircleMemberDto.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 4e9bfb00..00000000
--- a/src/Yavsc.Blogs.Tests/CircleMembersApiTests.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-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 c35da7c5..368b488a 100644
--- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs
@@ -1,4 +1,5 @@
using System.Linq;
+using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
@@ -26,7 +27,7 @@ namespace Yavsc.Blogs.Controllers
[HttpGet]
public IEnumerable GetCircle()
{
- var uid = User.GetUserId();
+ var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
return _context.Circle.Where(c => c.OwnerId == uid);
}
@@ -42,7 +43,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(ModelState);
}
- var uid = User.GetUserId();
+ var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
Circle circle = await _context.Circle.SingleOrDefaultAsync(
m => m.Id == id && m.OwnerId == uid);
@@ -73,7 +74,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest();
}
- var uid = User.GetUserId();
+ var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var existing = await _context.Circle.SingleOrDefaultAsync(
c => c.Id == id && c.OwnerId == uid);
if (existing is null)
@@ -117,7 +118,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(ModelState);
}
- var uid = User.GetUserId();
+ var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
circle.OwnerId = uid;
_context.Circle.Add(circle);
@@ -155,7 +156,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(ModelState);
}
- var uid = User.GetUserId();
+ var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
Circle circle = await _context.Circle.SingleOrDefaultAsync(
m => m.Id == id && m.OwnerId == uid);
if (circle == null)
@@ -169,143 +170,6 @@ 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)
@@ -320,30 +184,4 @@ 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;
- }
}