Compare commits

...

2 commits

Author SHA1 Message Date
5e3d361f88
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).
2026-08-18 14:10:12 +01:00
ef59cd1735
fix(circle-api): use User.GetUserId() for owner scoping
The scoping that landed in e376aed8 ("restrict Circle + BlogAcl
reads and writes to caller's own data") reads the caller's uid
with User.FindFirstValue(ClaimTypes.NameIdentifier). That works
when the JWT bearer middleware remaps the "sub" claim to the
long ClaimTypes.NameIdentifier URI — which is the default
behaviour. But the BlogsWebServerFixture test host and any host
that sets MapInboundClaims = false (preserved here to keep
"sub" as "sub" for the resource-based ownership checks in
BlogSpotService) end up with no ClaimTypes.NameIdentifier claim
at all, only "sub". On those hosts, every OwnerId == uid
filter silently returns nothing, so the controller responds 404
even for the caller's own circles.

Switch to the canonical User.GetUserId() extension helper
(Yavsc.Server.Helpers.UserHelpers), which tries "sub" first,
then ClaimTypes.NameIdentifier, then "nameid". This aligns
CircleApiController with BlogApiController (which already uses
GetUserId()) and restores correct behaviour on hosts that run
with MapInboundClaims = false.

Drop the now-unused System.Security.Claims using.

No behavioural change for production: there, MapInboundClaims
remains true, ClaimTypes.NameIdentifier is populated, and
GetUserId() returns the same value as FindFirstValue would have.
2026-08-18 14:02:07 +01:00
10 changed files with 928 additions and 44 deletions

View file

@ -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;
/// <summary>
/// View model for the "add a Yavsc user to a circle" modal.
///
/// <para>Resolves users through <see cref="IUserDirectory"/>
/// (which delegates to <c>/api/user-search</c>); the caller
/// (CirclesPage) decides whether to add the picked user to
/// the circle by calling
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
/// (which is bound to the dialog's "Ajouter" button).</para>
///
/// <para>The dialog itself doesn't know the target
/// <c>CircleId</c>: that's set by the caller via the
/// constructor and the dialog only triggers
/// <see cref="IUserDirectory.SearchAsync"/> against the
/// <see cref="SearchQuery"/> string. The "Add" command
/// returns the picked <see cref="UserSummary"/> via the
/// <see cref="Confirmed"/> event, and the hosting
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
private readonly IUserDirectory _directory;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<UserSummary> 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;
/// <summary>
/// Raised when the user confirms a selection. The hosting
/// <c>CirclesPage</c> subscribes to this event and calls
/// <c>CircleApiClient.AddMemberAsync</c> 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.
/// </summary>
public event EventHandler<UserSummary>? 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(); }
/// <summary>
/// Search the directory for users matching the current
/// <see cref="SearchQuery"/>. Triggered explicitly via the
/// "Rechercher" button — no debouncing, so the caller
/// stays in control of how often the network is hit.
/// </summary>
[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<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Raise <see cref="Confirmed"/> for the currently selected
/// user. No-op when no selection has been made — keeps the
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public void Add()
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
return;
}
Confirmed?.Invoke(this, Selected);
}
}

View file

@ -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;
/// <summary>
/// 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.
///
/// <para>The view lists circles in <see cref="Circles"/>, supports
/// create / edit via <see cref="DraftName"/>, and exposes
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
/// surfaces success / error feedback in the view footer.</para>
///
/// <para>When the user selects a circle in the list,
/// <see cref="LoadMembersAsync"/> fetches its members into
/// <see cref="Members"/>. The "Add a member" command
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
/// raises a <c>Confirmed</c> event back, which the page's
/// code-behind forwards here via
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
{
@ -37,12 +50,26 @@ public partial class CirclesPageViewModel : ViewModelBase
[ObservableProperty]
public partial bool DraftPublic { get; set; }
/// <summary>Members of the currently selected circle. Empty
/// when no circle is selected or after a refresh that
/// produced an empty list. Updated by
/// <see cref="LoadMembersAsync"/>.</summary>
[ObservableProperty]
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user wants to add a member to the
/// currently selected circle. The view listens to this
/// event and opens <c>AddCircleMemberDialog</c>.
/// </summary>
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(); }
/// <summary>
/// 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.
/// </summary>
partial void OnSelectedCircleChanged(CircleDto? value)
{
Members = new ObservableCollection<CircleMemberDto>();
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
}
}
/// <summary>
/// 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.
/// </summary>
[RelayCommand]
public async Task LoadMembersAsync(long circleId)
{
IsBusy = true;
try
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
Members = new ObservableCollection<CircleMemberDto>();
}
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;
}
}
/// <summary>
/// Fire the <see cref="AddMemberRequested"/> event so
/// the view opens <c>AddCircleMemberDialog</c>. The view
/// forwards the dialog's <c>Confirmed</c> event back to
/// <see cref="OnAddMemberConfirmedAsync"/>.
/// </summary>
[RelayCommand]
public void OpenAddMember()
{
if (SelectedCircle is null)
{
StatusMessage = "Sélectionnez d'abord un cercle";
return;
}
AddMemberRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Called by the view when the dialog confirms a
/// selection. Adds the picked user to the currently
/// selected circle and refreshes the members list.
/// </summary>
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;
}
}
/// <summary>
/// Per-row "remove" command. Updates the local
/// collection in place so the UI doesn't flash.
/// </summary>
[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;
}
}
}

View file

@ -0,0 +1,57 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.AddCircleMemberDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<TextBox Grid.Column="0"
Text="{Binding SearchQuery, Mode=TwoWay}"
PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Selection hint -->
<TextBlock Grid.Row="1"
Text="Sélectionnez un résultat puis cliquez Ajouter."
FontSize="11" Opacity="0.6"
Margin="0,0,0,8"/>
<!-- Search results -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2">
<TextBlock Text="{Binding DisplayName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer"
Click="OnCloseClicked"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,54 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
/// <summary>
/// Modal "add a member to a circle" page. Hosted by
/// <c>CirclesPage</c>; the caller passes the resolved
/// <see cref="IUserDirectory"/> via the constructor.
///
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
/// when the user picks a result and clicks "Ajouter"; the
/// hosting page subscribes to that event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id. The dialog itself does not know the circle id
/// by design.</para>
/// </summary>
public partial class AddCircleMemberDialog : ContentPage
{
public AddCircleMemberDialog()
{
InitializeComponent();
}
public AddCircleMemberDialog(IUserDirectory directory)
{
InitializeComponent();
DataContext = new AddCircleMemberDialogViewModel(directory);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// 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).
/// </summary>
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.
}
}

View file

@ -6,7 +6,7 @@
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Toolbar: refresh + new -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
@ -16,8 +16,15 @@
Command="{Binding StartCreateCommand}"/>
</StackPanel>
<!-- List of circles -->
<ListBox Grid.Row="1" Margin="12,0,12,12"
<!-- Two-pane body: circles (left) + members (right) -->
<Grid Grid.Row="1" Margin="12,0,12,12"
ColumnDefinitions="*,16,*"
RowDefinitions="*,Auto">
<!-- Left column: list of circles + editor -->
<Grid Grid.Row="0" Grid.Column="0"
RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
@ -40,7 +47,7 @@
</ListBox>
<!-- Editor -->
<Grid Grid.Row="2" Margin="12" RowDefinitions="Auto,Auto,Auto"
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
@ -53,9 +60,47 @@
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Right column: members of the selected circle -->
<Grid Grid.Row="0" Grid.Column="2"
RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Membres"
FontWeight="Bold"
VerticalAlignment="Center"/>
<Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberCommand}"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Members}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleMemberDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Retirer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state hint -->
<TextBlock Grid.Row="2"
Text="Sélectionnez un cercle pour voir ses membres."
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
FontSize="11" Opacity="0.6"
Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"

View file

@ -1,14 +1,55 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class CirclesPage : ContentPage
{
private CirclesPageViewModel? _vm;
public CirclesPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking
// handlers across navigation pushes / DataContext resets.
if (_vm is not null)
_vm.AddMemberRequested -= OnAddMemberRequested;
_vm = DataContext as CirclesPageViewModel;
if (_vm is not null)
_vm.AddMemberRequested += OnAddMemberRequested;
}
private void OnAddMemberRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || _vm is null) return;
// Resolve the directory via DI. The dialog raises its
// own Confirmed event; the VM subscribes via the method
// below — we pass the VM in so the closure can call
// back into it without the dialog needing to know the
// type of its caller. EventHandler<UserSummary> wants a
// void return, so wrap the async VM method in a fire-
// and-forget helper.
var directory = services.GetRequiredService<IUserDirectory>();
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()

View file

@ -50,4 +50,36 @@ public sealed class CircleApiClient
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);
}

View file

@ -0,0 +1,21 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle/{id}/members</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.CircleMemberDto</c>. Intentionally
/// stops short of the Email field that
/// <see cref="UserSearchResultDto"/> 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 <see cref="IYavscApiClient"/>'s other
/// endpoints rather than widening this shape.</para>
/// </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; }
}

View file

@ -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;
/// <summary>
/// Behavioural tests for the circle-members endpoints on
/// <c>CircleApiController</c>:
/// <c>GET /api/circle/{id}/members</c>,
/// <c>POST /api/circle/{id}/members</c>,
/// <c>DELETE /api/circle/{id}/members/{userId}</c>.
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides an in-memory
/// <c>ApplicationDbContext</c>, JWT bearer auth with HS256,
/// and the production <c>BlogScope</c> policy. Tests use
/// <c>TestTokenIssuer</c> to mint tokens whose <c>sub</c>
/// claim identifies the caller.</para>
///
/// <para>Test users (<c>alice</c>, <c>bob</c>) are seeded
/// directly via <see cref="ApplicationDbContext.Users"/>:
/// the Blogs fixture doesn't stand up
/// <c>UserManager&lt;ApplicationUser&gt;</c>, so we go
/// through the DbContext the same way the production code
/// would.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public CircleMembersApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed
/// <c>alice</c> + <c>bob</c>. <c>UseInMemoryDatabase</c>
/// shares its store across the fixture lifetime, so each
/// test starts from a clean slate.</summary>
private void ResetDatabaseWithUsers()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// 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.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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);
}
}

View file

@ -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<Circle> 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);
}
/// <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)
@ -184,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;
}
}