diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index 0d5fa50c..4dd4b288 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -8,8 +8,6 @@
12.1.1
-
-
@@ -21,16 +19,12 @@
-
-
-
-
diff --git a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs
index 895f220e..daabdf59 100644
--- a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs
+++ b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs
@@ -166,4 +166,51 @@ public class BlogPostAuthorDtoTests
Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _));
}
+
+ [Fact]
+ public void BlogPostDto_deserialises_acl_from_detail_payload()
+ {
+ // Detail payload shape emitted by BlogApiController.GetBlog:
+ // ACL entries are included under "acl"/"ACL".
+ var json = """
+ {
+ "id": 99,
+ "title": "ACL test",
+ "authorId": "u-alice",
+ "acl": [
+ { "circleId": 12, "blogPostId": 99 },
+ { "circleId": 34, "blogPostId": 99 }
+ ]
+ }
+ """;
+
+ var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson);
+
+ Assert.NotNull(post);
+ var acl = post!.GetACL();
+ Assert.Equal(2, acl.Length);
+ Assert.Contains(acl, a => a.CircleId == 12);
+ Assert.Contains(acl, a => a.CircleId == 34);
+ }
+
+ [Fact]
+ public void BlogPostDto_does_not_emit_acl_when_serialized_for_write()
+ {
+ var post = new BlogPostDto
+ {
+ Id = 77,
+ Title = "Write payload"
+ };
+ post.AuthorizeCircle(11);
+
+ // The client should not send ACL through POST/PUT blog payloads.
+ // ACL mutations have their own dedicated /blogacl endpoint.
+ var json = JsonSerializer.Serialize(post,
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ Assert.False(root.TryGetProperty("acl", out _));
+ Assert.False(root.TryGetProperty("wireAcl", out _));
+ }
}
diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs
index ccb33ec7..dd277629 100644
--- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs
+++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs
@@ -9,6 +9,7 @@ using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
+using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot;
namespace PostIt.Tests;
@@ -190,9 +191,8 @@ public class PostAclDialogTests
await Task.Delay(20);
}
- // Assert: exactly two GETs went out (one to /blogacl,
- // one to /circle), both from the LoadAsync call.
- Assert.Equal(2, handler.RequestCount);
+ // Assert: one GET went out (for /circle) from LoadAsync.
+ Assert.Equal(1, handler.RequestCount);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
@@ -218,7 +218,58 @@ public class PostAclDialogTests
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
- Assert.Equal(2, handler.RequestCount);
+ Assert.Equal(1, handler.RequestCount);
Assert.True(vm.Loaded);
}
+
+ [Fact]
+ public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles()
+ {
+ var post = new BlogPostDto { Id = 42, Title = "ACL hydration" };
+ post.AuthorizeCircle(12);
+ post.AuthorizeCircle(34);
+
+ var api = new StubAclApiClient();
+ var aclClient = new BlogAclApiClient(api, "http://localhost/");
+ var circleClient = new CircleApiClient(api, "http://localhost/");
+ var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
+
+ await vm.LoadAsync();
+
+ Assert.Equal(1, api.CallCount);
+ Assert.Equal(2, vm.AclEntries.Count);
+ Assert.Contains(vm.AclEntries, a => a.CircleId == 12);
+ Assert.Contains(vm.AclEntries, a => a.CircleId == 34);
+ }
+
+ private sealed class StubAclApiClient : IYavscApiClient
+ {
+ public HttpClient Http { get; } = new();
+ public int CallCount { get; private set; }
+
+ public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
+ {
+ CallCount++;
+
+ if (typeof(T) == typeof(List))
+ {
+ var circles = new List
+ {
+ new() { Id = 12, Name = "A", OwnerId = "owner", Public = false },
+ new() { Id = 34, Name = "B", OwnerId = "owner", Public = false },
+ };
+ return Task.FromResult((T)(object)circles);
+ }
+
+ return Task.FromResult(default(T)!);
+ }
+
+ public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
+ {
+ CallCount++;
+ return Task.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
}
diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs
index 84e10cfb..f56b666d 100644
--- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs
@@ -250,7 +250,23 @@ public partial class MainViewModel : ViewModelBase
StatusMessage = "Select an existing post before managing ACL.";
return;
}
- await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
+
+ var postForAcl = SelectedPost;
+ try
+ {
+ var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true);
+ if (detailed is not null)
+ {
+ postForAcl = detailed;
+ SelectedPost = detailed;
+ }
+ }
+ catch
+ {
+ // Keep the dialog usable even if the detail refresh fails.
+ }
+
+ await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true);
}
[RelayCommand]
diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
index ae9e71d5..60544606 100644
--- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Linq;
+using System.Net;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -8,9 +10,17 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
+using Yavsc.Abstract.Identity.Security;
+using System.Net.Http;
namespace PostIt.ViewModels;
+public sealed class PostAclEntry
+{
+ public long CircleId { get; init; }
+ public string CircleName { get; init; } = string.Empty;
+}
+
///
/// View model for the "Gérer l'ACL" modal of a single blog post.
///
@@ -41,7 +51,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
MyCircles { get; set; } = new();
[ObservableProperty]
- public partial ObservableCollection
+ public partial ObservableCollection
AclEntries { get; set; } = new();
[ObservableProperty]
@@ -77,6 +87,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
+
+ AclEntries = new ObservableCollection(post.GetACL().Select(a => ToAclEntry(a.CircleId)));
+ SelectedCircleToAdd = null;
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
@@ -90,16 +103,17 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true;
try
{
- // Load circles and ACL entries in parallel — both are
- // independent reads on the same host. The caller's uid
- // is implicit in both endpoints.
+ // Load circles for the picker. ACL entries come from the
+ // BlogPostDto detail payload (source of truth for initial state).
var circlesTask = _circleClient.GetMyCirclesAsync();
- var aclTask = _aclClient.GetMyAclAsync();
- await Task.WhenAll(circlesTask, aclTask);
+ await Task.WhenAll(circlesTask);
var circles = circlesTask.Result ?? new List();
MyCircles = new ObservableCollection(circles);
+ // Resolve labels now that circles are available.
+ AclEntries = new ObservableCollection(AclEntries.Select(a => ToAclEntry(a.CircleId)));
+
StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
@@ -126,14 +140,20 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true;
try
{
- var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
+ if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id))
+ {
+ StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
+ return;
+ }
+
+ var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload
{
CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id
});
if (created is not null)
{
- AclEntries.Add(created);
+ AclEntries.Add(ToAclEntry(created.CircleId));
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
}
else
@@ -141,6 +161,13 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = "Autorisation refusée par le serveur";
}
}
+ catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
+ {
+ // Conflict means the link already exists in backend. Resync
+ // from the dedicated ACL API so the UI reflects server truth.
+ await ReloadAclEntriesFromServerAsync();
+ StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
+ }
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
@@ -152,14 +179,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
}
[RelayCommand]
- public async Task RevokeAsync(PostAccessControlRulePayload? acl)
+ public async Task RevokeAsync(PostAclEntry? acl)
{
if (acl is null) return;
IsBusy = true;
try
{
await _aclClient.RevokeAsync(acl.CircleId);
- AclEntries.Remove(acl);
+ var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId);
+ if (existing is not null)
+ AclEntries.Remove(existing);
StatusMessage = "Autorisation révoquée";
}
catch (Exception ex)
@@ -171,4 +200,26 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = false;
}
}
+
+ private async Task ReloadAclEntriesFromServerAsync()
+ {
+ var allAcl = await _aclClient.GetMyAclAsync();
+ var currentPostAcl = (allAcl ?? new List())
+ .Where(a => a.BlogPostId == Post.Id)
+ .Select(a => ToAclEntry(a.CircleId))
+ .GroupBy(a => a.CircleId)
+ .Select(g => g.First())
+ .ToList();
+ AclEntries = new ObservableCollection(currentPostAcl);
+ }
+
+ private PostAclEntry ToAclEntry(long circleId)
+ {
+ var circleName = MyCircles.FirstOrDefault(c => c.Id == circleId)?.Name;
+ return new PostAclEntry
+ {
+ CircleId = circleId,
+ CircleName = string.IsNullOrWhiteSpace(circleName) ? $"Cercle #{circleId}" : circleName
+ };
+ }
}
diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
similarity index 95%
rename from src/PostIt/PostIt/Settings/AuthenticationSettings.cs
rename to src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
index 9ebeaebd..8034820d 100644
--- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs
@@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject
///
public const string AndroidRedirectUri = "android://postit-signin";
- public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
+ public const string DefaultAuthority = "https://yavsc.pschneider.fr";
- public static string DefaultClientId { get; internal set; } = "postit";
+ public const string DefaultClientId = "postit";
- public static string[] DefaultScopes { get; set; } = { "blogs"} ;
+ public static readonly string[] DefaultScopes = { "blogs" };
[ObservableProperty]
public partial string Authority { get; set; }
diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
similarity index 99%
rename from src/PostIt/PostIt/ViewModels/Settings.cs
rename to src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index 8586fa34..f942249b 100644
--- a/src/PostIt/PostIt/ViewModels/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -314,7 +314,7 @@ public partial class Settings : ViewModelBase
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
}
else
- this.Authentication.Scopes = settings.Authentication.Scopes;
+ this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
// A disk load (or an embedded-resource fallback) is the
diff --git a/src/PostIt/PostIt/Views/PostAclDialog.axaml b/src/PostIt/PostIt/Views/PostAclDialog.axaml
index da5320d3..8552988e 100644
--- a/src/PostIt/PostIt/Views/PostAclDialog.axaml
+++ b/src/PostIt/PostIt/Views/PostAclDialog.axaml
@@ -3,8 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels"
- xmlns:dtos="using:Yavsc.Api.Client.Dtos"
- xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
+ xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:PostAclDialogViewModel"
>
@@ -32,10 +31,10 @@
-
+
-