diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs index 68fa514e..fbccb606 100644 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt.Tests/BearerScopeTests.cs @@ -8,6 +8,9 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using PostIt.Services; using Xunit; @@ -94,7 +97,7 @@ public class BearerScopeTests // CapturingHttpHandler is the assertion point. It // records the first request's Authorization header and // returns 200 with an empty array (BlogApiClient - // deserialises to List). + // deserialises to List). var captured = new CapturingHttpHandler(); var client = new YavscApiClient( settings, @@ -119,7 +122,7 @@ public class BearerScopeTests // Resolve a BlogApiClient on top. We don't need real // posts; we just need the outbound HTTP request to be // the one we capture. - var blog = new BlogApiClient(subClient); + var blog = new BlogApiClient(subClient, "http://localhost/"); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs index 755ce105..4b541e42 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -1,4 +1,4 @@ -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using Yavsc.Models; @@ -18,7 +18,7 @@ internal sealed class CallRecorder /// Test fake that records every CallAsync invocation /// and answers them with a canned sequence: the first call gets -/// a server-issued BlogPost (Id=42), the second call gets a +/// a server-issued BlogPostDto (Id=42), the second call gets a /// single-element list containing that post. Used by the ViewModel /// tests and the headless UI test to capture exactly what the /// Save button posts to the server. @@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { _recorder.Calls.Add((method, path, body)); - // BlogPost? boxes to BlogPost at runtime, so we test the - // non-nullable type — typeof(BlogPost?) is a C# error + // BlogPostDto? boxes to BlogPostDto at runtime, so we test the + // non-nullable type — typeof(BlogPostDto?) is a C# error // (CS8639: "typeof cannot be used on a nullable reference // type"). - if (typeof(T) == typeof(BlogPost)) - return Task.FromResult((T)(object)new BlogPost + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto { Id = 42, Title = "Mon premier billet", AuthorId = "tester", Article = "Contenu du billet de test.", }); - if (typeof(T) == typeof(List)) - return Task.FromResult((T)(object)new List + if (typeof(T) == typeof(List)) + return Task.FromResult((T)(object)new List { new() { Id = 42, Title = "Mon premier billet" } }); diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index c76115d7..b6bf963a 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -2,7 +2,8 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using PostIt.Models; +using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -24,7 +25,7 @@ namespace PostIt.Tests; /// in which a brand-new post can be created), the binding has /// no target and the user's keystrokes are silently dropped. /// Clicking "Save" then routes to the VM branch -/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } } +/// if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } } /// which the controller rejects with 400 "The Title field is /// required." This test fails on that branch today and will /// pass once the VM owns a dedicated Title/Article @@ -40,7 +41,7 @@ public class MainPageSaveTests // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; @@ -76,14 +77,14 @@ public class MainPageSaveTests // we inspect the recorder. await Task.Delay(200); - // Assert: the first POST to "blog" carried a BlogPost + // Assert: the first POST to "blog" carried a BlogPostDto // whose Title is exactly what the user typed. The bug // fails this assertion with Title == string.Empty. Assert.NotEmpty(recorder.Calls); var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); Assert.Equal("blog", path); - var sent = Assert.IsType(body); + var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } } diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index 48569915..2dee4604 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,4 +1,5 @@ -using PostIt.Models; +using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; @@ -14,12 +15,12 @@ public class PostItViewModelTests // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); - var blog = new BlogApiClient(fakeApi); + var blog = new BlogApiClient(fakeApi, "http://localhost/"); var viewModel = new MainPageViewModel(blog); - viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); - viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); - viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); + viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); + viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); + viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.SearchText = "search"; viewModel.SearchCommand.Execute(null); @@ -40,13 +41,13 @@ public class PostItViewModelTests // The new BlogApiClient delegates transport to YavscApiClient. // We feed it a fake YavscApiClient that returns the expected // list straight from CallAsync. - var expected = new List + var expected = new List { new() { Id = 1, Title = "Hello" }, new() { Id = 2, Title = "World" } }; var api = new StubYavscApiClient(expected); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var posts = await blog.GetPostsAsync(); @@ -76,8 +77,8 @@ public class PostItViewModelTests /// Test fake that hands back a canned list of posts from any CallAsync. private sealed class StubYavscApiClient : YavscApiClient { - private readonly List _posts; - public StubYavscApiClient(List posts) + private readonly List _posts; + public StubYavscApiClient(List posts) : base( new Settings { @@ -97,7 +98,7 @@ public class PostItViewModelTests { // The canned fake only knows about a list of posts; the // BlogApiClient test asserts on that list directly. - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) return Task.FromResult((T)(object)_posts); return Task.FromResult(default(T)!); } diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index c020fec9..e54bc541 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -8,6 +8,9 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b5740f2f..c5ab68e2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; using PostIt.Services; +using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; @@ -55,7 +56,11 @@ public partial class App : Application "PostIt", "tokens.json")); var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(userSearchClient); var services = new ServiceCollection(); @@ -75,14 +80,21 @@ public partial class App : Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); - services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(api); services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 1b14653a..e4d51a88 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -3,13 +3,11 @@ net10.0 enable latest - true true 1.0.1.0 1.0.1.0 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f 1.0.1-5 - @@ -26,8 +24,8 @@ - + @@ -42,4 +40,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs index 82746c49..9da4a685 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,27 +1,72 @@ #if !ANDROID && !IOS using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; namespace PostIt.Services; /// -/// Desktop stub for IContactService. +/// Desktop implementation of backed +/// by the central /api/user-search endpoint +/// (). /// -/// On desktop targets (Linux, macOS, Windows) MAUI Essentials -/// Contacts.Default throws NotImplementedInReferenceAssemblyException, -/// so we short-circuit with an empty list rather than trying to -/// call into the portable facade at runtime. +/// Desktop has no equivalent of the mobile address book +/// (no Contacts.Default, no CardDAV out of the box), so the +/// address book is built on demand from the Yavsc user table. +/// Results are accumulated in an in-memory cache exposed as +/// ; the cache is process-lifetime only +/// — there's no persistence layer. /// -/// Future provider plug-ins (Google Contacts API, Exchange EWS, -/// CardDAV) can either replace this stub on a per-OS basis or -/// live behind their own IContactService implementation that the -/// DI container selects by configuration. +/// This is the consumer that closes the loop with the +/// user-search endpoint landed on the server in commit 6 +/// (b3056f1c) and the client in commit 7 +/// (6e7e0414). /// public sealed class ContactService : IContactService { + private readonly UserSearchClient _client; + + public ObservableCollection Contacts { get; } = new(); + + public ContactService(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Array.Empty()); + => Task.FromResult>(Contacts.ToArray()); + + public async Task SearchAsync(string query, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + // Clear the cache to mirror an empty result. The + // address-book UX treats an empty query as "start + // over". + Contacts.Clear(); + return; + } + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return; + + // Append the search results to the cache. We don't + // de-dupe across searches — the simplest behaviour, and + // matches what users expect from a search panel ("show + // me what came back"). Callers wanting a single list + // can re-render Contacts on the next query. + foreach (var u in results) + { + Contacts.Add(new ContactDto( + Id: u.Id, + DisplayName: u.FullName ?? u.UserName, + Email: u.Email)); + } + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index 744fb9a6..d3eb8a10 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,6 +1,7 @@ #if ANDROID || IOS using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; @@ -24,6 +25,8 @@ namespace PostIt.Services; /// public sealed class ContactService : IContactService { + public ObservableCollection Contacts { get; } = new(); + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -38,21 +41,18 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - var result = new List(); + // Flatten the per-contact email list down to one + // primary email. The platform-neutral ContactDto only + // carries one; the use case ("invite / add to a + // circle") only needs one. The first non-empty entry + // wins. + Contacts.Clear(); foreach (var c in contacts) { - var emails = new List(); - if (c.Emails is not null) - { - foreach (var e in c.Emails) - { - if (!string.IsNullOrEmpty(e.EmailAddress)) - emails.Add(e.EmailAddress); - } - } - result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); + var email = FlattenPrimaryEmail(c.Emails); + Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); } - return result; + return Contacts.ToArray(); } catch (Exception ex) { @@ -60,5 +60,22 @@ public sealed class ContactService : IContactService return Array.Empty(); } } + + public Task SearchAsync(string query, CancellationToken ct = default) + => throw new PlatformNotSupportedException( + "SearchAsync is not supported on mobile — use GetDeviceContactsAsync " + + "to load the local address book. The network search lives on the " + + "desktop service, which queries the central user-search endpoint."); + + private static string? FlattenPrimaryEmail(IEnumerable? emails) + { + if (emails is null) return null; + foreach (var e in emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + return e.EmailAddress; + } + return null; + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 8c6da44a..4f0ba102 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; namespace PostIt.Services; /// -/// Abstraction over device contact providers (MAUI Essentials on mobile, -/// future Google/Exchange/IMAP providers). +/// Abstraction over device contact providers (MAUI Essentials on +/// mobile, the central /api/user-search endpoint on desktop). /// /// Implementations live next to this file in platform-conditional /// source files: ContactService.Mobile.cs (ANDROID/IOS) and @@ -15,15 +16,46 @@ namespace PostIt.Services; /// public interface IContactService { + /// + /// Returns the contacts known so far. On mobile this is the + /// full device address book (after permission grant); on + /// desktop this is the in-memory cache populated by previous + /// calls — empty until the user + /// has searched for something. + /// Task> GetDeviceContactsAsync(CancellationToken ct = default); + + /// + /// On desktop: hits GET /api/user-search?q=… and + /// appends matching users to the in-memory cache exposed via + /// . On mobile: throws + /// — the mobile + /// provider uses the device-local address book, not a + /// network search. + /// + Task SearchAsync(string query, CancellationToken ct = default); + + /// + /// Live view of the in-memory contact cache. UI binds to + /// this directly for a \"search results\" panel; on mobile + /// implementations this is populated eagerly by + /// . + /// + ObservableCollection Contacts { get; } } /// -/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer; -/// concrete providers (MAUI Essentials today, Google Contacts API later) -/// map to this type. +/// Platform-neutral contact DTO. Source-of-truth shape for the UI +/// layer; concrete providers (MAUI Essentials on mobile, +/// UserSearchClient on desktop) map to this type. +/// +/// Email is a single string on purpose: the central +/// search endpoint returns one email per user, and the UI use +/// case is \"pick someone to invite / add to a circle\", which +/// never needs more than one. Multi-email contacts on mobile +/// flatten to the primary address (first non-empty). /// public sealed record ContactDto( string Id, string DisplayName, - IReadOnlyList Emails); + string? Email); \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 9ae1453b..b611fe02 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using PostIt.ViewModels; +using Yavsc.Api.Client; namespace PostIt.Services; @@ -24,7 +25,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public class YavscApiClient : IAsyncDisposable +public class YavscApiClient : IYavscApiClient, IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs new file mode 100644 index 00000000..17d4c4be --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +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). +/// +/// 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. +/// +public partial class CirclesPageViewModel : ViewModelBase +{ + private readonly CircleApiClient _client; + + [ObservableProperty] + public partial ObservableCollection Circles { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircle { get; set; } + + /// Editor buffer for the new / edited circle's name. + [ObservableProperty] + public partial string DraftName { get; set; } = string.Empty; + + /// Editor buffer for the new / edited circle's visibility flag. + [ObservableProperty] + public partial bool DraftPublic { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public CirclesPageViewModel(CircleApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + 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(); } + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var list = await _client.GetMyCirclesAsync(); + Circles = new ObservableCollection(list ?? new()); + StatusMessage = $"{Circles.Count} cercle(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void StartCreate() + { + SelectedCircle = null; + DraftName = string.Empty; + DraftPublic = false; + StatusMessage = "Nouveau cercle"; + } + + [RelayCommand] + public void StartEdit(CircleDto? circle) + { + if (circle is null) return; + SelectedCircle = circle; + DraftName = circle.Name; + DraftPublic = circle.Public; + StatusMessage = $"Édition de « {circle.Name} »"; + } + + [RelayCommand] + public async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(DraftName)) + { + StatusMessage = "Le nom est obligatoire"; + return; + } + + IsBusy = true; + try + { + if (SelectedCircle is null) + { + var created = await _client.CreateCircleAsync(new CircleDto + { + Name = DraftName.Trim(), + Public = DraftPublic, + }); + StatusMessage = created is null + ? "Création échouée" + : $"Cercle « {created.Name} » créé"; + } + else + { + SelectedCircle.Name = DraftName.Trim(); + SelectedCircle.Public = DraftPublic; + await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle); + StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour"; + } + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task DeleteAsync(CircleDto? circle) + { + if (circle is null) return; + IsBusy = true; + try + { + await _client.DeleteCircleAsync(circle.Id); + StatusMessage = $"Cercle « {circle.Name} » supprimé"; + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e7ea26a0..d907606f 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -4,7 +4,8 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using PostIt.Models; +using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; namespace PostIt.ViewModels; @@ -24,7 +25,7 @@ public partial class MainPageViewModel : ViewModelBase /// previous "{Binding SelectedPost.Title}" binding, the user's /// keystrokes were silently dropped whenever /// SelectedPost was null, which made the editor a trap - /// and caused Save to POST a BlogPost with an empty + /// and caused Save to POST a BlogPostDto with an empty /// title — hence the 400 "The Title field is required". [ObservableProperty] public partial string DraftTitle { get; set; } @@ -46,13 +47,13 @@ public partial class MainPageViewModel : ViewModelBase public partial string SearchText { get; set; } [ObservableProperty] - public partial ObservableCollection Posts { get; set; } + public partial ObservableCollection Posts { get; set; } [ObservableProperty] - public partial ObservableCollection FilteredPosts { get; set; } + public partial ObservableCollection FilteredPosts { get; set; } [ObservableProperty] - public partial BlogPost? SelectedPost { get; set; } + public partial BlogPostDto? SelectedPost { get; set; } [ObservableProperty] public partial bool IsBusy { get; set; } @@ -82,8 +83,8 @@ public partial class MainPageViewModel : ViewModelBase private void Init(Settings? settings) { SearchText = string.Empty; - Posts = new ObservableCollection(); - FilteredPosts = new ObservableCollection(); + Posts = new ObservableCollection(); + FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; @@ -119,7 +120,7 @@ public partial class MainPageViewModel : ViewModelBase partial void OnSearchTextChanged(string value) => ApplyFilter(); - partial void OnSelectedPostChanged(BlogPost? value) + partial void OnSelectedPostChanged(BlogPostDto? value) { // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content @@ -176,7 +177,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - // Build a fresh BlogPost from the editor buffer on + // Build a fresh BlogPostDto from the editor buffer on // every Save — we no longer mutate SelectedPost in // place. The previous behaviour copied the buffer // (which was a no-op when SelectedPost was null) @@ -188,7 +189,7 @@ public partial class MainPageViewModel : ViewModelBase // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { - var draft = new BlogPost + var draft = new BlogPostDto { Title = DraftTitle, Article = DraftArticle ?? string.Empty, @@ -204,7 +205,7 @@ public partial class MainPageViewModel : ViewModelBase } else { - var update = new BlogPost + var update = new BlogPostDto { Id = SelectedPost.Id, AuthorId = SelectedPost.AuthorId, @@ -316,4 +317,32 @@ public partial class MainPageViewModel : ViewModelBase /// forced the buggy "draft with empty title" branch. private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + + /// + /// Raised when the user asks to open the "manage ACL" dialog for + /// the currently selected post. The MainPage code-behind + /// listens to this event and pushes a PostAclDialog on the + /// navigation stack. The VM itself can't navigate directly + /// because the navigation surface (NavigationPage) lives + /// in the View layer. + /// + public event EventHandler? ManageAclRequested; + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public void ManageAcl() + { + if (SelectedPost is null) return; + ManageAclRequested?.Invoke(this, SelectedPost); + } + + /// + /// Raised when the user asks to open the circles page (full + /// CRUD on their own circles). Same routing as + /// . + /// + public event EventHandler? OpenCirclesRequested; + + [RelayCommand] + public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs new file mode 100644 index 00000000..68b96b7c --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Gérer l'ACL" modal of a single blog post. +/// +/// Loads the caller's circles once on construct (the dropdown +/// only shows circles the user owns), then keeps an in-memory list +/// of the ACL entries for the post. / +/// are the only mutating verbs; both +/// refresh the list afterwards so the UI stays in sync with the +/// server. +/// +/// The server is the source of truth: it scopes every +/// endpoint to the caller's uid and rejects ACL grants on posts +/// the caller doesn't own. This VM does not re-validate that — +/// any 403 / 404 will surface as an exception caught by the +/// command and routed to . +/// +public partial class PostAclDialogViewModel : ViewModelBase +{ + private readonly BlogAclApiClient _aclClient; + private readonly CircleApiClient _circleClient; + + /// The post whose ACL is being edited. Set by the + /// caller (MainPage) when opening the dialog. + public BlogPostDto Post { get; } + + [ObservableProperty] + public partial ObservableCollection MyCircles { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection AclEntries { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircleToAdd { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public PostAclDialogViewModel( + BlogPostDto post, + BlogAclApiClient aclClient, + CircleApiClient circleClient) + { + Post = post ?? throw new ArgumentNullException(nameof(post)); + _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); + _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); + } + + 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(); } + + [RelayCommand] + public async Task LoadAsync() + { + 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. + var circlesTask = _circleClient.GetMyCirclesAsync(); + var aclTask = _aclClient.GetMyAclAsync(); + await Task.WhenAll(circlesTask, aclTask); + + var circles = circlesTask.Result ?? new List(); + MyCircles = new ObservableCollection(circles); + + var allAcl = aclTask.Result ?? new List(); + AclEntries = new ObservableCollection( + allAcl.Where(a => a.BlogPostId == Post.Id)); + + StatusMessage = $"{AclEntries.Count} autorisation(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task AddAsync() + { + if (SelectedCircleToAdd is null) + { + StatusMessage = "Sélectionnez un cercle à ajouter"; + return; + } + + IsBusy = true; + try + { + var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + { + CircleId = SelectedCircleToAdd.Id, + BlogPostId = Post.Id, + Comment = false, + }); + if (created is not null) + { + AclEntries.Add(created); + StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; + } + else + { + StatusMessage = "Autorisation refusée par le serveur"; + } + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task RevokeAsync(CircleAuthorizationDto? acl) + { + if (acl is null) return; + IsBusy = true; + try + { + await _aclClient.RevokeAsync(acl.CircleId); + AclEntries.Remove(acl); + StatusMessage = "Autorisation révoquée"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/CirclesPage.axaml new file mode 100644 index 00000000..d9320eb2 --- /dev/null +++ b/src/PostIt/PostIt/Views/CirclesPage.axaml @@ -0,0 +1,66 @@ + + + + + +