diff --git a/CHANGELOG.md b/CHANGELOG.md index c855b249..ac258ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,15 +16,111 @@ Cette convention est partagée avec le dépôt [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) pour la production des paquets `.deb`. -## [Unreleased] +## [1.0.7] - preview ### Added +- Per-post ACL in PostIt: a new “Manage ACL” page, opened from the ACL + button on a selected post, lets the post author grant or revoke + grants for individuals or circles. The server scopes each grant + operation to `caller == post.AuthorId` and returns `404` (not `403`) + for posts the caller does not own, so the existence of another + user's post is not leaked. +- Circle membership API + UI: three new REST endpoints under + `/api/circle/{id}/members` (`GET` list, `POST` add, `DELETE` + remove) and a new “Members” column on the *My Circles* page with an + “Add a member” button that opens a search modal. The search modal + reuses `IUserDirectory` (introduced by the `IContactService` split + in this same release) — exactly the use case the abstraction was + carved out for. +- Publish toggle for blog posts: a new `PUT /api/BlogApi/{id}/publish` + endpoint, and a `Published` checkbox in the post toolbar that + toggles a `BlogSpotPublication` row for the post. The publish + signal flows through the pre-existing `PermissionHandler.IsPublic` + path, so no new column was needed and the server-side authorisation + logic is unchanged. +- `UserSearchApiController` in `Yavsc.Blogs`: + `GET /api/user-search?q=...&e=...&take=...`. Any-authenticated- + caller endpoint that exposes the user's email under a closed- + community assumption (documented in the controller's XML doc). + Wired to the PostIt Desktop address book so the user search modal + picks it up. +- `IYavscApiClient` abstraction in `Yavsc.Api.Client`. The transport + for the blog/circle/blog-acl/user-search clients is now accessed + through this interface, so `PostIt.Tests` can stub the HTTP layer + without spinning up a real WebAPI host. +- Forgejo Actions release workflow: a `.forgejo/workflows/release.yml` + pipeline that builds and publishes a release with the PostIt APK + on tag push. Written in pure bash (the runner image has no Node), + uses `jq` for JSON body construction and response parsing, uses the + runner-provided `GITHUB_TOKEN` (no repo-level secret needed), + validates the CHANGELOG section heading before allowing the tag + to ship. +- `make release V=` target: creates a `release/` branch + from `main`, bumps the `` property in every `.csproj` via + `dotnet-gitversion /updateprojectfiles`, commits the bump on the + release branch, and pushes to `origin`. Fails fast if the working + tree is dirty or if `HEAD` is not on `main`. +- Forgejo status badges in the README. ### Changed +- The new Publish toggle replaces the “Visibility enum” approach + originally drafted in this branch: the existing `BlogSpotPublication` + table already carried enough information to expose a publish + switch, so no schema change was needed. The original `feat(blog): + add Visibility { Private, Public }` commit and its EF migration + were reverted in favour of the endpoint-only toggle. +- `BlogPost` DTO and `IBlogPost` moved from `PostIt.Models` to + `Yavsc.Abstract.Blogspot`, the shared assembly where the server-side + entity and the wire DTO both live. Renamed `Yavsc.Blogspot.BlogPost` + to `BlogPostDto` to make the wire/entity distinction explicit. +- `BlogAclApiController` and `CircleApiController` moved from + `Yavsc.Api` (not yet enabled in production) to `Yavsc.Blogs`, where + they belong next to the `BlogSpotService` they depend on. +- `IContactService` split from `IUserDirectory`: the two interfaces + previously conflated the local address-book access (mobile-only, + via `Contacts.Default`) and the Yavsc user-search access + (Desktop-only, via `/api/user-search`) behind a single facade. The + split restores the `ContactDto.Emails` multi-value shape that was + being silently flattened to a single string before. +- CI: the Forgejo Actions build now compiles `.csproj` projects + directly inside the runner container (which ships the .NET SDK + + Android workload), instead of relying on a separate Docker build + step. Node-based third-party actions were replaced with bash + curl + + `jq`. The validate-release job parses the CHANGELOG section + heading to derive the channel (`stable` / `preview` / `unstable`) + rather than the patch-version parity alone. ### Fixed +- `CircleApiController` used to read the caller's user id via + `FindFirstValue(ClaimTypes.NameIdentifier)`, which does not match + when JWT Bearer middleware has `MapInboundClaims = false`. Switched + to `User.GetUserId()` (tries `sub` first, then + `ClaimTypes.NameIdentifier`, then `nameid`). This was a latent + bug visible in tests but easy to ship to production if a host + ever disabled the remap. +- `CircleApiController` and `BlogAclApiController` reads and writes + were not always scoped to the caller's own data. Tightened the + authorisation checks: cross-user reads now return `404`, not the + raw record. +- `validate-release` CHANGELOG channel check used to parse the + patch-version parity only, which disagreed with the channel + suffix in the section heading (e.g. `## [1.0.7] - preview` + would be flagged as `stable` from the parity alone). The job now + inspects the heading line and trusts the suffix when present. +- `.forgejo/workflows/release.yml`: the asset-upload URL now carries + the asset name as a query-string parameter instead of a `curl` + positional argument. The previous shape triggered Forgejo's + “Missing `name` parameter” 400 in some cases. ### Removed +- The `## [Unreleased]` block has been moved into this section. +- The abandoned `Visibility { Private, Public }` enum and its EF + migration, reverted in this release. The publish toggle covers + the same user-visible switch without a schema change. + +[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD +[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 +[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 ## [1.0.6] - stable @@ -58,5 +154,4 @@ pour la production des paquets `.deb`. actual release id. Switched to `jq` for both body construction and field extraction. -[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 diff --git a/README.md b/README.md index d1ed912a..f3ca0e46 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. +# Statut actuel des actions Forgejo + +[![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml) + +[![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)]( +https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml +) + +[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest) + + # Statut actuel des actions GitHub * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) 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/PostIt.Tests.csproj b/src/PostIt.Tests/PostIt.Tests.csproj index 3d35d827..54c40e8c 100644 --- a/src/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt.Tests/PostIt.Tests.csproj @@ -6,10 +6,10 @@ false PostIt.Tests true - 1.0.1.0 - 1.0.1.0 - 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f - 1.0.1-5 + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada + 1.1.0-beta.1 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/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 900f1428..62b3a343 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 3820bf49..de4e3801 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -12,10 +12,10 @@ apk false android-arm;android-arm64;android-x86;android-x64 - 1.0.1.0 - 1.0.1.0 - 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f - 1.0.1-5 + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada + 1.1.0-beta.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index a339b9f0..7a0a9ba3 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -4,10 +4,10 @@ Exe true enable - 1.0.1.0 - 1.0.1.0 - 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f - 1.0.1-5 + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada + 1.1.0-beta.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index c543c550..6ab6fdac 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -5,10 +5,10 @@ See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.--> net10.0 enable - 1.0.1.0 - 1.0.1.0 - 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f - 1.0.1-5 + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada + 1.1.0-beta.1 app.manifest diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b5740f2f..6f93edf9 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,12 @@ 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(); + var userDirectory = new UserDirectory(userSearchClient); var services = new ServiceCollection(); @@ -75,14 +81,22 @@ 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.AddSingleton(userDirectory); 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/Models/BlogPost.cs b/src/PostIt/PostIt/Models/BlogPost.cs deleted file mode 100644 index e62fcea2..00000000 --- a/src/PostIt/PostIt/Models/BlogPost.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Yavsc.Abstract.Identity; -using Yavsc.Abstract.Identity.Security; -using Yavsc.Blogspot; - -namespace PostIt.Models; - -public class BlogPost : IBlogPost -{ - public string AuthorId { get; set; } - - public IApplicationUser Author { get; set; } - - public string Article { get; set ; } - public string Photo { get; set ; } - public long Id { get; set ; } - public DateTime DateCreated { get; set ; } - public string UserCreated { get; set ; } - public DateTime DateModified { get; set ; } - public string UserModified { get; set ; } - public string Title { get; set ; } - - public bool AuthorizeCircle(long circleId) - { - throw new NotImplementedException(); - } - - public ICircleAuthorization[] GetACL() - { - throw new NotImplementedException(); - } - - public string[] GetTags() - { - throw new NotImplementedException(); - } -} diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index d9cf96d3..c63771b8 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -4,10 +4,10 @@ enable latest true - 1.0.1.0 - 1.0.1.0 - 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f - 1.0.1-5 + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada + 1.1.0-beta.1 @@ -25,6 +25,7 @@ + diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs new file mode 100644 index 00000000..fa7d37f6 --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -0,0 +1,36 @@ +#if !ANDROID && !IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Desktop stub for . +/// +/// The desktop has no equivalent of the mobile address +/// book (no Contacts.Default, no CardDAV out of the +/// box). Rather than synthesise a list from a different +/// source, this provider returns an empty list and lets the +/// UI render an honest "no local contacts on this platform" +/// message. +/// +/// If desktop users want to invite people who aren't +/// Yavsc members, that flow goes through a separate path +/// (manual email entry + invitation endpoint) — not through +/// . Finding existing Yavsc +/// members is 's job, not this +/// one's. +/// +/// Future CardDAV / Google Contacts / Exchange +/// providers can plug in here as additional +/// implementations selected +/// from DI by configuration. +/// +public sealed class ContactService : IContactService +{ + public Task> GetDeviceContactsAsync(CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); +} +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs new file mode 100644 index 00000000..8dbd134d --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -0,0 +1,82 @@ +#if ANDROID || IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.ApplicationModel.Communication; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Devices; + +namespace PostIt.Services; + +/// +/// Mobile implementation backed by MAUI Essentials +/// Contacts.Default. +/// +/// Compiled only for ANDROID and IOS. On desktop targets, +/// see ContactService.Desktop.cs (the stub that wins at +/// compile time). +/// +/// Note: at runtime, this class throws +/// NotImplementedInReferenceAssemblyException unless +/// the host application project also references the +/// platform-specific Microsoft.Maui.Essentials implementation +/// (typically PostIt.Android). On iOS the same is +/// required via PostIt.iOS. On desktop the stub is used +/// and this file is excluded. +/// +public sealed class ContactService : IContactService +{ + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) + { + if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) + return Array.Empty(); + + try + { + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + return Array.Empty(); + + var contacts = await Contacts.Default.GetAllAsync(); + if (contacts is null) return Array.Empty(); + + // Carry the per-contact email list as-is. A real + // device contact can carry several addresses (home / + // work / other); the UI use case ("invite / add to a + // circle") can then decide which address to use, or + // let the user pick. The platform-neutral ContactDto + // shape is intentionally richer than the Yavsc + // directory's single-Email shape — the two flows + // answer different questions. + var result = new List(contacts.Count); + foreach (var c in contacts) + { + var emails = ExtractEmails(c.Emails); + result.Add(new ContactDto( + c.Id, + c.DisplayName ?? string.Empty, + emails)); + } + return result; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}"); + return Array.Empty(); + } + } + + private static IReadOnlyList ExtractEmails(IEnumerable? emails) + { + if (emails is null) return Array.Empty(); + var list = new List(); + foreach (var e in emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + list.Add(e.EmailAddress); + } + return list; + } +} +#endif diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs new file mode 100644 index 00000000..49ca3064 --- /dev/null +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Abstraction over the device-local address book. Used by +/// the "invite someone" flow to enumerate people the user +/// already has in their phone — including people who have +/// never heard of Yavsc. +/// +/// Distinct from , which +/// reads the central Yavsc user table. A device contact may +/// not have a Yavsc account; a directory entry always does. +/// The two are exposed as separate interfaces so a UI that +/// needs both can take both by constructor injection and +/// present them under separate sections (e.g. "Contacts from +/// your phone" vs "Yavsc members"). +/// +/// Implementations live next to this file in +/// platform-conditional source files: +/// ContactService.Mobile.cs (ANDROID/IOS) and +/// ContactService.Desktop.cs (everything else). On +/// desktop the implementation is a stub that returns an +/// empty list: the desktop has no equivalent of the mobile +/// address book, and inviting from a desktop is a separate +/// flow. +/// +public interface IContactService +{ + /// + /// Read the device address book. Returns the contacts + /// known to the local provider; on desktop (no local + /// provider) this is always an empty list. + /// + Task> GetDeviceContactsAsync(CancellationToken ct = default); +} + +/// +/// Platform-neutral contact DTO. Source-of-truth shape for +/// the UI layer; concrete providers (MAUI Essentials on +/// mobile) map to this type. +/// +/// Emails is a list on purpose: a real device +/// contact may carry several addresses (home / work / other). +/// The UI use case ("invite / add to a circle") can then +/// decide which address to use, or let the user pick. This +/// is intentionally richer than the Yavsc directory's +/// single-Email shape — the two flows answer different +/// questions and shouldn't be flattened onto the same +/// wire. +/// +public sealed record ContactDto( + string Id, + string DisplayName, + IReadOnlyList Emails); diff --git a/src/PostIt/PostIt/Services/IUserDirectory.cs b/src/PostIt/PostIt/Services/IUserDirectory.cs new file mode 100644 index 00000000..7d4c1eb4 --- /dev/null +++ b/src/PostIt/PostIt/Services/IUserDirectory.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Abstraction over the central Yavsc user directory. Used by +/// the "add to a circle" flow to find Yavsc users by display +/// name or email. +/// +/// Distinct from , which +/// reads the device-local address book. A Yavsc user +/// directory entry is always a registered account; a device +/// contact may be anyone in the user's phone — including +/// people who have never heard of Yavsc. +/// +/// Implementations live next to this file in +/// platform-conditional source files: +/// UserDirectory.Desktop.cs and +/// UserDirectory.Mobile.cs. Both currently delegate to +/// UserSearchClient (the central /api/user-search +/// endpoint); the split exists so future platform-specific +/// sources (offline cache, directory-scoped providers) can be +/// plugged in without disturbing the consumer. +/// +public interface IUserDirectory +{ + /// + /// Search the directory by display name (substring) and/or + /// email (exact). + /// + /// Substring filter on the user's + /// display name. Empty or whitespace short-circuits to an + /// empty list (matches the client UX of "type to search", + /// not "show me a directory"). + /// Cancellation token. + /// A flat list of matching directory entries. + /// Never null; may be empty. + Task> SearchAsync(string query, CancellationToken ct = default); +} + +/// +/// Platform-neutral summary of a Yavsc directory entry. Mirrors +/// the wire shape of /api/user-search (see +/// UserSearchResultDto) but expressed in terms that +/// don't leak transport concerns. +/// +/// Kept as a record on purpose: directory entries are +/// immutable snapshots from the server, so structural equality +/// makes "did the user already pick this one?" trivial. +/// +public sealed record UserSummary( + string Id, + string UserName, + string? FullName, + string? Avatar, + string? Email) +{ + /// + /// Convenience for "what to show in a picker". Falls back + /// to when + /// is null or empty. + /// + public string DisplayName => + string.IsNullOrWhiteSpace(FullName) ? UserName : FullName; +} diff --git a/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs new file mode 100644 index 00000000..c821bb87 --- /dev/null +++ b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs @@ -0,0 +1,52 @@ +#if !ANDROID && !IOS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +/// +/// Desktop implementation of . +/// Delegates to the central /api/user-search endpoint +/// via . +/// +/// The desktop has no device-local address book, so the +/// "add to a circle" flow on desktop is Yavsc-users-only. +/// Inviting someone who doesn't have a Yavsc account from +/// desktop is a separate feature (manual email entry + +/// invitation endpoint) and lives outside this interface. +/// +public sealed class UserDirectory : IUserDirectory +{ + private readonly UserSearchClient _client; + + public UserDirectory(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task> SearchAsync( + string query, CancellationToken ct = default) + { + // UserSearchClient already short-circuits on empty + // queries, but do it here too so the contract is + // obvious to anyone reading IUserDirectory alone + // without having to chase the client wrapper. + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty(); + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return Array.Empty(); + + return results.Select(u => new UserSummary( + Id: u.Id, + UserName: u.UserName, + FullName: u.FullName, + Avatar: u.Avatar, + Email: u.Email)).ToList(); + } +} +#endif diff --git a/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs new file mode 100644 index 00000000..5cba6e4a --- /dev/null +++ b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs @@ -0,0 +1,49 @@ +#if ANDROID || IOS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +/// +/// Mobile implementation of . +/// Same backing as the desktop provider (the central +/// /api/user-search endpoint via +/// ) — mobile devices have the +/// network too, and "add to a circle" needs the same directory +/// regardless of platform. +/// +/// The split exists so a future mobile-only provider +/// (offline cache, device-local mirror of the user's own +/// circles) can be plugged in without touching consumers. +/// +public sealed class UserDirectory : IUserDirectory +{ + private readonly UserSearchClient _client; + + public UserDirectory(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task> SearchAsync( + string query, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty(); + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return Array.Empty(); + + return results.Select(u => new UserSummary( + Id: u.Id, + UserName: u.UserName, + FullName: u.FullName, + Avatar: u.Avatar, + Email: u.Email)).ToList(); + } +} +#endif 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/AddCircleMemberDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs new file mode 100644 index 00000000..a721d738 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs @@ -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; + +/// +/// View model for the "add a Yavsc user to a circle" modal. +/// +/// Resolves users through +/// (which delegates to /api/user-search); the caller +/// (CirclesPage) decides whether to add the picked user to +/// the circle by calling +/// +/// (which is bound to the dialog's "Ajouter" button). +/// +/// The dialog itself doesn't know the target +/// CircleId: that's set by the caller via the +/// constructor and the dialog only triggers +/// against the +/// string. The "Add" command +/// returns the picked via the +/// event, and the hosting +/// CirclesPage then calls +/// . +/// +public partial class AddCircleMemberDialogViewModel : ViewModelBase +{ + private readonly IUserDirectory _directory; + + [ObservableProperty] + public partial string SearchQuery { get; set; } = string.Empty; + + [ObservableProperty] + public partial ObservableCollection Results { get; set; } = new(); + + [ObservableProperty] + public partial UserSummary? Selected { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + /// + /// Raised when the user confirms a selection. The hosting + /// CirclesPage subscribes to this event and calls + /// CircleApiClient.AddMemberAsync with the target + /// circle id + the picked user's id. The dialog itself + /// does not know the circle id by design: separation of + /// concerns — the modal is a user picker, not a + /// "circle joiner" form. + /// + public event EventHandler? Confirmed; + + public AddCircleMemberDialogViewModel(IUserDirectory directory) + { + _directory = directory ?? throw new ArgumentNullException(nameof(directory)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + /// + /// Search the directory for users matching the current + /// . Triggered explicitly via the + /// "Rechercher" button — no debouncing, so the caller + /// stays in control of how often the network is hit. + /// + [RelayCommand] + public async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(SearchQuery)) + { + Results.Clear(); + StatusMessage = "Tapez un nom ou un email"; + return; + } + + IsBusy = true; + try + { + var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true); + Results = new ObservableCollection(hits ?? Array.Empty()); + StatusMessage = $"{Results.Count} résultat(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + /// + /// Raise for the currently selected + /// user. No-op when no selection has been made — keeps the + /// UI from firing an event with a null payload. + /// + [RelayCommand] + public void Add() + { + if (Selected is null) + { + StatusMessage = "Sélectionnez un utilisateur"; + return; + } + Confirmed?.Invoke(this, Selected); + } +} diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs new file mode 100644 index 00000000..c017c426 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -0,0 +1,310 @@ +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; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Mes cercles" page. CRUD on the caller's own +/// circles (the server scopes every endpoint to the caller's uid +/// since the BlogAcl fix on this branch), plus membership +/// management on the currently selected circle. +/// +/// The view lists circles in , supports +/// create / edit via , and exposes +/// per-item Delete and per-item edit commands. +/// drives a progress overlay during API calls; +/// surfaces success / error feedback in the view footer. +/// +/// When the user selects a circle in the list, +/// fetches its members into +/// . The "Add a member" command +/// () is a UI event the view +/// raises to open AddCircleMemberDialog; the dialog +/// raises a Confirmed event back, which the page's +/// code-behind forwards here via +/// . The "remove" +/// command is per-row and runs inline. +/// +public partial class CirclesPageViewModel : ViewModelBase +{ + 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; } + + /// Members of the currently selected circle. Empty + /// when no circle is selected or after a refresh that + /// produced an empty list. Updated by + /// . + [ObservableProperty] + public partial ObservableCollection Members { get; set; } = new(); + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + /// + /// Raised when the user wants to add a member to the + /// currently selected circle. The view listens to this + /// event and opens AddCircleMemberDialog. + /// + public event EventHandler? AddMemberRequested; + + public CirclesPageViewModel(CircleApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + /// + /// Partial property setter: when the selected circle + /// changes, refresh the members list. The setter is + /// invoked by the [ObservableProperty] source generator + /// for both user selections and programmatic resets. + /// + partial void OnSelectedCircleChanged(CircleDto? value) + { + Members = new ObservableCollection(); + if (value is not null) + { + // Fire-and-forget: load members in the background. + // Errors are routed to StatusMessage inside + // LoadMembersAsync. + _ = LoadMembersAsync(value.Id); + } + } + + [RelayCommand] + public async Task RefreshAsync() + { + 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; + } + } + + /// + /// Load the members of one of the caller's circles. The + /// server scopes the endpoint with a 404 when the circle + /// doesn't belong to the caller (mirroring the rest of the + /// circle API); that case flattens to an empty list here. + /// + [RelayCommand] + public async Task LoadMembersAsync(long circleId) + { + IsBusy = true; + try + { + var list = await _client.GetMembersAsync(circleId); + Members = new ObservableCollection(list ?? new()); + StatusMessage = $"{Members.Count} membre(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + Members = new ObservableCollection(); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void StartCreate() + { + 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é"; + // 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) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + /// + /// Fire the event so + /// the view opens AddCircleMemberDialog. The view + /// forwards the dialog's Confirmed event back to + /// . + /// + [RelayCommand] + public void OpenAddMember() + { + if (SelectedCircle is null) + { + StatusMessage = "Sélectionnez d'abord un cercle"; + return; + } + AddMemberRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Called by the view when the dialog confirms a + /// selection. Adds the picked user to the currently + /// selected circle and refreshes the members list. + /// + public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked) + { + if (SelectedCircle is null || picked is null) return; + IsBusy = true; + try + { + await _client.AddMemberAsync(SelectedCircle.Id, picked.Id); + StatusMessage = $"« {picked.DisplayName} » ajouté au cercle"; + await LoadMembersAsync(SelectedCircle.Id); + } + catch (Exception ex) + { + // 409 (already a member) is a likely race — surface + // it as a friendly status, not an error. The + // server returns 409 for "already a member"; + // YavscApiClient surfaces that as an exception + // today; future refactors could route 409 into a + // typed result, but for now the message string is + // distinctive enough. + var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict") + ? "Déjà membre du cercle" + : $"Erreur: {ex.Message}"; + StatusMessage = msg; + } + finally + { + IsBusy = false; + } + } + + /// + /// Per-row "remove" command. Updates the local + /// collection in place so the UI doesn't flash. + /// + [RelayCommand] + public async Task RemoveMemberAsync(CircleMemberDto? member) + { + if (member is null || SelectedCircle is null) return; + IsBusy = true; + try + { + await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id); + Members.Remove(member); + StatusMessage = $"« {member.UserName} » retiré du cercle"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e7ea26a0..d1d16306 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; } @@ -34,6 +35,18 @@ public partial class MainPageViewModel : ViewModelBase [ObservableProperty] public partial string DraftArticle { get; set; } + /// Editor buffer for the post's publication state. + /// Reflects the server-side IsPublished flag (the + /// existence of a row in BlogSpotPublication) and + /// is pushed to the server via + /// on explicit + /// toggle — it is NOT included in the regular Save + /// payload, mirroring the wire contract where + /// BlogPostDto doesn't carry Publish as a + /// mutable field. Toggling is its own action. + [ObservableProperty] + public partial bool DraftIsPublished { get; set; } + [ObservableProperty] public partial ViewModelBase? CurrentViewModel { get; set; } @@ -46,13 +59,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 +95,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"; @@ -101,6 +114,7 @@ public partial class MainPageViewModel : ViewModelBase WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; + DraftIsPublished = false; CurrentViewModel = this; } @@ -119,7 +133,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 @@ -130,6 +144,9 @@ public partial class MainPageViewModel : ViewModelBase // doesn't show stale content. DraftTitle = value?.Title ?? string.Empty; DraftArticle = value?.Article ?? string.Empty; + // Mirror publication state too. Defaults to false on + // null selection so a fresh draft starts unpublished. + DraftIsPublished = value?.IsPublished ?? false; UpdateCommandStates(); } @@ -176,7 +193,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 +205,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 +221,7 @@ public partial class MainPageViewModel : ViewModelBase } else { - var update = new BlogPost + var update = new BlogPostDto { Id = SelectedPost.Id, AuthorId = SelectedPost.AuthorId, @@ -240,6 +257,46 @@ public partial class MainPageViewModel : ViewModelBase }); } + /// + /// Toggle the publication state of the currently selected + /// post. Pushes the new state to + /// PUT /api/BlogApi/{id}/publish and reflects it + /// locally in + the + /// selected post so the UI updates without a full + /// refresh. + /// + /// The toggle is its own action — separate from Save + /// — because Publish is not part of the + /// BlogPostDto payload. Bundling it into Save + /// would require a wire-shape change and a second server + /// overload; the dedicated endpoint keeps the wire + /// contract clean. + /// + [RelayCommand] + internal async Task TogglePublish() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; + return; + } + + await ExecuteAsync(async () => + { + var desired = !DraftIsPublished; + await BlogClient.SetPublishAsync(SelectedPost.Id, desired); + DraftIsPublished = desired; + // Mirror into the selected post so a subsequent + // RefreshPostsAsync() doesn't blow away the + // locally flipped state until the round-trip + // re-hydrates it. + SelectedPost.IsPublished = desired; + StatusMessage = desired + ? $"Billet {SelectedPost.Id} publié." + : $"Billet {SelectedPost.Id} remis en brouillon."; + }); + } + [RelayCommand] internal void OpenSettings() { @@ -316,4 +373,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/AddCircleMemberDialog.axaml b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml new file mode 100644 index 00000000..2c13e99c --- /dev/null +++ b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml @@ -0,0 +1,57 @@ + + + + + + +