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 @@ - + -