Compare commits

...

150 commits

Author SHA1 Message Date
5274d7bdf5 Merge pull request 'release/1.0.8-rc1' (#42) from release/1.0.8-rc1 into main
All checks were successful
Dotnet build and test / build (push) Successful in 7m4s
Reviewed-on: #42
2026-08-23 23:19:06 +01:00
48feb27277
changelog: align 1.0.8-rc1 section title with release workflow
All checks were successful
Dotnet build and test / build (pull_request) Successful in 5m31s
Forgejo Release / release (push) Successful in 7m20s
The Forgejo release workflow validates the section title against the
channel derived from tag parity: '[TAG] - stable', '[TAG] - preview',
or '[TAG] - unstable'. The 2026-08-21 entry broke the validation with
'## [1.0.8-rc1] - 2026-08-21' (date suffix instead of channel). Move
the date into the body of the section (it is already mentioned in the
'Fixed' subsection) and use '## [1.0.8-rc1] - unstable' to match the
1.0.6 / 1.0.7 convention.
2026-08-21 23:07:34 +01:00
cec482625d
release unstables
Some checks failed
Dotnet build and test / build (pull_request) Successful in 7m37s
Forgejo Release / release (push) Failing after 15s
2026-08-21 22:51:25 +01:00
39ec739eab
changelog: section for 1.0.8-rc1
Some checks failed
Forgejo Release / release (push) Failing after 15s
Dotnet build and test / build (pull_request) Has been cancelled
Ajoute la section [1.0.8-rc1] au CHANGELOG.md, en français, au format
Keep a Changelog (### Added / ### Changed / ### Fixed). Couvre :
- le fix backend du 500 sur POST /api/v1/blogacl (commit d2a0c263)
- le fix client PostIt (commit b82b6722)
- le passage de CheckOwner en async (commit e48ede1e)
- le seed one-shot de la fixture BlogsWebServerFixture
- les tests de non-régression (sentinelles 'never 500' et 'shape PostIt')
- la règle 'Pas de object' dans CONTRIBUTING.md

Met aussi à jour le bloc de liens de comparaison en bas du fichier
pour pointer [1.0.8-rc1] vers 1.0.7...1.0.8-rc1.
2026-08-21 22:43:31 +01:00
9da6888e03 Merge pull request 'feat/postit-acl-members' (#41) from feat/postit-acl-members into release/1.0.8-rc1
Reviewed-on: #41
2026-08-21 22:31:58 +01:00
d2a0c263dd
acl post: reject BlogPostId <= 0 with 400, no 500
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m18s
The 2026-08-21 prod 500 on POST /api/v1/blogacl was caused by the
PostIt client sending { circleId } only — the server deserialised
into CircleAuthorizationToBlogPost with BlogPostId = default(long) = 0,
and EF Core refused the INSERT with InvalidOperationException.

The PostIt-side fix lives in b82b6722 (enrich the payload with
blogPostId). This commit is the server-side guard: validate
BlogPostId > 0 in the controller and return 400 BadRequest instead
of letting the request reach SaveChangesAsync. The same shape that
crashed on 2026-08-21 now fails fast at the validation layer.

Verified by BlogAclApiTests.PostCircleAuthorization_dosent_return_500:
sentinel that asserts 'never 500' on a payload with BlogPostId = -1.
Previously red (500 from EF Core), now green (400 from the new guard).
2026-08-21 22:08:05 +01:00
e48ede1e84
acl post: never 500 regression sentinel + async CheckOwner + fixture seed
The hard rule on POST /api/v1/blogacl is: a 500 is never acceptable,
regardless of the payload shape. The prod 500 logged on 2026-08-21 on
mercure was caused by the PostIt client sending { circleId } only, which
the server deserialised into CircleAuthorizationToBlogPost with
BlogPostId = default(long) = 0; EF Core refused the INSERT with
InvalidOperationException: The value of
'CircleAuthorizationToBlogPost.BlogPostId' is unknown. The PostIt fix
lives in b82b6722 (enrich the payload with blogPostId). The server-side
guard lives in this commit:

- BlogAclApiController.CheckOwner is now async and uses FirstOrDefaultAsync
  instead of First, so it does not deadlock the request thread and
  returns false on a missing circle (which the controller already maps
  to ChallengeResult).
- BlogsWebServerFixture now seeds Alice, her Circle and her BlogPost
  in ConfigurePipelineAsync, once at host startup, against the shared
  SqliteConnection (Cache=Shared). EnsureCreated is idempotent and
  runs against the connection that every DbContext resolves through,
  so the test theory can POST payloads with real FK ids against a
  schema that actually has the Circle / BlogSpot tables.
- BlogAclApiTests:
    - PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape
      is the regression sentinel for the prod fix.
    - PostCircleAuthorization_never_returns_500 is a [Theory] over
      several payload shapes; any future commit that reintroduces a
      500 path turns it red. CleanupAcl at the start of each insert-
      bearing test isolates against xUnit's no-guarantee-of-order
      execution: a successful POST in test N would otherwise conflict
      with test N+1 against the same (CircleId, BlogPostId) pair.
2026-08-21 22:00:27 +01:00
4956890236
refacto seed test db
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m8s
2026-08-21 20:45:46 +01:00
c645973b52
no object
Some checks failed
Dotnet build and test / build (pull_request) Failing after 9m2s
2026-08-21 20:33:30 +01:00
34c7b153ff
warnings 2026-08-21 20:33:15 +01:00
4b9b8d5e78
fixes the compile
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 20:25:13 +01:00
107c4d0b00
test some failling pathes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 4m37s
2026-08-21 19:37:22 +01:00
76a3660dcf
workaround on testing the null CloseButton
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m41s
2026-08-21 18:46:26 +01:00
404d406931
Testing circle was authorized
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 17:21:54 +01:00
b82b6722c7
fixes a 500 in prod and the associated test 2026-08-21 16:58:27 +01:00
8f91cbed02
A Circle must pre-exist before beeing used by an authorization 2026-08-21 16:40:24 +01:00
88461786ee
Roll back refacto on Posit.Tests 2026-08-21 16:18:20 +01:00
06672c4c90
remove dead 'Comment' field from CircleAuthorizationToBlogPost
The bool Comment on CircleAuthorizationToBlogPost was dead code:
never read or written by any caller in src/, no UI exposure, no
behavioural semantics. The wire DTO (CircleAuthorization in
Yavsc.Abstract) doesn't carry it, no reader consumes it, and the
PostIt client builds its payload without it.

What changes:
- src/Yavsc.Server/Models/Access/CircleAuthorizationToBlogPost.cs:
  remove the property.
- src/Yavsc.Blogs.Tests/BlogAclApiTests.cs: drop 'Comment = true'
  from the existing test payload and trim the now-inaccurate XML
  doc comment ('CircleId + BlogPostId + Comment' -> 'CircleId +
  BlogPostId'). Also adds a new [Fact] pinning the prod bug
  reported on 2026-08-21 (HTTP 500 'BlogPostId is unknown' when
  PostIt POSTs the bare { circleId } shape). That test stays red:
  the real fix for the 500 is in PostIt (payload needs blogPostId)
  + on the wire DTO + server-side validation, and lives in a
  follow-up commit.

Migration:
- src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost
  drops the boolean 'Comment' column on CircleAuthorizationToBlogPost.
  The generated scaffold also wanted to drop three 'ClientId1'
  shadow FK columns on ClientScopes / ClientRedirectUris /
  ClientGrantTypes (from leftover HasOne<Client>() overrides in
  ApplicationDbContext.OnModelCreating); those were removed from
  the .cs to keep the migration scoped to this fix. Cleaning up the
  shadow property declarations themselves is left as a separate
  task.

The ModelSnapshot still reflects the shadow 'ClientId1' columns
intentionally: they exist in the prod database today (all NULL),
and EF will rescaffold a drop migration for them on the next
'migrations add' regardless. No data loss.
2026-08-21 00:27:27 +01:00
a44c04ad77
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user
Bundled end-of-branch commit on feat/postit-acl-members.

PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
  (Bearer/OIDC scope tests vs. blog API fakes live where they
  belong) and introduces PostItHeadlessCollection so the
  Avalonia.Headless tests share a single xUnit collection
  instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
  POST /api/v1/blogacl) and the fakes it relies on
  (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
  DialogTests); pulls UserId-through-OIDC-sub path into
  BearerScopeTests / FakeAuthorizingBrowser /
  OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
  tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).

Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
  Microsoft.EntityFrameworkCore.Sqlite and rewrites
  BlogsWebServerFixture to hold a single shared
  SqliteConnection (Cache=Shared) for the fixture lifetime,
  with a sync Dispose close to dodge async teardown hangs.
  Reason: the EF Core InMemory provider silently ignores FKs,
  which masked the kind of bug we are about to pin in the
  ACL tests. SQLite enforces them, so any future INSERT that
  forgets to seed its parent rows fails loudly here instead
  of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
  tweak to follow the new connection lifecycle.

Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
  SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
  test that POST/PUT/DELETE a BlogPost and sends AuthorId=
  'tester' in the payload needs an AspNetUsers row to satisfy
  the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
  ResetAndSeedDefaultUser helper for the six mutating tests;
  the four GET-only and ModelState-only tests keep the bare
  ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
  cleanly instead of hanging at teardown — previously a stuck
  test held the shared SqliteConnection open and the next
  tests waited indefinitely.

Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
6825f74308
refacto API prefix + nav.back 2026-08-20 20:50:52 +01:00
995a02197d Merge pull request 'fix(postit): repair Circles bindings + align UserSearchApi route' (#40) from feat/postit-acl-members into release/1.0.8-rc1
Reviewed-on: #40
2026-08-20 07:24:19 +01:00
bd6ca9d11f
fix(postit): repair Circles bindings + align UserSearchApi route
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m51s
- CirclesPage: drop VisualRoot/MainWindow hack, switch to App.PushPageAsync(vm)
- CirclesPageViewModel: make OpenAddMemberAsync public so Avalonia XAML trampoline can call it
- AddCircleMemberDialog: bind SearchAsync/Add (drop Command suffix)
- UserSearchApiController: route under Constants.APIPrefix (= api/v1/user-search), matching the rest of Yavsc.Blogs controllers and the PostIt client's BlogsApiUrl default
2026-08-20 07:22:07 +01:00
e75993ea36 Merge pull request 'fix/postit-acl' (#39) from fix/postit-acl into release/1.0.8-rc1
Reviewed-on: #39
2026-08-20 01:24:42 +01:00
46a9a84fbc
fixes the cirle POST
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m37s
2026-08-20 01:23:14 +01:00
ecfdca8f01
gixes the path to circles API 2026-08-19 21:02:28 +01:00
803e778208
access the post selector 2026-08-19 20:30:34 +01:00
a5ccfde7e1 Merge pull request 'fix/inactive-toolbar-buttons' (#38) from fix/inactive-toolbar-buttons into release/1.0.8-rc1
Reviewed-on: #38
2026-08-19 20:02:48 +01:00
e88920485a
chore(vscode): remove local mcp.json (openclaw moved to global User config).
Some checks failed
Dotnet build and test / build (pull_request) Failing after 17m50s
2026-08-19 19:59:50 +01:00
21a79074cc
chore(vscode): add PATH env to openclaw MCP server config
Lets the openclaw MCP server spawned by VS Code locate dotnet, adb
and other SDK tools (android-sdk, .dotnet/tools) used during
integration sessions.
2026-08-19 19:55:23 +01:00
6d9bb82b61
PostIt: register dialog pages in DI for ViewLocator resolution 2026-08-19 19:53:38 +01:00
600bb81be1
PostIt: show ViewLocator fallback errors in navigation 2026-08-19 17:32:51 +01:00
4cda942fb4
doc: lift PostIt navigation rule to top-level section
The 'Navigation (PostIt)' rule was buried as a sub-item under
'Conventions de code', mixed with style rules. Lift it to a
top-level section between 'Tests' and 'Conventions de code' so
contributors looking for nav guidance find it without scrolling
through editorconfig preferences.

Add a pointer to doc/architecture/postit.md for the full
topology (NavRoot, SessionStatusViewModel, lifecycle signals
vs user-driven nav). Content of the rule itself is unchanged
from 12a71ada — only the placement and the cross-link.
2026-08-19 17:19:43 +01:00
12a71ada6a
doc: align navigation docs with VM-first pattern
The two recent commits (3fbbafc4, 0065de70) replaced the
OpenSettingsRequested event + CurrentViewModel assignment with
App.PushPageAsync(vm): the VM resolves the target ViewModel
through DI, App resolves the Control through the ViewLocator,
guards against double-push, and pushes via NavRoot. The docs
were still describing the pre-refactor world.

Update three places:

- CONTRIBUTING.md — the "Navigation (PostIt)" rule now
  describes App.PushPageAsync as the single channel and shows
  the canonical OpenSettings command as the example.
- doc/architecture/postit.md — the Navigation section
  distinguishes VM-first navigation (App.PushPageAsync) from
  lifecycle signals (LoginSucceeded, LogoutCompleted) and
  drops the obsolete OpenSettingsRequested row.
- src/PostIt/PostIt/App.axaml.cs — refresh the SettingsPage
  singleton justification: point (c) now describes the
  anti-empilement guard inside PushPageAsync, not the
  OpenSettingsRequested handler that no longer exists.

No production behaviour change — doc only (and the inline
comment that referenced a removed event).
2026-08-19 17:17:22 +01:00
0065de7000
PostIt: homogenize VM-first navigation flows 2026-08-19 17:06:46 +01:00
3fbbafc454
PostIt: route VM navigation through ViewLocator 2026-08-19 17:01:58 +01:00
30a0e10bae
fixes the compilation 2026-08-19 15:45:49 +01:00
aa098daed5
doc: codify PostIt navigation rule via ViewLocator
Navigation in PostIt is owned by src/PostIt/PostIt/ViewLocator.cs.
To open a screen, the caller assigns the target ViewModel to the
host's CurrentViewModel (which binds the IContentControl.Content);
the ViewLocator decides which Control instance to push and resolves
it through DI. ViewModels never instantiate views nor resolve them
from DI directly. Add the rule and a canonical example to
CONTRIBUTING.md so contributors do not re-derive the pattern from
scratch each time.
2026-08-19 15:20:29 +01:00
e35bc273a3
refacto BlogPost 2026-08-19 14:19:45 +01:00
84f3ffa9c2
test(postit): pin inoperative toolbar buttons (ACL, Mes cercles, [DEV] Signature) with headless UI tests
Three buttons on MainPage's toolbar are reported as inoperative
in the running app: ACL, Mes cercles, and [DEV] Signature. They
click but no dialog / page opens.

This commit adds headless UI tests that drive each button via
the Avalonia headless harness (KeyPressQwerty(Enter) on a
focused, x:Name'd button, per the CalculatorTests pattern in
Avalonia.Samples) and asserts the post-click top of
NavRoot.NavigationStack is a non-null Page.

The tests fail today on every button (stack size before == after
== 1): the click does not push anything. The bug is the user's
real complaint — the test is now wired to catch it.

To make the buttons reachable by the harness without walking
the visual tree (which does not see buttons hosted inside a
NavigationPage), name the two unnamed buttons:

- ACL  -> ManageAclButton
- Mes cercles -> OpenCirclesButton

([DEV] Signature was already named OpenSignatureDevButton.)

The XAML change is cosmetic; bindings and commands are
untouched. The test pattern follows SessionStatusBannerTests:
new MainWindow().Show(), PushAsync(MainPage), drive controls via
their generated x:Name fields.
2026-08-18 23:24:08 +01:00
f36679aa65
fix(blog): replace IApplicationUser Author with concrete BlogPostAuthorDto
System.Text.Json cannot materialise an interface without a
polymorphic converter. Until this commit, BlogPostDto.Author
was typed as the abstract interface IApplicationUser, which
crashed the "load posts" call in PostIt whenever the server
returned a post with a populated Author object (the common
case — GET /api/BlogApi).

Fix:

* Introduce a minimum-viable wire DTO BlogPostAuthorDto in
  Yavsc.Abstract.Blogspot (record: Id, UserName, Avatar).
  These are the only fields the client UI actually needs;
  the server-side ApplicationUser navigation is preserved
  for permission checks and authorisation.
* Change IBlogPost.Author and BlogPostDto.Author from
  IApplicationUser to BlogPostAuthorDto? (interface change,
  breaking). The EF entity BlogPost keeps its full
  ApplicationUser navigation property and exposes
  IBlogPost.Author via an explicit interface implementation
  that projects to BlogPostAuthorDto on demand (so EF can
  still lazy-load the navigation without forcing an eager
  join on every read).
* Restore the using directive that was accidentally removed
  when the BlogPostDto property was rewritten (needed for
  ICircleAuthorization in GetACL()).

Regression coverage (the missing test Paul flagged):

* Add BlogPostAuthorDtoTests in PostIt.Tests with four
  scenarios that exercise the wire shape on the client side:
  - A BlogPostDto JSON with a populated Author round-trips
    through JsonSerializer without throwing and the three
    fields (Id, UserName, Avatar) survive intact.
  - A BlogPostDto JSON with explicit "author": null
    deserialises with Author == null.
  - A BlogPostDto JSON without any Author field at all
    deserialises with Author == null (forward compat).
  - The serialised shape of BlogPostAuthorDto uses camelCase
    property names (matching the server's Web defaults), so
    the field names on the wire don't drift without a test
    catching it.

Tests: 55/55 PostIt.Tests (+4 new), 24/24 Yavsc.Blogs.Tests,
44/44 Yavsc.Org.Tests. No regressions.

Side note: yavsc.sln picks up Yavsc.Api.Client (added by
'feat/postit-acl' in 1.0.7 but never registered in the
solution file until now — probably auto-added by a recent
'dotnet build' that discovered the .csproj).
2026-08-18 22:01:09 +01:00
40d992ee18 Merge pull request 'feat/postit-fs' (#37) from feat/postit-fs into release/1.0.8-rc1
Reviewed-on: #37
2026-08-18 21:33:37 +01:00
d245d3972d
ci: retrigger Forgejo Actions on PR #37
All checks were successful
Dotnet build and test / build (pull_request) Successful in 7m48s
PR #37 (feat/postit-fs -> release/1.0.8-rc1) was opened before
the buildAndTest.yml workflow trigger was widened to include
release/*. Forgejo Actions does not re-evaluate the workflow
file on its own — a push event on the PR is needed to
re-trigger the CI. This empty commit is the push.
2026-08-18 21:26:40 +01:00
6695c9ed4d
build and test release/*
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-18 21:25:06 +01:00
65f6ad8752
cleanup 2026-08-18 21:14:52 +01:00
72d6497455
Merge branch 'release/1.0.8-rc1' into feat/postit-fs2 2026-08-18 19:32:30 +01:00
1ab6e59fe2
chore(release): bump version via gitversion for 1.0.8-rc1 2026-08-18 19:18:36 +01:00
1167169aa8 Merge pull request 'release/1.0.7' (#34) from release/1.0.7 into main
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 31s
Dotnet build and test / build (push) Successful in 9m5s
Reviewed-on: #34
2026-08-18 19:05:46 +01:00
03fc898af5
chore(release): add 1.0.7 preview section to CHANGELOG
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 14s
Dotnet build and test / build (pull_request) Successful in 10m53s
Forgejo Release / release (push) Successful in 8m42s
The release workflow's validate-release job requires a
'## [TAG] - channel' section in CHANGELOG.md before allowing
the tag to ship. Without this entry, the 1.0.7 tag push
fails the workflow with:

  ::error::No section matching '## [1.0.7]' found in CHANGELOG.md.
  Add a '## [1.0.7] - preview' section before tagging.

The section collects the 35 commits shipped between 1.0.6 and
1.0.7: ACL feature (per-post grants + circle membership), the
Publish toggle that replaces the abandoned Visibility enum,
the make release target, the IYavscApiClient abstraction, the
IContactService/IUserDirectory split, and the Forgejo Actions
release workflow rewrite (bash + jq, runner-provided
GITHUB_TOKEN, .csproj projects built directly inside the
runner container).

The '## [Unreleased]' block is consumed by this section, and
the trailing link reference is updated to point at
1.0.6...1.0.7 for the standard Keep-a-Changelog compare URL.
2026-08-18 18:33:40 +01:00
b613927409
MEF 2026-08-18 18:09:30 +01:00
c44d3db5a4
chore(release): bump version via gitversion for 1.0.8-rc1 2026-08-18 18:08:38 +01:00
5fe0d9eb45 Merge pull request 'release/1.0.7-rc1' (#36) from release/1.0.7-rc1 into release/1.0.7
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 10s
Dotnet build and test / build (pull_request) Successful in 6m45s
Forgejo Release / release (push) Failing after 29s
Reviewed-on: #36
2026-08-18 17:27:48 +01:00
15f018117f
chore(release): bump version via gitversion for 1.0.7-rc1 2026-08-18 17:20:59 +01:00
6e50967702
build(makefile): add 'release' target to automate release branch creation
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 16s
Dotnet build and test / build (push) Successful in 7m14s
Paul wanted a Makefile target that:
- takes the target version as an argument (V=1.0.7-rc1),
- creates a release/<V> branch from main,
- runs 'dotnet-gitversion /updateprojectfiles' to bump
  <Version> across all .csproj from git history,
- commits the bump on the release branch (not on main, so
  main stays clean),
- pushes the new branch to origin.

The /src/**/*.csproj paths and CHANGELOG.md are excluded from
Forgejo's protected-branch rule so the bump commit goes
through on the release branch.

The target refuses to run unless the operator is already on
main with a clean working tree — no automatic checkout to
main, so the bump never lands on the wrong branch by
accident.

The version in the branch name (V=...) is an intent label.
The version GitVersion writes into the .csproj is whatever
GitVersion computes from git history (last tag + commit
count), so assembly versions stay truthful even when the
branch name is aspirational.
2026-08-18 17:20:23 +01:00
495ad6d6be
links
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 17s
Dotnet build and test / build (pull_request) Successful in 11m15s
2026-08-18 16:40:52 +01:00
b89b8bfccc Merge pull request 'feat(post): add Publish toggle for blog posts (no schema change)' (#35) from feat/postit-acl into release/1.0.7
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 19s
Dotnet build and test / build (pull_request) Successful in 10m19s
Reviewed-on: #35
2026-08-18 16:23:23 +01:00
e79f6423db Merge pull request 'feat/postit-acl' (#32) from feat/postit-acl into release/1.0.7
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 16s
Dotnet build and test / build (pull_request) Has been cancelled
Reviewed-on: #32
2026-08-18 16:14:31 +01:00
3fb5f40acb
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.

The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).

Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish  body { publish: bool }
  Returns 204 on success, 404 when the post doesn't exist,
  Challenge() (401) when the caller is not the author
  (EditPermission gate). Idempotent: PUT because the
  resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
  factored out of the existing
  Modify(BlogPostEditViewModel) inline toggle, so the new
  endpoint reuses the same BlogSpotPublication row logic
  (add row if missing on publish=true, remove row if
  present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
  service after each Index/Details fetch — a single bulk
  lookup, not N+1 — and surfaces through the wire JSON
  so PostIt can show the current state without a follow-up
  request.
- ApplicationUser nav properties (Posts, Book,
  DeviceDeclaration, Connections, Circles, BlackList,
  Rooms, RoomAccess, Membership, BlogComments) now carry
  BOTH [JsonIgnore] (Newtonsoft) and
  [System.Text.Json.Serialization.JsonIgnore] so the
  Yavsc.Blogs test fixture (System.Text.Json) stops
  exploding on object cycles when serialising
  BlogPost.Author.Posts.Author.Posts. Production
  (Yavsc.Org, NewtonsoftJson) was already safe via the
  Newtonsoft-only attribute; this commit just makes the
  Yavsc.Blogs side consistent.

Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
  new endpoint.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
  field; serialised as a plain bool in JSON.

UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
  mirrors the existing DraftTitle/DraftArticle pattern;
  hydrated from SelectedPost.IsPublished on selection
  change. TogglePublish command pushes the new state to
  SetPublishAsync and updates both the buffer and the
  selected post locally so the UI reflects the change
  without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
  bound to DraftIsPublished TwoWay and wired to
  TogglePublishCommand. The toggle is its own action
  (not part of Save), matching the wire contract.

Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
    * PUT publish=true returns 204 and IsPublished is true
      in the next GET
    * PUT publish=false clears IsPublished
    * PUT on an unknown post returns 404
    * PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
  app.UseDeveloperExceptionPage() so 500s in tests
  surface a real stack trace instead of an empty
  InternalServerError body — much easier to diagnose
  future regressions.

Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
  in the admin web Yavsc (the Org UI already edits Publish
  inline; no work needed there).
2026-08-18 16:10:45 +01:00
42625f5ddd
Revert "feat(blog): add Visibility { Private, Public } to gate post reads"
This reverts commit 33ecfa7ebd.
2026-08-18 15:40:52 +01:00
33ecfa7ebd
feat(blog): add Visibility { Private, Public } to gate post reads
Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.

Semantics (matches what BlogSpotService.Index / Details enforce):

  Visibility.Public  + empty ACL  : every caller sees it
  Visibility.Public  + non-empty  : only author + ACL circles + admin
  Visibility.Private + any ACL    : only author + admin (ACL ignored)
                                    ACL is preserved across
                                    Private/Public flips so
                                    reopening is lossless

The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.

Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
  Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
  share the same type). Stored as int via .HasConversion<int>()
  on BlogPost.Visibility. Default Private on construction;
  the column default in the migration is 0 so existing rows
  land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
  axis model. Authenticated and anonymous callers now share
  the same shape (Public+emptyACL visible to all, otherwise
  scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
  lookup, replaced with the Visibility + empty-ACL check that
  matches the new model. PermissionHandler.IsSponsor and
  IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
  /CircleMembers/Details and similar): mirror of the
  Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
  still controls whether a row exists in BlogSpotPublication;
  the two systems coexist (Publish = 'is this draft published',
  Visibility = 'who can read it'). Follow-up to consolidate.

EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
  per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
  their indexes/columns on ClientScopes, ClientRedirectUris,
  ClientGrantTypes. These shadow FKs were created by EF from
  HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
  that have long since been removed from
  ApplicationDbContext.OnModelCreating, but the snapshot was
  never regenerated against the current model. The columns
  are nullable ints with no production data, so the drop is
  lossless. Without this, EF Core would keep emitting
  warnings on every migration add and the model would drift
  further from reality.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
  serialises the enum as its underlying int, so the JSON
  shape is a plain number, no JsonConverter needed.

Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
  mirroring the existing DraftTitle/DraftArticle pattern.
  Initialised to Private so a fresh draft is private by
  default. Save command writes the chosen value into the
  BlogPostDto payload for both CreatePostAsync and
  UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
  from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
  in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
  French user-facing labels ('Privé' / 'Public'); registered
  in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
  between Title and Article. Uses the existing 'no hardcoded
  Background without Foreground' lesson so dark mode works.

Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
  Visibility fixtures seeded directly in the in-memory DB:
    * Private + ACL: only the author sees it
    * Public + empty ACL: any authenticated caller sees it
    * Public + non-empty ACL: caller without ACL membership
      does NOT see it
    * Private + ACL: ACL is ignored, only the author sees it
    * Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
  directly so the two code paths (Index filter vs per-author
  feed) can't diverge silently. Same fixture, no HTTP.

Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
  (which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
  still edits posts without a visibility field).
2026-08-18 15:35:22 +01:00
5e3d361f88
feat(acl): server endpoints + client + UI for circle membership
Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.

This commit closes that gap end-to-end:

Server (Yavsc.Blogs)
- GET    /api/circle/{id}/members           list members
- POST   /api/circle/{id}/members           add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId}  remove a user
  All three are scoped to caller == circle.OwnerId; non-owned
  circles return 404 (not 403) to avoid leaking existence, in
  line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
  wire shapes. CircleMemberDto mirrors UserSearchResultDto
  minus Email — membership UI doesn't need contact details.

Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
  add+get, duplicate add → 409, remove, and cross-owner 404.
  Test users (alice, bob) are seeded directly through the
  in-memory DbContext — the Blogs fixture doesn't stand up
  UserManager<ApplicationUser>.

Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
  GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
  All match the server's contract: 404 flattens to null,
  409 surfaces as an exception (callers can dedupe beforehand
  if they want idempotent behaviour).

UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
  that auto-loads on SelectedCircle change (via the partial
  setter generated by [ObservableProperty]). Commands:
  LoadMembersAsync, OpenAddMember (raises an event the view
  subscribes to), OnAddMemberConfirmedAsync (called by the
  view when the dialog confirms a selection), RemoveMemberAsync.
  409 (already a member) is detected from the exception
  message and surfaced as a friendly status rather than an
  error — a likely race when the same user gets added twice
  through two UI paths.

- CirclesPage layout is now two-pane (circles + editor on the
  left, members of the selected circle on the right). The
  member pane has an 'Ajouter un membre' button that opens
  AddCircleMemberDialog. Code-behind wires the dialog's
  Confirmed event back into the VM via an async lambda
  wrapper (EventHandler<T> wants void, the VM method is
  async Task).

- AddCircleMemberDialog is a ContentPage (light modal, same
  pattern as PostAclDialog). Its ViewModel consumes
  IUserDirectory — the abstraction introduced by 04a31709
  to fix the 'user search should not be IContactService'
  confusion. The dialog raises Confirmed with the picked
  UserSummary; the host (CirclesPage) is responsible for
  calling CircleApiClient.AddMemberAsync.

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.

Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
2026-08-18 14:10:12 +01:00
ef59cd1735
fix(circle-api): use User.GetUserId() for owner scoping
The scoping that landed in e376aed8 ("restrict Circle + BlogAcl
reads and writes to caller's own data") reads the caller's uid
with User.FindFirstValue(ClaimTypes.NameIdentifier). That works
when the JWT bearer middleware remaps the "sub" claim to the
long ClaimTypes.NameIdentifier URI — which is the default
behaviour. But the BlogsWebServerFixture test host and any host
that sets MapInboundClaims = false (preserved here to keep
"sub" as "sub" for the resource-based ownership checks in
BlogSpotService) end up with no ClaimTypes.NameIdentifier claim
at all, only "sub". On those hosts, every OwnerId == uid
filter silently returns nothing, so the controller responds 404
even for the caller's own circles.

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

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

No behavioural change for production: there, MapInboundClaims
remains true, ClaimTypes.NameIdentifier is populated, and
GetUserId() returns the same value as FindFirstValue would have.
2026-08-18 14:02:07 +01:00
04a31709a2
refactor(postit): split IContactService from IUserDirectory
IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.

Split into two interfaces, each with a single responsibility:

- IContactService: device-local address book only. Mobile provider
  reads MAUI Essentials Contacts.Default and carries the full email
  list per contact. Desktop provider is an honest stub returning an
  empty list — the desktop has no local address book, and inviting
  external people from desktop is a separate flow (manual email
  entry + invitation endpoint) that doesn't belong here.

- IUserDirectory: central Yavsc user directory, the only consumer
  of /api/user-search. Both Desktop and Mobile providers delegate
  to UserSearchClient; the platform split exists so future
  platform-specific sources (offline cache, directory-scoped
  providers) can plug in without disturbing consumers.

ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.

App.axaml.cs registers both interfaces as singletons.

Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
2026-08-18 13:22:54 +01:00
29a66a8c30 Merge branch 'feat/postit-acl' into feat/app-invite 2026-08-18 01:16:34 +01:00
da3534bd34 Merge branch 'main' into feat/postit-acl
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 15s
Dotnet build and test / build (pull_request) Successful in 5m44s
2026-08-18 01:03:03 +01:00
f4d4c786b4 Merge pull request 'release/1.0.6' (#28) from release/1.0.6 into main
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 19s
Dotnet build and test / build (push) Successful in 7m33s
Reviewed-on: #28
2026-08-18 01:02:44 +01:00
dd8cb60fb4
Forgejo badges 2026-08-18 01:01:22 +01:00
d0e0f4c175
feat(postit): wire Desktop address book to /api/user-search
Replaces the empty ContactService.Desktop stub with a real
implementation backed by UserSearchClient. Closes the loop
between the server-side /api/user-search endpoint (b3056f1c),
the client wrapper (6e7e0414), and the platform abstraction.

IContactService gains:
- SearchAsync(string query, CancellationToken): on desktop,
  hits /api/user-search and appends results to an in-memory
  cache. On mobile, throws PlatformNotSupportedException —
  mobile providers use the device-local address book
  (GetDeviceContactsAsync) and don't talk to a network search.
- Contacts (ObservableCollection<ContactDto>): live view of
  the cache; UI binds directly to it. Mobile populates it
  inside GetDeviceContactsAsync (eager load); desktop populates
  it via SearchAsync (lazy, on-demand).

ContactDto shape changes:
- Emails (IReadOnlyList<string>) -> Email (string?). The
  /api/user-search endpoint returns one email per user. The
  use case ('invite / add to a circle') only needs one.
- Mobile provider flattens its per-contact email list down
  to the first non-empty entry (a small functional loss that
  matches the wire shape).

App.axaml.cs constructs a ContactService from the
UserSearchClient singleton and registers it as
IContactService so future ViewModels can take the interface
by constructor injection.

Build + 51/51 tests green. The mobile provider is still
gated by #if ANDROID || IOS and not exercised by the
Desktop test target — runtime behaviour on Android will
need a smoke test on device when PostIt.Android lands.
2026-08-18 00:36:36 +01:00
6e7e04141b
feat(api-client): add UserSearchClient for /api/user-search
Adds the client-side half of the user-search endpoint landed
on the server in b3056f1c (commit 6 on this branch). The
client mirrors the server's filter contract:

- query: substring match on FullName or UserName
- email: exact match on Email
- take: 1..100, default 25

Empty (query + email) short-circuits to an empty list
client-side rather than letting the server return the first
`take` users alphabetically — the address-book UX is
"type to search", not "show me a directory".

The DTO (Yavsc.Api.Client.Dtos.UserSearchResultDto) is a flat
shape (Id, UserName, FullName, Avatar, Email) with no
navigation properties; field names match the JSON the server
emits so deserialisation is a no-op.

PostIt wiring:
- App.axaml.cs constructs a UserSearchClient singleton and
  registers it alongside CircleApiClient and BlogAclApiClient.
- The PostIt.csproj ProjectReference to Yavsc.Api.Client was
  in place before this commit on feat/postit-acl; the rebase
  of feat/app-invite on top of feat/postit-acl dropped it.
  This commit re-adds it.
2026-08-18 00:34:29 +01:00
69a660cafb
feat(app-invite): isolate ContactService to mobile targets
Splits the single ContactService class (which threw
PlatformNotSupportedException on non-Android/iOS targets) into a
platform-conditional structure:

- IContactService + ContactDto: shared abstraction in
  src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend
  on this; concrete providers map their native shapes to ContactDto.

- ContactService.Mobile.cs: MAUI Essentials implementation, compiled
  only when ANDROID or IOS is defined. Wraps
  Contacts.Default.GetAllAsync() with permission handling and a
  NotImplementedInReferenceAssemblyException safety net.

- ContactService.Desktop.cs: stub returning an empty list, compiled
  when neither ANDROID nor IOS is defined. Replaces the
  'throw PlatformNotSupportedException' path so desktop targets
  (PostIt.Desktop, PostIt.Browser) build and run cleanly.

The Microsoft.Maui.Essentials portable facade is referenced from
PostIt.csproj, but it only becomes functional when the host
application project (PostIt.Android, future PostIt.iOS) also
references the platform-specific implementation.

No tests added: per AGENTS.md, a 'stub returns empty list' test on
PostIt.Tests (net10.0 desktop target) would be cosmetic and not
detect the real failure mode. Android-side tests require a working
PostIt.Android project, which doesn't exist yet.

Future providers (Google Contacts API, Exchange, CardDAV) plug in
as additional IContactService implementations selected by DI
configuration.
2026-08-18 00:31:26 +01:00
a8c219e0fa
WIP app invite: scaffold MAUI Essentials dependency in shared PostIt
Adds Microsoft.Maui.Essentials package and <UseMaui>true</UseMaui> to
src/PostIt/PostIt/PostIt.csproj so the shared project can compile code
that calls MAUI Essentials APIs (Microsoft.Maui.ApplicationModel.*).

Also adds a draft ContactService that wraps Contacts.Default.GetAllAsync()
behind a runtime platform check and permission request.

WIP caveats:
- The portable MAUI Essentials facade compiles on net10.0 but throws
  NotImplementedInReferenceAssemblyException at runtime when no
  platform-specific MAUI Essentials binary is loaded. A PostIt.Android
  project (or equivalent) must reference the Android MAUI Essentials
  implementation for Contacts.Default.GetAllAsync() to actually work.
- On desktop (Linux/macOS/Windows) the API is unsupported by design;
  ContactService currently throws PlatformNotSupportedException. A
  desktop stub returning Array.Empty<Contact>() is the likely next step.
- No tests yet. The scaffold is unverified at runtime; build passes.
2026-08-18 00:31:26 +01:00
ab8e77279b
ci(forgejo): put asset name in URL query string, not as curl arg
Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2)
a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus
de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name'
parameter".

Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...`
était passé en argument positionnel entre `--data-binary @file` et
l'URL. curl l'interprète comme un second fichier d'input (un fichier
nommé '?name=...'), pas comme un query param, et l'API Forgejo ne
voit jamais le name.

Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement.
L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets.
2026-08-18 00:31:25 +01:00
bea2e35bb4
chore(release): update 1.0.6 CHANGELOG section (image v2, jq fix)
La section [1.0.6] - stable du CHANGELOG mentionnait encore
debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH
release qui tombait en 404 à cause du sed greedy + JSON minifié.
Mets à jour avant de relancer la publication de la release
1.0.6 (workflow_dispatch), pour que le body publié reflète l'état
réel de l'infra (image v2 avec jq) et du workflow.
2026-08-18 00:31:25 +01:00
c2d55317ab
ci(forgejo): build JSON bodies with jq instead of hand-rolled sed
L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir
de debian12-dotnet10-android36-v2 (Dockerfile du repo
dotnet-android-build-image, commit e06f096 "adds jq"). On en
profite pour supprimer json_escape et json_field à base de sed,
qui étaient fragiles :

  * sed est greedy par défaut : sur du JSON minifié d'une seule
    ligne (ce que renvoie l'API Forgejo de cette instance pour
    /releases/tags/<tag>), la regex s/.*"id".../\1/p attrape la
    DERNIÈRE occurrence de "id":<digits> sur la ligne, qui est
    l'id de l'auteur de la release (1, premier user du repo),
    pas l'id de la release (10706).
  * Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié :
    il n'isole rien et le sed greedy continue à capturer
    l'id de l'auteur.
  * PATCH /releases/1 tombait alors en 404 "The target couldn't
    be found" (cf. run échoué du 2026-08-17 04:05 sur le tag
    1.0.6).

jq résout les deux problèmes en une fois :
  * jq -r '.id' retourne le champ id racine, pas l'id imbriqué
    dans author.
  * jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease:
    $prerelease}' construit un body JSON proprement échappé
    (backslashes, guillemets, newlines, caractères de contrôle
    Unicode) sans avoir à le reproduire à la main.

Effet de bord : les bodies PATCH et POST sont écrits dans
/tmp/patch.json et /tmp/post.json puis passés à curl via
--data-binary @<file> au lieu d'une variable shell. Plus de
problème de quoting en chaîne shell, plus de collision avec
les espaces ou les caractères spéciaux du body.

Pré-requis côté runner : image pazof/yavsc-build-env:debian12-
dotnet10-android36-v2 (avec jq) + maj du label correspondant
dans la config du runner Forgejo.
2026-08-18 00:31:24 +01:00
24fede0bd0
ci(forgejo): limit json_field extraction to top-level keys
L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON
pretty-printed où l'id racine (release.id, ex. 10706) est sur la
première ligne, mais l'objet author contient aussi un id (souvent 1
pour le premier user du repo). L'ancienne regex sed matchait la
première occurrence globale de "id" dans le fichier, donc elle
retombait sur author.id=1 et le PATCH /releases/1 tombait en 404
'The target couldn't be found'.

Fix : on pipe le fichier dans 'head -3' pour ne matcher que les
premières lignes (couvre largement le préambule de l'objet release).
Si Forgejo renvoie du JSON minifié (une seule ligne), head -3
renvoie toute la ligne et la regex matche le premier id (la racine,
parce que les champs auteur sont après les champs racine).
2026-08-18 00:31:24 +01:00
2df364aa1e
ci(forgejo): build JSON bodies in pure bash, no python3
L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni
node). Le step de publication Forgejo utilisait python3 pour générer
les bodies JSON (POST /releases, PATCH /releases/{id}) et pour
extraire le 'id' de la réponse.

Fix : deux fonctions bash :
- json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t)
- json_field : extraction d'un champ scalaire d'un fichier JSON via sed

Suffisant pour les bodies qu'on envoie (tag_name, name, body,
prerelease) et les champs qu'on lit (id).
2026-08-18 00:31:23 +01:00
copilot-swe-agent[bot]
8960ce7d93
fix(ci): fix validate-release CHANGELOG channel check to inspect heading line
Co-authored-by: pazof <3072814+pazof@users.noreply.github.com>
2026-08-18 00:31:23 +01:00
copilot-swe-agent[bot]
5c20c0bc04
Initial plan 2026-08-18 00:31:23 +01:00
64d25bb2f1
ci(forgejo): build .NET projects directly, skip docker
L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload
Android, mais PAS le binaire 'docker' ni de daemon Docker. Le
'Build de l'image Docker' du workflow plantait avec 'docker: command
not found'.

Fix : on exécute directement les commandes dotnet du Dockerfile
(restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r
android-arm64), puis on copie l'APK depuis le chemin de sortie
standard bin/Release/net10.0-android/android-arm64/.

Note : le Dockerfile reste la voie canonique pour les builds en
local et via GitHub Actions (qui a docker). Ce fix concerne
uniquement le workflow Forgejo Actions où le runner n'a pas Docker.
2026-08-18 00:31:22 +01:00
e07c536e1f
ci(forgejo): check CHANGELOG channel suffix on the section title
The previous awk extracted the section body but excluded the title
line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never
matched. Fix: include the title line in the extracted body, verify
the channel suffix on the title, then strip the title before passing
the body to the release API.
2026-08-18 00:31:22 +01:00
fd99260bc7
ci(forgejo): replace all Node-based actions with bash + curl
The runner's docker label points at pazof/yavsc-build-env, a Debian
image without Node.js. Any action like actions/checkout@v7,
actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc.
fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'.

This workflow is rewritten in pure bash:
- replace actions/checkout with explicit git clone + checkout (full
  history + tags so GitVersion.MsBuild is happy);
- merge the two jobs into one (no inter-job artifacts needed since
  everything shares the runner's filesystem);
- replace rasterstate/forgejo-release-action with direct calls to the
  Forgejo REST API (/api/v1/repos/.../releases, .../assets), with
  python3 used to build and parse JSON bodies (jq not guaranteed in
  the runner image).

Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The
rasterstate action or any other Node-based action can be reinstated
later if the runner image is swapped for one with Node installed.
2026-08-18 00:31:21 +01:00
c4695dc254
ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow
Repo-level secrets creation is broken on this Forgejo instance
(InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely
a text-vs-bytea column type on the secret table). The fix is in
upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto-
provided by the runner, scoped to contents: write for the current
repo) keeps the release workflow operational without any UI setup.

When the instance is upgraded and the secret table is migrated,
revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }}
for least-privilege.
2026-08-18 00:31:21 +01:00
4a15edb9e5
ci(forgejo): publish release with PostIt APK on tag push
Adds .forgejo/workflows/release.yml: triggered by tag push or
workflow_dispatch, it validates the tag/CHANGELOG parity (stable /
preview / unstable), builds the PostIt Android APK via the existing
Dockerfile (--target build-env), and publishes a Forgejo release with
the APK as an asset via rasterstate/forgejo-release-action@v1.

Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml
so the two channels (Forgejo source-of-truth + GitHub mirror) stay
consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo
PAT scoped to write:repository configured in the repository's Actions
secrets.
2026-08-18 00:31:19 +01:00
b3056f1c2e
feat(user-search): add UserSearchApiController in Yavsc.Blogs
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 26s
Dotnet build and test / build (pull_request) Successful in 14m59s
Lives in Yavsc.Blogs (not Yavsc.Api) because Yavsc.Api is not
yet enabled in production; future migration to Yavsc.Api is a
single namespace + route prefix change.

Endpoint: GET /api/user-search?q=<name>&e=<email>&take=<n>
- Authorisation: [Authorize] (any authenticated caller).
- q: case-insensitive substring match on FullName OR UserName.
- e: case-insensitive exact match on Email.
- take: 1..100, default 25.

Returns a flat UserSearchResultDto (Id, UserName, FullName,
Avatar, Email) — no navigation properties, so the payload
stays small even if the user table grows.

The Email field is included because the address-book use case
(composing circle membership, sending invites) needs it.
On Yavsc's single-tenant deployments the user table is a
closed community; multi-tenant deployments should gate this
controller behind a tenant-scoped policy before exposing it.
The trade-off is documented in the controller's class-level
XML doc.
2026-08-18 00:20:10 +01:00
1b289c1387
refactor(model): rename Yavsc.Blogspot.BlogPost to BlogPostDto
When commit 0e95e283 moved BlogPost from PostIt.Models to
Yavsc.Blogspot, it created an unfortunate collision with the
server-side EF entity Yavsc.Models.Blog.BlogPost. The two
classes have nothing in common beyond the name; the DTO is
the wire shape PostIt exchanges with the Blogs API, the EF
entity is the persistence model. Server code that imports both
namespaces (BlogSpotService.cs, etc.) ended up with 'BlogPost
is an ambiguous reference between X and Y' errors.

Renaming the client DTO to BlogPostDto (matching the
naming convention of the other DTOs in Yavsc.Api.Client.Dtos
— CircleDto, CircleAuthorizationDto, UserSearchResultDto)
disambiguates without renaming the EF entity on the server.

The namespace stays Yavsc.Blogspot; only the class name
changes. All call sites (client code, tests, XAML DataTemplates,
XML doc comments) are updated mechanically.
2026-08-18 00:20:01 +01:00
0e7576857d
feat(postit): UI for managing Circles + per-post ACL
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 13s
Dotnet build and test / build (pull_request) Failing after 3m51s
Landing the user-facing surface for the BlogAcl work. The user
can now:

1. Open the 'Mes cercles' page (a new 'Mes cercles' button on
   the main page) and create / edit / delete their own
   circles. The page lists circles in an ObservableCollection
   bound to a ListBox; per-row buttons drive StartEdit and
   Delete; the bottom editor pushes new / edited circles via
   the Save command.

2. With a post selected, click the new 'ACL' button to open a
   modal 'PostAclDialog' for that post. The modal shows the
   current ACL entries (filtered server-side by Allowed.OwnerId
   == caller) and a dropdown of the caller's circles to add.
   Each entry has a 'Revoke' button.

Both pages follow the same pattern:
- ViewModel uses [ObservableProperty] for state and
  [RelayCommand] for verbs; IsBusy drives a ProgressBar
  overlay; StatusMessage surfaces server feedback.
- View follows the XAML-Background/Foreground lesson (no
  hard-coded colours), so dark mode works without
  contrast surprises.
- Code-behind is minimal — just AvaloniaXamlLoader.Load —
  because navigation is driven by RelayCommand + event
  (ManageAclRequested, OpenCirclesRequested) that the
  MainPage code-behind handles via its DataContextChanged
  handler.

The 'complete' scope (c) of this commit was confirmed by
Paul. Three follow-up tracks are deliberately out of scope
and tracked in MEMORY.md (2026-08-18):
- i18n: no .resx / IStringLocalizer today; all visible text
  is hard-coded French.
- Avalonia.Headless UI tests: only ViewModel-level coverage
  is feasible today; full navigation tests are a separate
  effort.
- XAML accessibility audit of pre-existing pages (Settings,
  MainPage) that predate the Background/Foreground lesson.

Build + 51/51 tests green.
2026-08-18 00:06:57 +01:00
a5887a2387
feat(postit): wire Circle + BlogAcl clients in the DI container
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 21s
Dotnet build and test / build (pull_request) Failing after 7m24s
App.axaml.cs is the composition root for PostIt. It now also
builds and registers:
- CircleApiClient (singleton) — backed by the same YavscApiClient
  and the same blogs base URL as BlogApiClient
- BlogAclApiClient (singleton) — same shape
- IYavscApiClient -> YavscApiClient mapping (singleton). The
  concrete class is still resolvable as YavscApiClient; the new
  registration makes the same instance available as
  IYavscApiClient so future consumers (and unit tests) can take
  the interface without coupling to the concrete type.

The 3 high-level clients are singletons: they hold no mutable
state of their own, just a reference to YavscApiClient and a
base URL. Reusing the same instance across requests is what the
HttpClient inside YavscApiClient was already designed for.
2026-08-17 23:51:51 +01:00
f835ad42a1
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.

Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
  now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
  the blogs base URL explicitly in its constructor so it doesn't
  need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
  Same conventions as CircleApiClient.

DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
  navigation properties on the server-side Circle (Owner,
  Members), which depend on ApplicationUser and other server
  types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
  reason: the server entity has Target and Allowed navigation
  properties the client never needs.

The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.

Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
ab40af8ef1
refactor(api-client): introduce IYavscApiClient abstraction in Yavsc.Api.Client
Yavsc.Api.Client is the new home for high-level HTTP clients
(BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It
depends on the host application's transport layer, but the host
(PostIt) is a UI app with OIDC, settings, and an ApplicationData
directory — none of which the abstract client library should
know about.

The IYavscApiClient interface captures just the transport
surface those clients need:
- HttpClient (so the client can configure BaseAddress)
- CallAsync<T> and CallAsync (the JSON over HTTP verb)

It deliberately leaves out LoginAsync / TrySilentLoginAsync /
CurrentAccessToken / HasValidSession / Settings — those are
authentication and configuration concerns, not transport. They
stay on the concrete YavscApiClient in PostIt.Services.

The concrete YavscApiClient now implements IYavscApiClient; the
existing public surface is unchanged (no breaking changes for
existing call sites in PostIt or the tests).

This commit only lays the foundation. The actual high-level
clients (Blog/Circle/BlogAcl) land in a follow-up commit that
re-uses this interface, so this one stays a small, reviewable
refactor.
2026-08-17 23:50:24 +01:00
0e95e28327
refactor(model): move BlogPost DTO from PostIt.Models to Yavsc.Blogspot
BlogPost is shared between the server (Yavsc.Server/Models/Blog/
BlogPost.cs is the EF entity) and any client that talks to the
blogs API. Keeping the client-side DTO in PostIt.Models made
sense when there was only one consumer; now that the
Yavsc.Api.Client project is about to host BlogApiClient alongside
CircleApiClient and BlogAclApiClient, the DTO has to live in a
layer both the client project and PostIt can reference without
inverting the dependency.

Yavsc.Abstract is the existing home for cross-tier interfaces
and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser).
Yavsc.Blogspot is the sub-namespace already used by the
matching interface, so the new concrete class follows.

Why not move Circle and CircleAuthorizationToBlogPost at the
same time? Both depend on the concrete ApplicationUser class
(via the Owner and Target/Allowed navigation properties) which
lives in Yavsc.Server. Moving them would mean either dragging
ApplicationUser into the abstract layer (huge blast radius —
auth, billing, chat, etc.) or weakening the navigation
properties (breaks EF Core shaping). They're staying where
they are; the new Yavsc.Api.Client will get DTO counterparts
instead.

Updated call sites:
- 4 .cs files: replace 'using PostIt.Models;' with
  'using Yavsc.Blogspot;' where the file was actually using
  BlogPost. Files that only used SignaturePadData keep their
  'using PostIt.Models;' — that type stays put.
- 1 .axaml file: xmlns:models="using:PostIt.Models" ->
  xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for
  the post list in MainPage).

Build + tests green (51/51).
2026-08-17 23:45:45 +01:00
e376aed887
fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data
Closes the data-leak holes that survived the move of these controllers
from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its
membership should never be visible, modifiable, or deletable by anyone
other than its owner.

BlogAclApiController:
- GetBlogACL() was returning the full table; now filters by
  Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed)
  so EF Core can push the filter into SQL instead of materialising
  the whole table.
- Other endpoints (GetById, Put, Post, Delete) already enforced
  ownership; left as is.

CircleApiController:
- GetCircle() (no id) now filters by OwnerId.
- GetCircle(id) now requires c.Id == id && c.OwnerId == uid;
  returns 404 (not 403) on miss to avoid leaking the existence of
  someone else's circle.
- PutCircle verifies the existing record is owned by the caller,
  then forces circle.OwnerId = uid on the body (the client's value
  is ignored). Returns ChallengeResult when the caller doesn't own
  the record.
- PostCircle forces circle.OwnerId = uid (was trusting the body).
- DeleteCircle now filters by OwnerId; 404 on miss.

All checks use the same source of truth (User.FindFirstValue(
ClaimTypes.NameIdentifier)) that the existing BlogAclApiController
authz code already relies on.
2026-08-17 23:36:20 +01:00
40e5630cfc
refactor(blogacl): move BlogAcl + Circle controllers from Yavsc.Api to Yavsc.Blogs
These two controllers belong to the Blogs subsystem (their routes
/api/blogacl and /api/circle are blog-domain concerns, not the
generic Api surface). Moving them next to BlogApiController keeps
related code together and prepares the PostIt client to consume
them through the same BlogsApiUrl base address as the existing
BlogApiClient.

Mechanical changes only:
- Namespace Yavsc.Controllers -> Yavsc.Blogs.Controllers
- Drop unused 'using Yavsc.Helpers;' (no symbol in the new
  compilation unit depends on it; the build confirms it was
  dead since the controllers were first written)
- Fix typo in CircleApiController route: 'api/cirle' -> 'api/circle'
  (any client trying to call the documented route was hitting 404)

No functional changes to authorization or query shape. The known
security gaps in these controllers (GetBlogACL and GetCircle
return unfiltered collections, DeleteCircle has no ownership
check) are deliberately left untouched in this commit and will
be addressed in a follow-up.
2026-08-17 23:34:48 +01:00
a4792a7a83
ci(forgejo): put asset name in URL query string, not as curl arg
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 46s
Dotnet build and test / build (pull_request) Successful in 12m18s
Forgejo Release / release (push) Successful in 6m34s
Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2)
a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus
de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name'
parameter".

Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...`
était passé en argument positionnel entre `--data-binary @file` et
l'URL. curl l'interprète comme un second fichier d'input (un fichier
nommé '?name=...'), pas comme un query param, et l'API Forgejo ne
voit jamais le name.

Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement.
L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets.
2026-08-17 05:44:12 +01:00
77fda10347
chore(release): update 1.0.6 CHANGELOG section (image v2, jq fix)
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Failing after 7m7s
Forgejo Release / release (push) Failing after 10m7s
La section [1.0.6] - stable du CHANGELOG mentionnait encore
debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH
release qui tombait en 404 à cause du sed greedy + JSON minifié.
Mets à jour avant de relancer la publication de la release
1.0.6 (workflow_dispatch), pour que le body publié reflète l'état
réel de l'infra (image v2 avec jq) et du workflow.
2026-08-17 05:25:20 +01:00
b390eb7be9 Merge pull request 'fix/forgejo-release-json-field-id' (#31) from fix/forgejo-release-json-field-id into release/1.0.6
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 14s
Dotnet build and test / build (pull_request) Successful in 9m40s
Reviewed-on: #31
2026-08-17 05:07:39 +01:00
c3c54ba5d5
ci(forgejo): build JSON bodies with jq instead of hand-rolled sed
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Successful in 13m27s
L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir
de debian12-dotnet10-android36-v2 (Dockerfile du repo
dotnet-android-build-image, commit e06f096 "adds jq"). On en
profite pour supprimer json_escape et json_field à base de sed,
qui étaient fragiles :

  * sed est greedy par défaut : sur du JSON minifié d'une seule
    ligne (ce que renvoie l'API Forgejo de cette instance pour
    /releases/tags/<tag>), la regex s/.*"id".../\1/p attrape la
    DERNIÈRE occurrence de "id":<digits> sur la ligne, qui est
    l'id de l'auteur de la release (1, premier user du repo),
    pas l'id de la release (10706).
  * Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié :
    il n'isole rien et le sed greedy continue à capturer
    l'id de l'auteur.
  * PATCH /releases/1 tombait alors en 404 "The target couldn't
    be found" (cf. run échoué du 2026-08-17 04:05 sur le tag
    1.0.6).

jq résout les deux problèmes en une fois :
  * jq -r '.id' retourne le champ id racine, pas l'id imbriqué
    dans author.
  * jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease:
    $prerelease}' construit un body JSON proprement échappé
    (backslashes, guillemets, newlines, caractères de contrôle
    Unicode) sans avoir à le reproduire à la main.

Effet de bord : les bodies PATCH et POST sont écrits dans
/tmp/patch.json et /tmp/post.json puis passés à curl via
--data-binary @<file> au lieu d'une variable shell. Plus de
problème de quoting en chaîne shell, plus de collision avec
les espaces ou les caractères spéciaux du body.

Pré-requis côté runner : image pazof/yavsc-build-env:debian12-
dotnet10-android36-v2 (avec jq) + maj du label correspondant
dans la config du runner Forgejo.
2026-08-17 04:35:00 +01:00
2f443e5450 Merge pull request 'ci(forgejo): limit json_field extraction to top-level keys' (#30) from fix/forgejo-release-json-field-id into release/1.0.6
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 20s
Dotnet build and test / build (pull_request) Successful in 5m7s
Forgejo Release / release (push) Failing after 10m25s
Reviewed-on: #30
2026-08-17 03:26:53 +01:00
5186ffb7c8
ci(forgejo): limit json_field extraction to top-level keys
L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON
pretty-printed où l'id racine (release.id, ex. 10706) est sur la
première ligne, mais l'objet author contient aussi un id (souvent 1
pour le premier user du repo). L'ancienne regex sed matchait la
première occurrence globale de "id" dans le fichier, donc elle
retombait sur author.id=1 et le PATCH /releases/1 tombait en 404
'The target couldn't be found'.

Fix : on pipe le fichier dans 'head -3' pour ne matcher que les
premières lignes (couvre largement le préambule de l'objet release).
Si Forgejo renvoie du JSON minifié (une seule ligne), head -3
renvoie toute la ligne et la regex matche le premier id (la racine,
parce que les champs auteur sont après les champs racine).
2026-08-17 03:26:11 +01:00
3222c56ddb Merge pull request 'ci(forgejo): build JSON bodies in pure bash, no python3' (#29) from fix/forgejo-release-bash-json into release/1.0.6
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 19s
Dotnet build and test / build (pull_request) Successful in 5m20s
Forgejo Release / release (push) Failing after 7m52s
Reviewed-on: #29
2026-08-17 02:18:09 +01:00
bbe483cb24
ci(forgejo): build JSON bodies in pure bash, no python3
L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni
node). Le step de publication Forgejo utilisait python3 pour générer
les bodies JSON (POST /releases, PATCH /releases/{id}) et pour
extraire le 'id' de la réponse.

Fix : deux fonctions bash :
- json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t)
- json_field : extraction d'un champ scalaire d'un fichier JSON via sed

Suffisant pour les bodies qu'on envoie (tag_name, name, body,
prerelease) et les champs qu'on lit (id).
2026-08-17 02:16:46 +01:00
63c7dbbd9b
Merge remote-tracking branch 'github/main' into fix/forgejo-release-build-no-docker
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 35s
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-17 02:05:51 +01:00
739cd716ec
Merge pull request #70 from pazof/copilot/fix-validate-release-job
fix(ci): validate-release channel check always failed for stable/preview tags
2026-08-17 02:05:01 +01:00
copilot-swe-agent[bot]
843d6b227f
fix(ci): fix validate-release CHANGELOG channel check to inspect heading line
Co-authored-by: pazof <3072814+pazof@users.noreply.github.com>
2026-08-17 01:02:14 +00:00
copilot-swe-agent[bot]
704f7565fe
Initial plan 2026-08-17 01:00:12 +00:00
f5b1ccee5e Merge pull request 'ci(forgejo): build .NET projects directly, skip docker' (#27) from fix/forgejo-release-build-no-docker into release/1.0.6
Some checks failed
Forgejo Release / release (push) Failing after 10m11s
Reviewed-on: #27
2026-08-17 01:56:42 +01:00
44edf71b12
ci(forgejo): build .NET projects directly, skip docker
L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload
Android, mais PAS le binaire 'docker' ni de daemon Docker. Le
'Build de l'image Docker' du workflow plantait avec 'docker: command
not found'.

Fix : on exécute directement les commandes dotnet du Dockerfile
(restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r
android-arm64), puis on copie l'APK depuis le chemin de sortie
standard bin/Release/net10.0-android/android-arm64/.

Note : le Dockerfile reste la voie canonique pour les builds en
local et via GitHub Actions (qui a docker). Ce fix concerne
uniquement le workflow Forgejo Actions où le runner n'a pas Docker.
2026-08-17 01:56:01 +01:00
69677727cb Merge pull request 'ci(forgejo): check CHANGELOG channel suffix on the section title' (#26) from fix/forgejo-release-changelog-title-check into release/1.0.6
Some checks failed
Forgejo Release / release (push) Failing after 13s
Reviewed-on: #26
2026-08-17 01:50:17 +01:00
5e600c11e1
ci(forgejo): check CHANGELOG channel suffix on the section title
The previous awk extracted the section body but excluded the title
line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never
matched. Fix: include the title line in the extracted body, verify
the channel suffix on the title, then strip the title before passing
the body to the release API.
2026-08-17 01:49:45 +01:00
f2776b34e3 Merge pull request 'ci(forgejo): rewrite release workflow in pure bash + curl' (#25) from fix/forgejo-release-native-bash into release/1.0.6
Some checks failed
Forgejo Release / release (push) Failing after 13s
Reviewed-on: #25
2026-08-17 01:45:54 +01:00
5b957c6cbb
ci(forgejo): replace all Node-based actions with bash + curl
The runner's docker label points at pazof/yavsc-build-env, a Debian
image without Node.js. Any action like actions/checkout@v7,
actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc.
fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'.

This workflow is rewritten in pure bash:
- replace actions/checkout with explicit git clone + checkout (full
  history + tags so GitVersion.MsBuild is happy);
- merge the two jobs into one (no inter-job artifacts needed since
  everything shares the runner's filesystem);
- replace rasterstate/forgejo-release-action with direct calls to the
  Forgejo REST API (/api/v1/repos/.../releases, .../assets), with
  python3 used to build and parse JSON bodies (jq not guaranteed in
  the runner image).

Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The
rasterstate action or any other Node-based action can be reinstated
later if the runner image is swapped for one with Node installed.
2026-08-17 01:45:22 +01:00
19da7909ce Merge pull request 'ci(github): backport fetch-depth fix on apk-deploy checkout to release/1.0.6' (#24) from fix/github-apk-checkout-fetch-depth-backport into release/1.0.6
Some checks failed
Forgejo Release / validate-release (push) Failing after 10s
Forgejo Release / release (push) Has been skipped
Reviewed-on: #24
2026-08-17 01:37:50 +01:00
b69c382beb
Checkout
1. complet de l’historique Git et des tags dans le job qui build l’APK via Docker:
2. with tags
2026-08-17 01:36:55 +01:00
c5c4d6b59a Merge pull request 'ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow' (#23) from fix/forgejo-release-use-runner-token into release/1.0.6
Reviewed-on: #23
2026-08-17 01:25:13 +01:00
80cb8c46fc
ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow
Repo-level secrets creation is broken on this Forgejo instance
(InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely
a text-vs-bytea column type on the secret table). The fix is in
upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto-
provided by the runner, scoped to contents: write for the current
repo) keeps the release workflow operational without any UI setup.

When the instance is upgraded and the secret table is migrated,
revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }}
for least-privilege.
2026-08-17 01:23:44 +01:00
70a69779fa Merge pull request 'ci(forgejo): publish release with PostIt APK on tag push' (#22) from feat/forgejo-release-page into release/1.0.6
Some checks failed
Forgejo Release / validate-release (push) Failing after 23s
Forgejo Release / release (push) Has been skipped
Reviewed-on: #22
2026-08-17 00:53:59 +01:00
3dd4700404
ci(forgejo): publish release with PostIt APK on tag push
Adds .forgejo/workflows/release.yml: triggered by tag push or
workflow_dispatch, it validates the tag/CHANGELOG parity (stable /
preview / unstable), builds the PostIt Android APK via the existing
Dockerfile (--target build-env), and publishes a Forgejo release with
the APK as an asset via rasterstate/forgejo-release-action@v1.

Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml
so the two channels (Forgejo source-of-truth + GitHub mirror) stay
consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo
PAT scoped to write:repository configured in the repository's Actions
secrets.
2026-08-17 00:46:51 +01:00
af8762d085 Merge pull request 'fix/workflow-checkout-fetch-depth' (#21) from fix/workflow-checkout-fetch-depth into main
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 16s
Dotnet build and test / build (push) Successful in 6m46s
Reviewed-on: #21
2026-08-17 00:15:45 +01:00
ed689cde96
Merge pull request #69 from pazof/fix/workflow-checkout-fetch-depth
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Successful in 6m45s
Fix/workflow checkout fetch depth
2026-08-17 00:09:25 +01:00
0fc3b81a0f
Checkout
1. complet de l’historique Git et des tags dans le job qui build l’APK via Docker:
2. with tags
2026-08-17 00:06:32 +01:00
56f115fa3e Merge pull request 'ci(release): re-check release/1.0.6 with billing init fix' (#20) from fix/post-release-recheck into release/1.0.6
Reviewed-on: #20
2026-08-16 16:52:11 +01:00
b3d1911302
ci: re-check CI on release/1.0.6 with billing init fix 2026-08-16 16:49:44 +01:00
f74577f80a Merge pull request 'fix(billing): tolerate ReflectionTypeLoadException during init' (#19) from fix/billing-init-reflection into release/1.0.6
Reviewed-on: #19
2026-08-16 16:43:09 +01:00
7f84d4d97a
fix(billing): tolerate ReflectionTypeLoadException during init
ConfigureBillingService() walks AppDomain.CurrentDomain.GetAssemblies()
and calls Assembly.GetTypes() on each. If any of the loaded assemblies
has a type that fails to resolve (a flaky dependency, an AddOn with a
broken reference, a test dependency that's been rewritten after compile),
GetTypes() throws ReflectionTypeLoadException (or, less commonly,
FileNotFoundException / TypeLoadException for the assembly itself).

In CI on the forgejo-runner (and especially in test discovery under
xunit v3), one such assembly is loaded somewhere between test runs and
silently throws. The exception is not handled, so:

  1. Collections are Cleared at the top of ConfigureBillingService().
  2. The reflection loop throws before reaching the
     RegisterBilling<HairCutQuery/HairMultiCutQuery/RdvQuery> calls.
  3. BillingService.Billing ends up empty (Count = 0).
  4. The second ConfigureBillingService() call sees the same assembly
     loaded (xunit v3 keeps the AppDomain warm for the whole suite),
     throws identically, and the test
     Yavsc.BillingServiceTests.ConfigureBillingService_CanBeCalledTwiceWithoutThrowing
     fails with 'Assert.Equal() Failure: Expected 3, Actual 0'.

Fix: catch ReflectionTypeLoadException and use the partial
.Types() list (the successfully-resolved subset), and use a
broader catch (with continue) for any other assembly-level
load failure. The lost user-settings types are not material;
they are derived from ApplicationDbContext in a separate loop
right after, and the RegisterBilling<>() calls that populate
BillingService.Billing run last, after both reflective phases
have completed best-effort.

The test still passes locally because the local test environment
loads a clean set of assemblies; only the CI runner (with its
extra test-time tooling) hits this path.
2026-08-16 16:35:05 +01:00
f92d23b54f
release: 1.0.6
Patch is even (6) and bare, so this is classified as 'stable' by
the validate-release job in .github/workflows/docker-publish-android.yml.

Move the Unreleased section up by inserting [1.0.6] below it, with
a list of changes that landed on this release:

  - Self-hosted Forgejo Actions runner now drives CI on yavsc,
    using pazof/yavsc-build-env:debian12-dotnet10-android36-v1
    pulled from Docker Hub.
  - .forgejo/workflows/buildAndTest.yml builds without
    actions/checkout (image has no Node) and uses NuGet.config
    for the isn.pschneider.fr feed.
  - Dockerfile / Dockerfile.backend drop the redundant
    'dotnet nuget add source' step that broke the APK build on
    GitHub Actions.
2026-08-16 16:18:37 +01:00
d8555a6827
Merge branch 'main' into feat/postit-release-page 2026-08-16 16:07:16 +01:00
4370019785 Merge pull request 'dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr'' (#18) from fix/github-action-apk into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 15s
Dotnet build and test / build (push) Failing after 3m49s
Reviewed-on: #18
2026-08-16 15:51:08 +01:00
eaa4c16936
dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr'
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 16s
Dotnet build and test / build (pull_request) Failing after 7m32s
The project-level NuGet.config (added in 94012c51) lists the isn feed
so 'dotnet restore' picks it up without an inline 'dotnet nuget add
source' step.

The inline add source was duplicating NuGet.config and causing build
failures in GitHub Actions:
  - The --allow-insecure-connections flag did not match the actual
    HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert,
    not self-signed), making the step fail with 'exit code 1'.
  - docker build --target build-env (used by
    .github/workflows/docker-publish-android.yml) hit this on every
    run.

Both Dockerfile and Dockerfile.backend had the same redundant step;
both removed. 'dotnet restore' still finds the feed via NuGet.config
at /src/NuGet.config (copied in by 'COPY . .').
2026-08-16 14:10:10 +01:00
abc507c0f3
dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr'
The project-level NuGet.config (added in 94012c51) lists the isn feed
so 'dotnet restore' picks it up without an inline 'dotnet nuget add
source' step.

The inline add source was duplicating NuGet.config and causing build
failures in GitHub Actions:
  - The --allow-insecure-connections flag did not match the actual
    HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert,
    not self-signed), making the step fail with 'exit code 1'.
  - docker build --target build-env (used by
    .github/workflows/docker-publish-android.yml) hit this on every
    run.

Both Dockerfile and Dockerfile.backend had the same redundant step;
both removed. 'dotnet restore' still finds the feed via NuGet.config
at /src/NuGet.config (copied in by 'COPY . .').
2026-08-16 14:09:11 +01:00
4862260608 Merge pull request 'forgejo/ci: run build in pazof/yavsc-build-env container' (#17) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 9s
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #17
2026-08-16 13:50:12 +01:00
cc50a8bbc8
ci: unshallow clone for GitVersion
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Successful in 5m41s
GitVersion.MsBuild fails on shallow clones ('Repository is a shallow
clone. Git repositories must contain the full history.') because it
walks the git log to compute the SemVer version.

Drop --depth 1 from both the PR ref fetch and the submodule update
so the runner's working tree has full history. The repo is small
enough that the cost is negligible.
2026-08-16 13:39:53 +01:00
94012c51ab
nuget: add NuGet.config pointing at isn.pschneider.fr
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 14s
Dotnet build and test / build (pull_request) Failing after 1m58s
The yavsc solution depends on HigginsSoft.IdentityServer8.* 8.1.0-alpha.*,
which is only published on the internal feed https://isn.pschneider.fr.
Public nuget.org has 8.0.4 as the nearest version, so every project that
uses IdentityServer8 (Yavsc.Org, Yavsc.Api, Yavsc.Blogs, Yavsc.Server,
cli, Yavsc.Org.Tests, Yavsc.Blogs.Tests) fails with NU1102 on restore.

Both feeds are reachable anonymously, so listing isn first and
nuget.org second in a project-level config restores everything without
credentials. The CI runner on forgejo now sees the same sources as a
local clone.
2026-08-16 13:35:44 +01:00
5f50135c7f
ci: drop working-directory, cd into /src/_src explicitly
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 2m1s
forgejo-runner v13 does not interpolate ${{ runner.workspace }}
in working-directory: (or ignores the field entirely for docker
containers), so the container tried to chdir to '/_src' (literally)
which does not exist.

The image WORKDIR is /src, so clone directly into /src/_src and cd
into it at the start of each step. Adds an echo of the checkout
SHA + branch state for visibility in the log.
2026-08-16 13:27:36 +01:00
86e59ad1c1
submodule: switch URL from SSH to HTTPS for forgejo anonymous access
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 13s
The runner container does not have an SSH client, and even if it
did, no key is configured for it. Forgejo Actions must reach the
submodule over HTTPS with anonymous read access (which is now
enabled on the Forgejo instance).

Use 'git submodule sync --recursive' on the developer side after
checkout to propagate the URL change to .git/modules/.
2026-08-16 13:26:12 +01:00
7370f48aac
ci: clone via GITHUB_REF instead of GITHUB_REF_NAME
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 12s
In pull_request context, GITHUB_REF_NAME is the PR number ('17'),
not the source branch. Cloning --branch 17 fails with
'Could not find remote branch 17 to clone'.

Use GITHUB_REF (refs/pull/N/head in PR context, refs/heads/<branch>
in push context) and fetch + checkout FETCH_HEAD. workflow_dispatch
falls back to the default branch.
2026-08-16 13:22:56 +01:00
380b5d12c8
ci: replace actions/checkout with manual git clone (image has no node)
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 9s
The pazof/yavsc-build-env:debian12-dotnet10-android36-v1 image only
ships .NET 10 SDK + Android SDK + JDK 17, no Node. actions/checkout@v6
requires Node, so the job failed with 'exec: node not found'.

Replace actions/checkout with a direct git clone over HTTPS (Forgejo
anonymous is enabled), and init submodules recursively.

Also drop the bogus docker://image:tag runs-on: matcher, use just 'docker'
to match the runner's declared label name.
2026-08-16 13:15:10 +01:00
4ac8e14ba9
run on docker
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 2m25s
2026-08-16 13:03:17 +01:00
e165e7bb61
dotnet-android-build-image: pin to <sha-ou-tag>
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 15s
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-16 12:49:50 +01:00
0fe293bcd2
forgejo/ci: run build in pazof/yavsc-build-env container
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 17s
Dotnet build and test / build (pull_request) Has been cancelled
Le workflow buildAndTest tournait sur un runner nu debian-latest avec
setup-dotnet@v5 pour la SDK 10.0.x. Restore échouait car cet
environnement n'a ni la source NuGet interne (isn.pschneider.fr) ni
les workloads Android configurés, contrairement à l'image
pazof/yavsc-build-env utilisée par le Dockerfile.

Bascule le job sur un runner labelisé docker avec l'image
debian12-dotnet10-android36-v1 directement. Le step setup-dotnet
devient inutile (l'image a déjà la SDK 10.0), le restore partage
la même config que le Dockerfile.

Refs l'image cible par ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1.
2026-08-15 22:18:03 +01:00
a906a96c94 Merge pull request 'postit: validate CHANGELOG section on tag, classify stable/preview/unstable' (#16) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 20s
Dotnet build and test / build (push) Failing after 1m23s
Reviewed-on: #16
2026-08-15 22:02:18 +01:00
93f39ca872
forgejo/ci: pin dotnet sdk 10.0.x for buildAndTest
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Failing after 30s
Le repo cible net10.0 partout (csproj, TFM), mais le workflow CI
Forgejo buildAndTest installait une SDK 9.0.x. Aligne sur 10.0.x
pour que la CI build avec une SDK qui connaît le TFM net10.0.

Pas de global.json ajouté : la SDK est résolue à l'installation
de l'image runner, le repo reste agnostique de la version exacte.
2026-08-15 22:00:36 +01:00
2a8c854a4c Merge branch 'main' into feat/postit-release-page
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Failing after 32s
2026-08-15 20:35:39 +01:00
99a62ebf81
postit: validate CHANGELOG section on tag, classify stable/preview/unstable
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Has been cancelled
Rend le job publish-release dépendant d'un nouveau job validate-release
qui :
- parse le tag (format MAJOR.MINOR.PATCH[-SUFFIX])
- classifie le canal : pair=stable, impair=preview, suffixe=instable
- fail-fast sur instable sauf opt-in explicite via workflow_dispatch
- vérifie que CHANGELOG.md contient une section ## [<tag>] - <canal>
- expose le body de la section via $GITHUB_ENV pour le job de publication

Le tag trigger passe de 'v*' à '*' (pas de préfixe sur les tags), et
le corps de release GitHub est désormais curé via CHANGELOG.md plutôt
que généré automatiquement.

Cette convention de parité est partagée avec le dépôt postit-debian
pour la production des paquets .deb (alignement à traiter dans une PR
séparée).
2026-08-15 20:34:47 +01:00
c24935c94b Merge pull request 'feat/postit-release-page' (#15) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 9s
Dotnet build and test / build (push) Failing after 49s
Reviewed-on: #15
2026-08-15 18:13:05 +01:00
825d54439c
postit: validate CHANGELOG section on tag, classify stable/preview/unstable
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
Rend le job publish-release dépendant d'un nouveau job validate-release
qui :
- parse le tag (format MAJOR.MINOR.PATCH[-SUFFIX])
- classifie le canal : pair=stable, impair=preview, suffixe=instable
- fail-fast sur instable sauf opt-in explicite via workflow_dispatch
- vérifie que CHANGELOG.md contient une section ## [<tag>] - <canal>
- expose le body de la section via $GITHUB_ENV pour le job de publication

Le tag trigger passe de 'v*' à '*' (pas de préfixe sur les tags), et
le corps de release GitHub est désormais curé via CHANGELOG.md plutôt
que généré automatiquement.

Cette convention de parité est partagée avec le dépôt postit-debian
pour la production des paquets .deb (alignement à traiter dans une PR
séparée).
2026-08-15 15:51:42 +01:00
e3987d7890
postit: add CHANGELOG.md with Keep a Changelog format
Initialise le changelog du projet au format Keep a Changelog 1.1.0,
en français, avec une section [Unreleased] vide prête à être curée
au moment de la première release.

Le préambule documente la convention de parité du patch :
- pair → stable
- impair → preview
- suffixe → instable

Cette convention est partagée avec le dépôt postit-debian pour la
production des paquets .deb (alignement à traiter dans une PR séparée).
2026-08-15 15:44:47 +01:00
eebc83cf7e
Add comments API support and tests
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Dotnet build and test / log-the-inputs (push) Has been cancelled
2026-08-10 22:27:52 +01:00
0fd9e40d67
Fix blog comment endpoint path and add regression test
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 21:48:03 +01:00
0d3fbf22c3
GetUserId_reads_NameIdentifier_when_sub_was_mapped
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 18:34:01 +01:00
44b391d496
Activity protection
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 18:12:59 +01:00
cd03b04755
re-refacto BlogPost serialization
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Dotnet build and test / log-the-inputs (push) Has been cancelled
2026-08-05 21:11:22 +01:00
64547840e4
refacto BlogPost serialization
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-05 21:05:14 +01:00
181 changed files with 11277 additions and 926 deletions

View file

@ -21,33 +21,28 @@ on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
branches: [ "main", "release/*" ]
jobs:
log-the-inputs:
runs-on: debian-latest
steps:
- run: |
echo "Log level: $LEVEL"
echo "Tags: $TAGS"
echo "Environment: $ENVIRONMENT"
env:
LEVEL: ${{ inputs.logLevel }}
TAGS: ${{ inputs.tags }}
build:
runs-on: debian-latest
runs-on: docker
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 9.0.x
- name: Clone yavsc
run: |
cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
cd _src
if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF"
git checkout FETCH_HEAD
fi
git submodule update --init --recursive
echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Restore dependencies
run: dotnet restore
run: cd /src/_src && dotnet restore
- name: Build
run: dotnet build --no-restore
run: cd /src/_src && dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
run: cd /src/_src && dotnet test --no-build --verbosity normal

View file

@ -0,0 +1,330 @@
# Build and publish a release on the Forgejo source-of-truth instance
# with the PostIt Android APK as an attached asset.
#
# Triggered by a push of a git tag. Validates the tag/changelog pair,
# builds the APK using the existing Dockerfile (--target build-env), then
# publishes a Forgejo release via the Forgejo REST API and uploads the
# APK as an asset.
#
# Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the
# Forgejo runner, scoped to contents: write for the current repo). A
# dedicated PAT (${{ secrets.RELEASE_TOKEN }}) was the preferred option
# for least-privilege, but creating repo-level secrets is currently
# broken on this Forgejo instance (InsertEncryptedSecret fails with a
# UTF-8 byte-sequence error, probably a text-vs-bytea column type on
# the secret table). Bumping to Forgejo v16 should fix it; until then,
# the runner-provided token keeps the workflow operational.
#
# Why bash + jq + curl, no third-party actions: the runner's docker
# label points at pazof/yavsc-build-env, a Debian image with jq but
# without Node.js or python3. Any action like actions/checkout,
# rasterstate/forgejo-release-action, etc. fails with "executable
# file not found in $PATH". jq is shipped in the image from
# debian12-dotnet10-android36-v2 onward; earlier tags fell back to
# hand-rolled JSON building via sed, which was fragile (cf. PR #30:
# sed greedy + head -3 still matched author.id instead of the
# release id on the minified JSON this instance returns, PATCH
# /releases/1 → 404). Same constraint as
# .forgejo/workflows/buildAndTest.yml.
#
# This workflow complements .github/workflows/docker-publish-android.yml
# which targets the GitHub mirror; the validate-release logic mirrors
# the GitHub-side job so the two channels stay consistent.
name: Forgejo Release
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).'
required: true
type: string
permissions:
contents: write
jobs:
# Job unique : validation tag/CHANGELOG + build APK + publication
# via l'API REST Forgejo (pas d'actions tierces Node).
release:
runs-on: docker
steps:
- name: Clone du repo au tag demandé
env:
# En push tag : github.ref_name est le tag.
# En workflow_dispatch : on lit l'input 'tag'.
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
run: |
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input."
exit 1
fi
# WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile).
cd /src
# Clone unshallow pour que GitVersion.MsBuild ait l'historique
# et les tags (sinon MSB3073 sur la cible Android cf. PR #21).
if [[ ! -d _src/.git ]]; then
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
fi
cd _src
git fetch --tags --force --prune origin
git checkout "$TAG"
echo "Checked out at $(git rev-parse HEAD) on $(git describe --tags --always 2>/dev/null || echo unknown)"
- name: Valider le tag et la section CHANGELOG
run: |
cd /src/_src
TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --short HEAD)"
echo "Validating tag $TAG"
# Parse semver : MAJOR.MINOR.PATCH[-SUFFIX]
if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format."
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
SUFFIX="${BASH_REMATCH[4]}"
# Classification du canal par parité du patch.
# Patch pair + pas de suffixe -> stable.
# Patch impair + pas de suffixe -> preview.
# Suffixe présent -> instable.
if [[ -n "$SUFFIX" ]]; then
CHANNEL="unstable"
elif (( PATCH % 2 == 0 )); then
CHANNEL="stable"
else
CHANNEL="preview"
fi
echo "Tag $TAG classifié comme channel=$CHANNEL"
# Seuls les suffixes explicitement autorisés déclenchent un
# release : -rcN et -betaN. Les autres suffixes (-alpha*,
# -dev*, -preview*, etc.) restent refusés — ils sont
# utilisables localement pour itérer, mais ne doivent pas
# être publiés comme release publique.
if [[ "$CHANNEL" == "unstable" ]]; then
if [[ ! "$SUFFIX" =~ ^-(rc|beta)([0-9]+)?$ ]]; then
echo "::error::Tag '$TAG' has suffix '$SUFFIX' which is not in the allowed release suffixes (-rcN, -betaN). Refusing to publish."
exit 1
fi
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then
echo "::error::CHANGELOG.md not found at repo root."
exit 1
fi
# Extraction de la section [TAG]. On cherche la première ligne
# commençant par '## [' qui contient '[TAG]' (entre '## [' et
# la prochaine ligne '## [' ou fin de fichier). awk en mode
# paragraphe suffit et reste POSIX. On garde aussi le titre
# (ligne `## [TAG] - channel`) pour la vérification du canal.
BODY=$(awk -v tag="[$TAG]" '
/^## \[/ {
if (in_section) exit
if (index($0, tag) > 0) {
in_section=1
print
next
}
}
in_section { print }
' CHANGELOG.md)
if [[ -z "$BODY" ]]; then
echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md."
echo "Add a '## [$TAG] - $CHANNEL' section before tagging."
exit 1
fi
# Vérification cohérence du canal déclaré dans le suffixe.
# Format attendu : "## [TAG] - stable" / "- preview" / "- unstable".
# On lit la première ligne du body qui contient le titre.
TITLE=$(echo "$BODY" | head -1)
if [[ "$TITLE" != *" - $CHANNEL"* ]]; then
echo "::error::Section title '$TITLE' must declare suffix '- $CHANNEL' to match tag parity."
exit 1
fi
# Body pour la release : retire la première ligne (titre).
BODY=$(echo "$BODY" | tail -n +2)
echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL"
# Expose channel + body pour les étapes suivantes via $GITHUB_ENV.
echo "RELEASE_CHANNEL=$CHANNEL" >> "$GITHUB_ENV"
echo "RELEASE_BODY<<EOF" >> "$GITHUB_ENV"
echo "$BODY" >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV"
- name: Build des projets .NET (sans docker)
# L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le
# workload Android, mais PAS le binaire `docker` ni de daemon
# Docker. On exécute donc les commandes dotnet directement
# au lieu de passer par `docker build`.
# Equivalent des stages build-env du Dockerfile (lignes
# restore + build Yavsc.Org + build Yavsc.Api + build
# Yavsc.Blogs + build PostIt.Android -r android-arm64).
run: |
cd /src/_src
dotnet restore
dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release --no-restore -clp:ErrorsOnly -r android-arm64
- name: Copier l'APK signé vers un emplacement connu
# Le build Android avec -r android-arm64 produit l'APK dans
# bin/Release/net10.0-android/android-arm64/. On le copie à
# la racine du checkout pour que l'étape d'upload le trouve.
run: |
cd /src/_src
APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk
if [[ ! -f "$APK" ]]; then
echo "::error::APK not found at $APK"
ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true
exit 1
fi
cp "$APK" /src/_src/PostIt.Android.apk
ls -la /src/_src/PostIt.Android.apk
- name: Publier la release Forgejo via l'API REST
# Pas d'action tierce (pas de Node dans l'image runner).
# On parle à l'API Forgejo directement via curl.
# Docs : https://forgejo.pschneider.fr/api/swagger#/repository/release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: |
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call."
exit 1
fi
# Le runner Forgejo expose l'API sur github.api_url (par
# défaut http://…/api/v1). On retire le suffixe /api/v1 s'il
# est présent pour dériver la base du serveur, puis on
# reconstruit l'URL de l'API proprement.
API_BASE="${GITHUB_API_URL%/}"
API_BASE="${API_BASE%/api/v1}"
# Construction des bodies JSON et extraction de champs via
# jq. L'image runner pazof/yavsc-build-env installe jq
# (>= 1.7) depuis debian12-dotnet10-android36-v2. La
# chaîne de construction --arg/--argjson garantit un
# escaping correct (backslashes, guillemets, newlines,
# caractères de contrôle Unicode) sans avoir à le
# reproduire à la main.
#
# json_escape et json_field à base de sed ont vécu : le
# sed greedy matche la dernière occurrence d'un champ
# dans la ligne, et l'API renvoie sur cette instance un
# JSON minifié d'une seule ligne où l'id de l'auteur
# (1, premier user du repo) suit l'id de la release
# (10706). PATCH /releases/<sed-captured-id> tombait
# alors en 404 "The target couldn't be found". jq
# résout les deux problèmes en une fois.
# 1. Vérifier si la release existe déjà pour ce tag.
echo "::group::Check existing release for tag $TAG"
HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")
echo "GET releases/tags/$TAG -> HTTP $HTTP"
EXISTING_ID=""
if [[ "$HTTP" == "200" ]]; then
EXISTING_ID=$(jq -r '.id // empty' /tmp/existing.json)
echo "Existing release id: ${EXISTING_ID:-none}"
fi
echo "::endgroup::"
# 2. Créer ou mettre à jour la release.
if [[ -n "$EXISTING_ID" ]]; then
echo "::group::Update release id=$EXISTING_ID"
jq -n \
--arg body "$RELEASE_BODY" \
--argjson prerelease "$IS_PRERELEASE" \
'{body: $body, prerelease: $prerelease}' \
> /tmp/patch.json
HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-binary @/tmp/patch.json \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID")
echo "PATCH release -> HTTP $HTTP"
echo "::endgroup::"
else
echo "::group::Create release"
jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
--arg body "$RELEASE_BODY" \
--argjson prerelease "$IS_PRERELEASE" \
'{tag_name: $tag, name: $name, body: $body, prerelease: $prerelease}' \
> /tmp/post.json
HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-binary @/tmp/post.json \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases")
echo "POST release -> HTTP $HTTP"
echo "::endgroup::"
fi
if [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then
echo "::error::Release creation/update failed (HTTP $HTTP):"
cat /tmp/release.json
exit 1
fi
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
echo "Release id=$RELEASE_ID"
# 3. Upload l'APK en asset.
# Le nom du fichier passe en query string (?name=...), pas
# en argument positionnel entre --data-binary et l'URL :
# sinon curl l'interprète comme un second fichier d'input
# (un fichier nommé '?name=PostIt.Android.apk') et l'API
# Forgejo renvoie 400 "Missing 'name' parameter".
echo "::group::Upload APK asset"
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary "@/src/_src/PostIt.Android.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk")
echo "POST asset -> HTTP $HTTP"
echo "::endgroup::"
if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json
exit 1
fi
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"

View file

@ -5,8 +5,14 @@ on:
branches:
- main
tags:
- 'v*'
- '*'
workflow_dispatch:
inputs:
force_unstable:
description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.'
required: false
type: boolean
default: false
# softprops/action-gh-release a besoin de contents: write
# pour publier une release + uploader un asset.
@ -19,6 +25,9 @@ jobs:
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
# 1. Votre étape de build actuelle (on nomme l'image "postit-android")
# --target build-env : on ne veut que le stage de build (qui
@ -41,11 +50,116 @@ jobs:
path: ./PostIt.Android.apk
retention-days: 7
publish-release:
# Uniquement déclenché par un tag v*. Le job apk-deploy tourne en
# parallèle, on partage l'artefact entre jobs.
# Job de validation : parse le tag, vérifie le format, applique la règle
# de parité du patch (pair=stable / impair=preview / suffixe=instable),
# et s'assure que CHANGELOG.md contient une section cohérente.
# Sans ce job, le job publish-release peut être bypassé (un attaquant
# qui contrôle un tag ne peut pas publier de release sans une section
# changelog cohérente).
validate-release:
if: startsWith(github.ref, 'refs/tags/')
needs: apk-deploy
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Valider le tag et la section CHANGELOG
env:
FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }}
run: |
TAG="${GITHUB_REF_NAME}"
# Parse semver : MAJOR.MINOR.PATCH[-SUFFIX]
if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format."
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
SUFFIX="${BASH_REMATCH[4]}"
# Classification du canal par parité du patch.
# Patch pair + pas de suffixe -> stable.
# Patch impair + pas de suffixe -> preview.
# Suffixe présent -> instable.
if [[ -n "$SUFFIX" ]]; then
CHANNEL="unstable"
elif (( PATCH % 2 == 0 )); then
CHANNEL="stable"
else
CHANNEL="preview"
fi
echo "Tag $TAG classifié comme channel=$CHANNEL"
# Fail-fast sur instable sauf opt-in explicite via workflow_dispatch.
if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then
echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
echo "Set force_unstable=true via workflow_dispatch to override."
exit 1
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then
echo "::error::CHANGELOG.md not found at repo root."
exit 1
fi
# Extraction de la section [TAG]. On cherche la première ligne
# commençant par '## [' qui contient '[TAG]' (entre '## [' et
# la prochaine ligne '## [' ou fin de fichier). awk en mode
# paragraphe suffit et reste POSIX.
BODY=$(awk -v tag="[$TAG]" '
/^## \[/ {
if (in_section) exit
if (index($0, tag) > 0) in_section=1
next
}
in_section { print }
' CHANGELOG.md)
if [[ -z "$BODY" ]]; then
echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md."
echo "Add a '## [$TAG] - $CHANNEL' section before tagging."
exit 1
fi
# Vérification cohérence du canal déclaré dans le titre de section.
# Format attendu : "## [TAG] - stable" / "- preview" / "- unstable".
HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md)
if [[ "$HEADER" != *" - $CHANNEL"* ]]; then
echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity."
echo "Current section header: $HEADER"
exit 1
fi
echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL"
# Exposition aux étapes suivantes via $GITHUB_ENV.
# heredoc <<EOF pour le body multi-lignes (pattern GitHub Actions).
{
echo "RELEASE_BODY<<EOF"
echo "$BODY"
echo "EOF"
echo "RELEASE_CHANNEL=$CHANNEL"
if [[ "$CHANNEL" == "stable" ]]; then
echo "IS_PRERELEASE=false"
else
echo "IS_PRERELEASE=true"
fi
} >> "$GITHUB_ENV"
publish-release:
# Déclenché uniquement par un push de tag. Le job apk-deploy produit
# l'artefact ; validate-release garantit la cohérence du tag et du
# changelog avant publication.
if: startsWith(github.ref, 'refs/tags/')
needs: [apk-deploy, validate-release]
runs-on: ubuntu-latest
steps:
- name: Récupérer l'APK depuis l'artefact
@ -61,7 +175,9 @@ jobs:
# apparaîtra dans l'asset et donc dans le permalink :
# https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
files: ./PostIt.Android.apk
# generate_release_notes: true -> évite d'avoir à maintenir
# le corps de release à la main. Décommente si tu veux.
# generate_release_notes: true
# Le body est extrait de la section CHANGELOG.md correspondant
# au tag, exposée par validate-release via $GITHUB_ENV.
body: ${{ env.RELEASE_BODY }}
# stable -> false (marque comme Latest).
# preview / unstable -> true (visible mais pas Latest).
prerelease: ${{ env.IS_PRERELEASE }}

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "external/dotnet-android-build-image"]
path = external/dotnet-android-build-image
url = https://forgejo.pschneider.fr/notazof/dotnet-android-build-image.git

11
.vscode/mcp.json vendored
View file

@ -1,11 +0,0 @@
{
"servers": {
"openclaw": {
"type": "stdio",
"command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node",
"args": [
"/home/paul/Workspace/tools/openclaw-mcp-server.js"
]
}
}
}

209
CHANGELOG.md Normal file
View file

@ -0,0 +1,209 @@
# Changelog
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
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`.
## [1.0.8-rc1] - unstable
### Added
- `BlogAclApiTests.PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test`
: test de non-régression qui épingle la forme exacte du payload
que PostIt envoie à `POST /api/v1/blogacl` (un objet
`PostAccessControlRulePayload` avec `CircleId` et `BlogPostId`).
C'est le verrou côté test du fix applicatif PostIt + serveur.
- `BlogAclApiTests.PostCircleAuthorization_never_returns_500` : une
`[Theory]` couvrant quatre shapes de payload (`{ circleId }`,
corps vide, `{ blogPostId }` seul, `{ circleId, blogPostId: 0 }`)
qui doivent tous retourner un statut différent de 500. Toute
réintroduction d'un chemin 500 dans le futur fera rougir ce test.
- `BlogAclApiTests.PostCircleAuthorization_dosent_return_500` et
`..._dosent_return_500_on_success` : entry points `[Fact]` qui
appellent la `[Theory]` ci-dessus avec un payload spécifique
chacun, pour pouvoir filtrer en isolation depuis la ligne de
commande ou le CI.
- Règle « Pas de `object` dans le code source applicatif » ajoutée
à `CONTRIBUTING.md` : types de retour, paramètres, champs,
propriétés, variables locales doivent être typés statiquement.
`dynamic` est interdit pour les mêmes raisons.
### Changed
- `BlogAclApiController.CheckOwner` devient `CheckOwnerAsync` et
utilise `FirstOrDefaultAsync` au lieu de `First`, supprimant
l'appel LINQ synchrone sur le fil de la requête et retournant
`false` sur cercle manquant (le contrôleur mappe déjà cela vers
`ChallengeResult`).
- `BlogsWebServerFixture` seed `alice`, son `Circle` et son
`BlogPost` une seule fois au démarrage du host, sur la
`SqliteConnection` partagée (`Cache=Shared`). Le précédent
`EnsureDeleted` au début de chaque test fermait la connexion
statique et détruisait le store `:memory:` pour tous les autres
`DbContext` ; il est retiré au profit d'un `EnsureCreated`
idempotent.
### Fixed
- `POST /api/v1/blogacl` ne retourne plus 500 sur les payloads
dont `BlogPostId` est absent ou à zéro. Le contrôleur rejette
`BlogPostId <= 0` avec `400 BadRequest` avant que la requête
n'atteigne `SaveChangesAsync`. L'incident de prod du 2026-08-21
sur mercure (PostIt envoyant seulement `circleId`, le serveur
voyant `BlogPostId = default(long) = 0` et EF Core levant
`InvalidOperationException` sur l'INSERT) n'est plus atteignable.
- PostIt `PostAclDialogViewModel.AddAsync` envoie désormais le
payload explicite `PostAccessControlRulePayload { CircleId,
BlogPostId }` au lieu de l'ancien `CircleAuthorization {
CircleId }`. Le DTO serveur `PostAccessControlRulePayload` est
introduit dans `Yavsc.Abstract` pour porter le contrat.
## [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=<version>` target: creates a `release/<V>` branch
from `main`, bumps the `<Version>` 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.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1
[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
### Added
- Self-hosted Forgejo Actions runner now drives the CI build for the
yavsc repository, using the
`pazof/yavsc-build-env:debian12-dotnet10-android36-v2` image pulled
from Docker Hub. Workflow runs end-to-end: clone, restore, build,
test, with NuGet.config picking up the `isn.pschneider.fr` feed.
- The build-env image now ships `jq` (Debian package, ≥ 1.7), so the
release workflow can build JSON bodies and parse API responses
without a hand-rolled `sed`-based extractor that was matching the
wrong `id` field on minified responses.
### Changed
- CI workflow `.forgejo/workflows/buildAndTest.yml` no longer relies on
`actions/checkout` (the runner image has no Node); clones yavsc via
`git`, fetches the ref under test, and initializes submodules over
HTTPS.
### Fixed
- `Dockerfile` and `Dockerfile.backend` no longer carry a redundant
`dotnet nuget add source` step that conflicted with the GitHub
Actions APK build (`--allow-insecure-connections` on an HTTPS
endpoint, exit 1). `NuGet.config` at the repo root supplies the
`isn.pschneider.fr` feed for every restore, including inside Docker.
- `.forgejo/workflows/release.yml`: PATCH on `/releases/{id}` no longer
404s on existing releases. The previous `sed`-based `json_field`
matched the last `id` on the line (the author's), so it tried to
PATCH `/releases/1` (the first user of the instance) instead of the
actual release id. Switched to `jq` for both body construction and
field extraction.
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6

View file

@ -49,6 +49,57 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Navigation (PostIt)
La navigation est centralisée dans
`App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`).
Pour ouvrir un écran, un ViewModel (généralement dans une
commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`PushPageAsync` résout la `Control` correspondante via le
`ViewLocator` (un `IDataTemplate` enregistré dans
`Application.DataTemplates` au boot), l'identifie comme
`Page`, lui assigne le VM comme `DataContext`, et appelle
`NavRoot.PushAsync(page)`. Une garde anti-empilement
compare par référence la nouvelle page au sommet courant
de la stack pour éviter un push doublon.
Pour qu'une nouvelle page soit navigable, il faut *deux*
enregistrements : la page dans le DI (`AddTransient<TPage>`
ou `AddSingleton<TPage>`) **et** une case dans le `switch`
de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash.
Règles :
- On n'instancie jamais une `View` à la main depuis un
ViewModel, on ne récupère jamais une `View` depuis la DI
directement dans un ViewModel.
- Le ViewModel qui déclenche la nav ne pousse pas lui-même
la page ; il appelle `App.PushPageAsync(vm)` et laisse
`App` orchestrer le `PushAsync` physique.
- Le ViewModel qui déclenche la nav ne capture pas de
référence à `MainWindow` ou `NavigationPage`. Il passe
par `App.Current` (l'app Avalonia est un singleton).
Exemple canonique (depuis `MainPageViewModel`) :
```csharp
[RelayCommand]
internal async Task OpenSettings()
{
var settingsVm = ((App)App.Current!).ServiceProvider
.GetRequiredService<Settings>();
await ((App)App.Current!).PushPageAsync(settingsVm)
.ConfigureAwait(true);
}
```
Cf. [doc/architecture/postit.md](./doc/architecture/postit.md)
pour la topologie complète (host de navigation,
`SessionStatusViewModel`, signaux de cycle de vie vs nav
utilisateur).
## Conventions de code
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en
@ -64,6 +115,13 @@ Quelques règles non capturées par `.editorconfig` :
- Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`).
- Préférer les expressions de pattern matching aux casts explicites.
- **Pas de `object` dans le code source applicatif.** Types de retour,
paramètres, champs, propriétés, variables locales : tout doit être
typé statiquement. `dynamic` est interdit pour les mêmes raisons.
Un cast en `object` est presque toujours le symptôme d'un contrat
qu'on a laissé s'effriter (DTO, payload, handler) — refactore
le contrat (record typé, DTO dédié, méthode dédiée) au lieu de
shimer avec un cast.
## Branches & commits

View file

@ -11,5 +11,6 @@
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
</PropertyGroup>
</Project>

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -46,10 +46,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source
COPY . .
# (3) Source NuGet interne (Letsencrypt, certificat auto-signé côté
# serveur, justifié par build privé).
RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json --allow-insecure-connections
# (4) Restore
RUN dotnet restore

View file

@ -25,9 +25,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . .
# 3. Restauration des dépendances avec vos workloads actifs
RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json
# 4. Restauration des dépendances pour tous les projets
RUN dotnet restore

View file

@ -48,5 +48,77 @@ docker-build:
docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc
# Crée une branche release/<V> depuis main, met à jour les
# `<Version>` des .csproj via dotnet-gitversion, et la
# pousse sur origin.
#
# Usage : make release V=1.0.7-rc1
#
# Pré-requis : être sur main, working tree clean. La cible
# vérifie les deux et refuse sinon — elle ne fait JAMAIS
# de checkout automatique, c'est à l'opérateur de s'être
# positionné sur la bonne branche au préalable (sinon le
# bump pourrait partir sur une branche tierce par accident).
#
# Notes :
# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
# donne release/1.0.7-rc1). C'est une étiquette d'intention,
# pas la version assembly.
# - La version dans les .csproj vient de GitVersion qui la
# calcule depuis l'historique git (tag le plus proche +
# nombre de commits). C'est la version assembly réelle.
# - L'ordre (fetch → branche → bump → push) garantit qu'on
# part d'un main synchro et qu'on ne pollue pas main avec
# le bump (qui vit sur la branche release).
# - Fail-fast si la branche existe déjà en local ou sur origin.
release:
@if [ -z "$(V)" ]; then \
echo "Usage: make release V=<version>"; \
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \
fi
@CURRENT=$$(git branch --show-current); \
if [ "$$CURRENT" != "main" ]; then \
echo "Refus : la cible doit être lancée depuis main."; \
echo " Branche courante : $$CURRENT"; \
echo " Fais : git checkout main && git pull --ff-only origin main"; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \
exit 1; \
fi
@BRANCH="release/$(V)"; \
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
echo "La branche $$BRANCH existe déjà en local."; \
echo " Pour la supprimer : git branch -D $$BRANCH"; \
exit 1; \
fi; \
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
echo "La branche $$BRANCH existe déjà sur origin."; \
exit 1; \
fi; \
echo "==> Fetch + vérification synchro main"; \
git fetch origin main; \
if ! git merge-base --is-ancestor origin/main HEAD; then \
echo "main a avancé plus loin que HEAD. Fais :"; \
echo " git pull --ff-only origin main"; \
exit 1; \
fi; \
echo "==> Création de $$BRANCH depuis main"; \
git checkout -b "$$BRANCH"; \
echo "==> dotnet-gitversion /updateprojectfiles"; \
dotnet-gitversion /updateprojectfiles; \
echo "==> Commit du bump"; \
git add .; \
if git diff --cached --quiet; then \
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
else \
git commit -m "chore(release): bump version via gitversion for $(V)"; \
fi; \
echo "==> Push de $$BRANCH sur origin"; \
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
.PHONY: test
.PHONY: test release

23
NuGet.config Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Project-level NuGet configuration.
The yavsc solution depends on HigginsSoft.IdentityServer8.* 8.1.0-alpha.*,
published only on the internal feed https://isn.pschneider.fr. The public
nuget.org feed has 8.0.4 as the nearest version, which causes NU1102 on
restore for every project that depends on it (Yavsc.Org, Yavsc.Api,
Yavsc.Blogs, Yavsc.Server, cli, tests).
Listing 'isn' before 'nuget.org' here ensures that restore finds the
alpha packages first, then falls back to nuget.org for everything else.
Both feeds are reachable anonymously; no credentials are stored here.
See AGENTS.md for the rationale.
-->
<configuration>
<packageSources>
<clear />
<add key="isn" value="https://isn.pschneider.fr/api/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View file

@ -1,9 +1,19 @@
# Yavsc
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
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
)
# 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)

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Api Org Blogs
APP_PROJECT_NAMES=Org Blogs
SLNDIR=..
include $(SLNDIR)/.env
@ -7,7 +7,6 @@ include .env
generated/:
@mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service:
generated/yavscBlogs.service:
@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
copy-services: copy-service-Org copy-service-Api copy-service-Blogs
copy-services: copy-service-Org copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services
copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@ -55,7 +53,7 @@ copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-serv
done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
sudo cp $^ $@
sudo chown root:root $@
@ -65,14 +63,14 @@ build_publish_%: clean_publish_dir_%
clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
install: build_publish copy-binaries copy-services
install: build_publish copy-binaries copy-services
@sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \
do \
sudo systemctl enable yavsc$${project} ; \
sudo systemctl start yavsc$${project} ; \
done
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
@ -86,13 +84,12 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig:
showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
clean:
@rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

@ -129,30 +129,51 @@ le DI est construit. Ordre, dans cet ordre :
## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
posé sur `MainWindow.axaml`. La pile est gérée par les
événements du `SessionStatusViewModel` :
posé sur `MainWindow.axaml`. La pile est gérée par deux
mécanismes distincts :
| Événement | Effet |
|---------------------------------|------------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans
une commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`)
résout la `Control` correspondante via le `ViewLocator`
enregistré dans `Application.DataTemplates`, l'identifie
comme `Page`, lui assigne le VM comme `DataContext`, et
appelle `NavRoot.PushAsync(page)`. C'est le seul chemin
pour les boutons de la toolbar, les `OpenSettings` /
`OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et
toute autre nav déclenchée par un ViewModel.
2. **Signaux de cycle de vie** : le `SessionStatusViewModel`
lève des événements consommés dans
`App.OnFrameworkInitializationCompleted` pour orchestrer
la nav de boot :
| Événement | Effet |
|---------------------|------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
Ces events ne sont **pas** un canal de nav utilisateur ; ils
portent une transition d'état applicatif (authentification
établie / perdue) et c'est `App` qui choisit d'en faire une
transition de pile.
### Garde anti-empilement
`NavigationPage.PushAsync` n'est pas idempotent : pousser deux
fois la même instance l'empile deux fois, et l'utilisateur doit
taper **Retour** N fois pour sortir. Le handler
`OpenSettingsRequested` est gardé pour bloquer ce cas :
taper **Retour** N fois pour sortir. La garde est implémentée
dans `App.PushPageAsync` (et consommée par tous les chemins
de nav utilisateur) :
```csharp
var settingsPage = provider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return; // déjà au sommet, no-op silencieux
return Task.CompletedTask; // déjà au sommet, no-op silencieux
}
_ = w.NavRoot.PushAsync(settingsPage);
return window.NavRoot.PushAsync(page);
```
La comparaison est par référence, pas par type : on ne veut
@ -178,9 +199,11 @@ qui ne tiendrait plus).
- `SessionStatusViewModel` est le seul VM avec une durée de vie
**process-entière** (singleton). Il survit à toutes les
navigations, expose `HasValidSession` en continu, et porte
les trois événements qui pilotent la navigation
(`LoginSucceeded`, `LogoutCompleted`,
`OpenSettingsRequested`).
les événements de cycle de vie consommés par `App` pour
orchestrer la nav de boot (`LoginSucceeded`,
`LogoutCompleted`). La nav utilisateur déclenchée par
l'utilisateur passe par `App.PushPageAsync(vm)`, pas par
un événement du `SessionStatusViewModel`.
- `MainPageViewModel` / `HomePageViewModel` /
`SignaturePageViewModel` sont `Transient` — une nouvelle
@ -233,10 +256,14 @@ pour `[RelayCommand]`".
`ViewLocator.Build`. Oublier le `ViewLocator` est silencieux
(juste un TextBlock "No view for X"), pas une exception.
- **Ajouter un événement global de navigation** (par ex.
"Push après payment success") : passer par un événement sur
un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`),
pas par une référence à `MainWindow` depuis le VM. Garder
les VMs découplés du `IClassicDesktopStyleApplicationLifetime`.
"Push après payment success") : ne pas capturer `MainWindow`
ni `NavigationPage` depuis le VM. La nav passe par
`App.PushPageAsync(vm)` dans tous les cas : soit le VM
appelle la méthode directement depuis une commande
(`[RelayCommand]`), soit un handler abonné à un événement
d'un singleton (cf. `SessionStatusViewModel`) l'appelle.
Garder les VMs découplés du
`IClassicDesktopStyleApplicationLifetime`.
- **Modifier l'OIDC** : la fiche à lire est
[postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche
ne ré-explique ni le flow, ni le pipe, ni le custom scheme.

@ -0,0 +1 @@
Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d

View file

@ -0,0 +1,156 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless coverage for the two interactive buttons of the
/// "add a circle member" modal: "Ajouter" and "Fermer".
///
/// <para>The dialog is pushed on top of <see cref="CirclesPage"/>
/// via the canonical <c>App.PushPageAsync</c> pipeline (the
/// same path <c>CirclesPageViewModel.OpenAddMemberAsync</c>
/// uses). The test asserts on <c>NavRoot.NavigationStack</c>
/// size before and after each click — the user's bug was "I
/// click and nothing happens", so the failure mode is a stack
/// that doesn't shrink for "Fermer", and a "Confirmer" event
/// that the host doesn't pick up for "Ajouter" (the dialog
/// stays up = stack doesn't shrink either).</para>
///
/// <para>Pattern follows <c>MainPageButtonsTests</c>: name
/// every interactive control in XAML with <c>x:Name</c>,
/// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para>
/// </summary>
public class AddCircleMemberDialogTests
{
/// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never
/// exercised in these tests — the picker starts empty and
/// the "Ajouter" button's IsEnabled is bound to a null
/// selection, which keeps the click harmless even when
/// its <see cref="AddCircleMemberDialogViewModel.Add"/>
/// command does fire.
/// </summary>
private sealed class StubUserDirectory : IUserDirectory
{
public Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<UserSummary>>(new List<UserSummary>());
}
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static async Task<TestAppContext> BuildApp()
{
TestAppContext context = new TestAppContext
{
};
return context;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta.
/// The graph exposes <c>IUserDirectory</c> (so the dialog
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary>
private static async Task<TestAppContext> Mount()
{
TestAppContext context = new TestAppContext();
var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/");
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton<IUserDirectory>(new StubUserDirectory());
services.AddSingleton(circleClient);
services.AddTransient<CirclesPage>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<AddCircleMemberDialog>();
services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider();
context.Window = new MainWindow();
context.App = (PostIt.App)Application.Current!;
context.App.DataTemplates.Clear();
context.App.DataTemplates.Add(new ViewLocator(sp));
context.App.AttachMainWindow(context.Window);
context.Window.Show();
context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult();
// The "Ajouter un membre" command on CirclesPage builds
// the dialog VM directly (it knows the directory from
// the service provider) and pushes it via App.PushPage.
await context.App.PushPageAsync(sp.GetRequiredService<AddCircleMemberDialogViewModel>());
context.dialog = context.Window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
return context;
}
/// <summary>
/// Click the "Fermer" button on the dialog and assert the
/// nav stack shrinks by exactly one.
/// </summary>
[AvaloniaFact]
public async Task Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var context = await Mount();
var window = context.Window!;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);
// Act
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException();
// The "Fermer" button uses a Click handler (not a
// Command), so RaiseEvent(Button.ClickEvent) is the
// right way to fire it from headless code. Executing
// Command would no-op because no Command is bound.
// FIXME Assert.NotNull(dialog.CloseButton):
// in order to click it by its def :
// dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
// The workaround is to execute the action like it's written :
await context.App!.GoBackAsync();
// Assert: stack -1, the top is the CirclesPage again.
Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1,
$"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
Assert.IsType<CirclesPage>(window.NavRoot.NavigationStack[^1]);
}
}

View file

@ -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<BlogPost>).
// deserialises to List<BlogPostDto>).
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);

View file

@ -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
/// <summary>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.</summary>
@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(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<BlogPost>))
return Task.FromResult((T)(object)new List<BlogPost>
if (typeof(T) == typeof(List<BlogPostDto>))
return Task.FromResult((T)(object)new List<BlogPostDto>
{
new() { Id = 42, Title = "Mon premier billet" }
});

View file

@ -0,0 +1,169 @@
using System.Text.Json;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Round-trip tests for the wire shape of a blog post as
/// serialised by Yavsc.Blogs and consumed by PostIt.
///
/// <para>
/// Background: in 1.0.7, <c>BlogPostDto.Author</c> was typed as
/// the abstract interface <c>IApplicationUser</c>. System.Text.Json
/// cannot materialise an interface without a polymorphic
/// converter, so the "load posts" call from PostIt crashed when
/// the server returned a post with a populated <c>Author</c>
/// object. The fix replaced <c>IApplicationUser</c> with a thin
/// concrete DTO, <c>BlogPostAuthorDto</c>, embedded directly in
/// <c>BlogPostDto.Author</c>.
/// </para>
///
/// <para>
/// These tests pin the wire shape: a JSON document with an
/// <c>Author</c> object must deserialise without throwing and
/// must round-trip the three fields PostIt exposes in the UI
/// (Id, UserName, Avatar). They are intentionally placed in
/// <c>PostIt.Tests</c> — the client-side assembly — so the
/// regression is caught at the deserialisation boundary, where
/// it actually manifested in production.
/// </para>
/// </summary>
public class BlogPostAuthorDtoTests
{
private static readonly JsonSerializerOptions CaseInsensitiveJson
= new() { PropertyNameCaseInsensitive = true };
[Fact]
public void BlogPostDto_deserialises_with_populated_author()
{
// A representative JSON shape the server would emit for
// GET /api/BlogApi. The Author object is fully populated
// — that's the shape that used to break deserialisation
// when Author was typed as the abstract IApplicationUser
// interface.
var json = """
{
"id": 42,
"title": "Premier billet",
"article": "Contenu",
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-02T12:00:00Z",
"userCreated": "alice",
"userModified": "alice",
"authorId": "u-alice",
"isPublished": true,
"author": {
"id": "u-alice",
"userName": "alice",
"avatar": "/avatars/alice.png"
}
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Equal(42, post!.Id);
Assert.Equal("Premier billet", post.Title);
Assert.Equal("u-alice", post.AuthorId);
Assert.True(post.IsPublished);
// The actual regression coverage: Author must
// materialise as a concrete DTO, not be left null because
// of a JsonException on IApplicationUser.
Assert.NotNull(post.Author);
Assert.Equal("u-alice", post.Author!.Id);
Assert.Equal("alice", post.Author.UserName);
Assert.Equal("/avatars/alice.png", post.Author.Avatar);
}
[Fact]
public void BlogPostDto_deserialises_when_author_is_null()
{
// The server is allowed to omit Author (the field is
// nullable on the wire — it maps to a navigation
// property that may not have been Included). The client
// must accept that shape without throwing.
var json = """
{
"id": 7,
"title": "Sans auteur",
"article": null,
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-01T12:00:00Z",
"userCreated": "system",
"userModified": "system",
"authorId": "system",
"isPublished": false,
"author": null
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
Assert.Equal("system", post.AuthorId);
}
[Fact]
public void BlogPostDto_deserialises_when_author_field_is_missing()
{
// Forward-compatibility: an older server that doesn't
// emit the Author field at all. Should not throw.
var json = """
{
"id": 9,
"title": "Ancien format",
"article": "Pas d'auteur dans la charge utile",
"photo": null,
"dateCreated": "2026-07-01T12:00:00Z",
"dateModified": "2026-07-01T12:00:00Z",
"userCreated": "bob",
"userModified": "bob",
"authorId": "u-bob",
"isPublished": true
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
}
[Fact]
public void BlogPostAuthorDto_serialises_back_to_expected_json_shape()
{
// Pin the wire shape on the way out too. The server
// builds BlogPostAuthorDto from an ApplicationUser and
// PostIt receives it as JSON; if the field names
// change (e.g. case) the round-trip on the client side
// is what would silently break.
//
// The server emits camelCase (ASP.NET Core's Web
// defaults — PropertyNamingPolicy = CamelCase). We
// mirror that here so the test reflects what the wire
// actually looks like. PropertyNameCaseInsensitive on
// the client deserialiser means we don't have to
// hardcode the casing for the inbound assertions.
var author = new BlogPostAuthorDto
{
Id = "u-alice",
UserName = "alice",
Avatar = "/avatars/alice.png"
};
var json = JsonSerializer.Serialize(author,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("id", out _));
Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _));
}
}

View file

@ -0,0 +1,239 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the three toolbar buttons on
/// <see cref="MainPage"/> that the user reported as inoperative:
/// "ACL", "Mes cercles", and "[DEV] Signature".
///
/// <para>Pattern (per the Avalonia headless testing docs —
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
/// interactive control in the XAML with <c>x:Name="..."</c>, then
/// in the test focus the named control and raise the click via
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
/// the supported path — searching the visual tree via
/// <c>GetVisualDescendants().OfType&lt;Button&gt;()</c> for a
/// button by Content text is brittle and was tried first; it does
/// not work reliably when the page is hosted inside an
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
/// pushed page in an internal container that the visual-tree walk
/// does not always expose under headless.</para>
///
/// <para>The assertion is on the post-click top of
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
/// the user's bug is "I click and the dialog / page never opens",
/// so the test fails when the click doesn't push anything onto the
/// stack. We pin γ + sniff léger — the new top must be a non-null
/// <see cref="Page"/>, but we do not yet assert the concrete type
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
/// which is the next iteration of this suite).</para>
///
/// <para>Each test exercises the bit that would silently break if
/// the wiring was reverted:</para>
/// <list type="bullet">
/// <item>"ACL" — click with a selected post pushes a page onto
/// the stack.</item>
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
/// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item>
/// </list>
/// </summary>
public class MainPageButtonsTests
{
/// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
/// wire call. These tests never invoke a command that hits
/// the API — only the click → nav side of the pipeline is
/// asserted.
/// </summary>
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
// suite to avoid side effects from App.BuildServices() (real
// token-store wiring).
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
services.AddSingleton(acl);
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
/// named buttons. The window is shown so the visual tree is
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
{
var window = new MainWindow();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
}
/// <summary>
/// Click a button by focusing it and pressing Enter — the
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
/// <c>NullReferenceException</c> from the headless impl
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// </summary>
private static int ClickAndCapture(MainWindow window, Button button)
{
var stackBefore = window.NavRoot.NavigationStack.Count;
button.Command?.Execute(button.CommandParameter);
if (button.Command is IAsyncRelayCommand asyncCommand)
{
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
}
return stackBefore;
}
[AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is
// armed.
var post = new BlogPostDto
{
Id = 42,
Title = "An existing post",
AuthorId = "u-alice"
};
var vm = MakeViewModel(post);
var (window, page) = MountMainPage(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
// binding) and the rest of the test is moot.
var aclButton = page.ManageAclButton;
Assert.NotNull(aclButton.Command);
Assert.True(aclButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
var stackBefore = ClickAndCapture(window, circlesButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via
// DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, signatureButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
}

View file

@ -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
/// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
/// <c>if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } }</c>
/// 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 <c>Title</c>/<c>Article</c>
@ -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<BlogPost>(body);
var sent = Assert.IsType<BlogPostDto>(body);
Assert.Equal(typed, sent.Title);
}
}

View file

@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
/// </summary>
public class PostAclDialogTests
{
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
/// <summary>
/// Build a minimal DI graph exposing the two API clients
/// (backed by a stub HTTP handler) and the page itself, so
/// <c>ViewLocator</c> can resolve the dialog from the VM.
/// Returns the handler, the API clients, and the window so
/// the test can assert on request counts and push the
/// dialog via the canonical <c>App.PushPageAsync</c> path.
/// The DI graph is built into a local <see cref="IServiceProvider"/>
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(aclClient);
services.AddSingleton(circleClient);
services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider();
// Hold the sp alive for the test scope; otherwise the
// GC could collect the singletons between Mount() and
// the assertion below, and we'd lose the wiring to the
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainWindow();
var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler);
}
/// <summary>
/// The bug: opening the dialog never called LoadAsync, so
/// MyCircles/AclEntries were empty. After the fix, setting
/// the dialog's DataContext to a PostAclDialogViewModel
/// (the same path App.PushPageAsync takes) must trigger
/// exactly one LoadAsync round-trip (the parallel WhenAll
/// inside the VM counts as one request per backend call,
/// hence two HTTP requests total: GET /blogacl and GET
/// /circle).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_runs_once_on_DataContext_changed()
{
// Arrange
var (window, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 42, Title = "Test post" };
// Sanity: handler starts quiet.
Assert.Equal(0, handler.RequestCount);
// Act: push the dialog via the canonical VM-first pipeline.
// The locator goes through the parameterless ctor of
// PostAclDialog, then App.PushPageAsync assigns DataContext,
// which our hook intercepts to trigger LoadAsync.
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await ((App)Application.Current!).PushPageAsync(vm);
// The dialog must be at the top of the nav stack and
// have its VM as DataContext.
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
?? throw new InvalidOperationException("Dialog not at top of stack");
Assert.Same(vm, dialog.DataContext);
// Drain pending async work. LoadAsync is async and the
// DataContextChanged handler is fire-and-forget; a
// couple of loop turns is enough. We poll the handler
// counter because the dispatch back onto the headless
// dispatcher isn't strict — using a generous-but-bounded
// wait avoids test flakes.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
{
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);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
}
/// <summary>
/// The fix exposes a guard on the VM too: a second call to
/// LoadAsync on the same instance must NOT issue more HTTP
/// traffic. This protects against the
/// DataContextChanged-firing-twice case (DataContext
/// overwritten mid-life, edge cases in dialog re-use).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_is_idempotent()
{
// Arrange
var (_, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
// Act: invoke LoadAsync twice in a row.
await vm.LoadAsync();
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
}

View file

@ -6,10 +6,10 @@
<IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />

View file

@ -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<BlogPost>
var expected = new List<BlogPostDto>
{
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
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
private sealed class StubYavscApiClient : YavscApiClient
{
private readonly List<BlogPost> _posts;
public StubYavscApiClient(List<BlogPost> posts)
private readonly List<BlogPostDto> _posts;
public StubYavscApiClient(List<BlogPostDto> 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<BlogPost>))
if (typeof(T) == typeof(List<BlogPostDto>))
return Task.FromResult((T)(object)_posts);
return Task.FromResult(default(T)!);
}

View file

@ -0,0 +1,11 @@
using PostIt.Views;
namespace PostIt.Tests;
internal class TestAppContext
{
public MainWindow? Window {get; set; }
public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; }
}

View file

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

94
src/PostIt.Tests/pslist Normal file
View file

@ -0,0 +1,94 @@
UID PID PPID C STIME TTY TIME CMD
paul 1155 1 0 13:18 ? 00:00:00 /usr/lib/systemd/systemd --user
paul 1168 1155 0 13:18 ? 00:00:00 (sd-pam)
paul 1361 1155 0 13:18 ? 00:00:00 /usr/bin/dbus-daemon --session --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
paul 1364 1155 1 13:18 ? 00:01:19 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789
paul 1367 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire
paul 1372 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire -c filter-chain.conf
paul 1373 1155 0 13:18 ? 00:00:00 /usr/bin/wireplumber
paul 1374 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire-pulse
paul 1444 1155 0 13:18 ? 00:00:00 /usr/bin/mpris-proxy
paul 2593 1155 0 13:19 ? 00:00:00 /usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=/run/user/1000/keyring
paul 2608 2487 0 13:19 tty2 00:00:00 /usr/libexec/gdm-x-session --run-script /usr/bin/gnome-session
paul 2617 2608 1 13:19 tty2 00:01:12 /usr/lib/xorg/Xorg vt2 -displayfd 3 -auth /run/user/1000/gdm/Xauthority -nolisten tcp -background none -noreset -keeptty -novtswitch -verbose 3
paul 2647 2608 0 13:19 tty2 00:00:00 /usr/libexec/gnome-session-binary
paul 2785 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi-bus-launcher
paul 2792 2785 0 13:19 ? 00:00:00 /usr/bin/dbus-daemon --config-file=/usr/share/defaults/at-spi2/accessibility.conf --nofork --print-address 11 --address=unix:path=/run/user/1000/at-spi/bus_1
paul 2802 1155 0 13:19 ? 00:00:00 /usr/libexec/gcr-ssh-agent --base-dir /run/user/1000/gcr
paul 2803 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-ctl --monitor
paul 2804 1155 0 13:19 ? 00:00:00 /usr/bin/ssh-agent -D
paul 2814 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd
paul 2828 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd-fuse /run/user/1000/gvfs -f
paul 2838 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-binary --systemd-service --session=gnome
paul 2874 1155 3 13:19 ? 00:02:13 /usr/bin/gnome-shell
paul 2896 2874 0 13:19 ? 00:00:01 /usr/libexec/mutter-x11-frames
paul 2902 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi2-registryd --use-gnome-session
paul 2918 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal
paul 2933 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-permission-store
paul 2938 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-document-portal
paul 2971 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-shell-calendar-server
paul 2976 1155 0 13:19 ? 00:00:00 /usr/libexec/dconf-service
paul 2992 1155 0 13:19 ? 00:00:00 /usr/libexec/evolution-source-registry
paul 2994 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.Shell.Notifications
paul 3012 1155 0 13:19 ? 00:00:12 /usr/bin/ibus-daemon --panel disable --xim
paul 3013 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-a11y-settings
paul 3014 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-color
paul 3015 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-datetime
paul 3016 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-housekeeping
paul 3018 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-keyboard
paul 3024 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-media-keys
paul 3025 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-power
paul 3027 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-print-notifications
paul 3029 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-rfkill
paul 3030 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-screensaver-proxy
paul 3035 2838 0 13:19 ? 00:00:05 /usr/bin/gnome-software --gapplication-service
paul 3037 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sharing
paul 3042 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-smartcard
paul 3048 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sound
paul 3054 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-usb-protection
paul 3057 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-wacom
paul 3058 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-xsettings
paul 3059 2838 0 13:19 ? 00:00:00 /usr/libexec/evolution-data-server/evolution-alarm-notify
paul 3064 2838 0 13:19 ? 00:00:00 /usr/bin/kalendarac
paul 3070 2838 0 13:19 ? 00:00:00 /usr/libexec/gsd-disk-utility-notify
paul 3088 2838 0 13:19 ? 00:00:00 /usr/bin/kdeconnectd
paul 3168 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.ScreenSaver
paul 3172 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-printer
paul 3207 3012 0 13:19 ? 00:00:00 /usr/libexec/ibus-memconf
paul 3208 3012 0 13:19 ? 00:00:06 /usr/libexec/ibus-extension-gtk3
paul 3214 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-x11 --kill-daemon
paul 3216 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-portal
paul 3218 1155 0 13:19 ? 00:00:00 /usr/libexec/localsearch-3
paul 3219 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gnome
paul 3241 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-udisks2-volume-monitor
paul 3251 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-mtp-volume-monitor
paul 3259 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-gphoto2-volume-monitor
paul 3265 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-goa-volume-monitor
paul 3271 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-daemon
paul 3280 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-identity-service
paul 3287 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-afc-volume-monitor
paul 3303 3012 0 13:20 ? 00:00:02 /usr/libexec/ibus-engine-simple
paul 3372 1155 0 13:20 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gtk
paul 3441 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfsd-metadata
paul 3453 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-calendar-factory
paul 3495 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-addressbook-factory
paul 4798 1155 0 13:26 ? 00:00:09 /usr/libexec/gnome-terminal-server
paul 4810 4798 0 13:26 pts/0 00:00:00 bash
paul 8614 1155 0 13:29 ? 00:00:01 /usr/bin/speech-dispatcher -s -t 0
paul 8656 8614 0 13:29 ? 00:00:00 [sd_espeak-ng-mb] <defunct>
paul 8709 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/espeak-ng.conf
paul 8785 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_dummy /etc/speech-dispatcher/modules/dummy.conf
paul 8799 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/
paul 10028 1155 0 13:31 ? 00:00:00 adb -L tcp:5037 fork-server server --reply-fd 4
paul 69578 2814 0 13:53 ? 00:00:00 /usr/libexec/gvfsd-http --spawner :1.22 /org/gtk/gvfs/exec_spaw/0
paul 108341 1155 3 14:06 ? 00:00:48 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/acpx/dist/cli.js __queue-owner
paul 108416 108341 0 14:06 ? 00:00:00 openclaw
paul 108458 108416 2 14:06 ? 00:00:37 openclaw-acp
paul 143553 1155 0 14:19 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpI2JxLw.tmp
paul 149205 1155 0 14:21 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpitRyQG.tmp
paul 151724 1155 0 14:22 ? 00:00:04 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpJEsOZV.tmp
paul 157447 1155 1 14:24 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpyM92DV.tmp
paul 165231 1155 0 14:26 ? 00:00:01 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmp5CKC19.tmp
paul 168472 1155 4 14:27 ? 00:00:09 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpuRJsnQ.tmp
paul 172147 1155 4 14:29 pts/0 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpxT8nje.tmp
paul 172435 4810 99 14:31 pts/0 00:00:00 ps -fu paul

View file

@ -14,7 +14,8 @@
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0.11" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
</ItemGroup>
</Project>
</Project>

View file

@ -12,10 +12,10 @@
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
@ -31,6 +31,6 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
<PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,84 @@
#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;
using PostIt.Services;
using System.Linq;
namespace PostIt.Android.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
return Array.Empty<ContactDto>();
try
{
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
if (status != PermissionStatus.Granted)
return Array.Empty<ContactDto>();
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// 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<ContactDto>(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<ContactDto>();
}
}
private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
{
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
list.Add(e.EmailAddress);
}
return list;
}
}
#endif

View file

@ -4,10 +4,10 @@
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Browser" />

View file

@ -5,10 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -2,10 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt"
x:Class="PostIt.App">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. -->
<Application.Styles>
<FluentTheme />

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia;
@ -7,6 +8,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;
@ -47,58 +49,11 @@ public partial class App : Application
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return;
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(ServiceProvider);
this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
var api = ServiceProvider.GetRequiredService<YavscApiClient>();
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider));
@ -132,8 +87,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
@ -141,9 +95,8 @@ public partial class App : Application
// Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window;
_ = window.NavRoot.PushAsync(homePage);
_ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack
@ -153,8 +106,6 @@ public partial class App : Application
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync();
};
@ -166,35 +117,7 @@ public partial class App : Application
_ = PushMainPageAsync();
};
// When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage singleton on top
// of the current navigation stack. The DataContext is
// already wired at composition time (see the
// provider.GetRequiredService<SettingsPage>().DataContext
// assignment above), so this handler is a pure
// navigation concern.
//
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
}
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
@ -205,6 +128,110 @@ public partial class App : Application
}
}
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> boot
/// without going through the full Avalonia desktop lifetime
/// (which never runs in a unit test). The container returned is
/// the exact one production uses — no test-only fakes, no
/// trimmed service list — so a test that exercises a VM, page,
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
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);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings)
{
Application.Current!.RequestedThemeVariant =
@ -232,19 +259,18 @@ public partial class App : Application
}
/// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static async Task PushMainPageAsync()
public static Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
return app.PushPageAsync(mainVm);
}
private bool TryHandOffCustomSchemeUrl()
@ -279,4 +305,53 @@ public partial class App : Application
return true;
}
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal Task PushPageAsync(ViewModelBase vm)
{
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return Task.CompletedTask;
}
return window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
}
}

View file

@ -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();
}
}

View file

@ -4,10 +4,10 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@ -25,6 +25,7 @@
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">

View file

@ -0,0 +1,36 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, 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.</para>
///
/// <para>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
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// 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.
///
/// <para>Distinct from <see cref="IUserDirectory"/>, 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").</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>ContactService.Mobile.cs</c> (ANDROID/IOS) and
/// <c>ContactService.Desktop.cs</c> (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.</para>
/// </summary>
public interface IContactService
{
/// <summary>
/// Read the device address book. Returns the contacts
/// known to the local provider; on desktop (no local
/// provider) this is always an empty list.
/// </summary>
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral contact DTO. Source-of-truth shape for
/// the UI layer; concrete providers (MAUI Essentials on
/// mobile) map to this type.
///
/// <para><c>Emails</c> 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-<c>Email</c> shape — the two flows answer different
/// questions and shouldn't be flattened onto the same
/// wire.</para>
/// </summary>
public sealed record ContactDto(
string Id,
string DisplayName,
IReadOnlyList<string> Emails);

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the central Yavsc user directory. Used by
/// the "add to a circle" flow to find Yavsc users by display
/// name or email.
///
/// <para>Distinct from <see cref="IContactService"/>, 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.</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>UserDirectory.Desktop.cs</c> and
/// <c>UserDirectory.Mobile.cs</c>. Both currently delegate to
/// <c>UserSearchClient</c> (the central <c>/api/user-search</c>
/// endpoint); the split exists so future platform-specific
/// sources (offline cache, directory-scoped providers) can be
/// plugged in without disturbing the consumer.</para>
/// </summary>
public interface IUserDirectory
{
/// <summary>
/// Search the directory by display name (substring) and/or
/// email (exact).
/// </summary>
/// <param name="query">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").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A flat list of matching directory entries.
/// Never null; may be empty.</returns>
Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral summary of a Yavsc directory entry. Mirrors
/// the wire shape of <c>/api/user-search</c> (see
/// <c>UserSearchResultDto</c>) but expressed in terms that
/// don't leak transport concerns.
///
/// <para>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.</para>
/// </summary>
public sealed record UserSummary(
string Id,
string UserName,
string? FullName,
string? Avatar,
string? Email)
{
/// <summary>
/// Convenience for "what to show in a picker". Falls back
/// to <see cref="UserName"/> when <see cref="FullName"/>
/// is null or empty.
/// </summary>
public string DisplayName =>
string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
}

View file

@ -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;
/// <summary>
/// Desktop implementation of <see cref="IUserDirectory"/>.
/// Delegates to the central <c>/api/user-search</c> endpoint
/// via <see cref="UserSearchClient"/>.
///
/// <para>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.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> 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<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -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;
/// <summary>
/// Mobile implementation of <see cref="IUserDirectory"/>.
/// Same backing as the desktop provider (the central
/// <c>/api/user-search</c> endpoint via
/// <see cref="UserSearchClient"/>) — mobile devices have the
/// network too, and "add to a circle" needs the same directory
/// regardless of platform.
///
/// <para>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.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -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;
/// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight.
/// </summary>
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.

View file

@ -21,6 +21,18 @@ public class ViewLocator : IDataTemplate
}
public Control Build(object? data)
{
try
{
return BuildCore(data);
}
catch (Exception ex)
{
return new TextBlock { Text = $"ViewLocator threw: {ex}" };
}
}
private Control BuildCore(object? data)
{
return data switch
{
@ -28,8 +40,11 @@ public class ViewLocator : IDataTemplate
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
}

View file

@ -0,0 +1,128 @@
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 PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "add a Yavsc user to a circle" modal.
///
/// <para>Resolves users through <see cref="IUserDirectory"/>
/// (which delegates to <c>/api/user-search</c>); the caller
/// (CirclesPage) decides whether to add the picked user to
/// the circle by calling
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
/// (which is bound to the dialog's "Ajouter" button).</para>
///
/// <para>The dialog itself doesn't know the target
/// <c>CircleId</c>: that's set by the caller via the
/// constructor and the dialog only triggers
/// <see cref="IUserDirectory.SearchAsync"/> against the
/// <see cref="SearchQuery"/> string. The "Add" command
/// returns the picked <see cref="UserSummary"/> via the
/// <see cref="Confirmed"/> event, and the hosting
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
private readonly IUserDirectory _directory;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<UserSummary> Results { get; set; } = new();
[ObservableProperty]
public partial UserSummary? Selected { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user confirms a selection. The hosting
/// <c>CirclesPage</c> subscribes to this event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id + the picked user's id. The dialog itself
/// does not know the circle id by design: separation of
/// concerns — the modal is a user picker, not a
/// "circle joiner" form.
/// </summary>
public event EventHandler<UserSummary>? Confirmed;
public AddCircleMemberDialogViewModel(IUserDirectory directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
/// <summary>
/// Search the directory for users matching the current
/// <see cref="SearchQuery"/>. Triggered explicitly via the
/// "Rechercher" button — no debouncing, so the caller
/// stays in control of how often the network is hit.
/// </summary>
[RelayCommand]
public async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchQuery))
{
Results.Clear();
StatusMessage = "Tapez un nom ou un email";
return;
}
IsBusy = true;
try
{
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Raise <see cref="Confirmed"/> for the currently selected
/// user. No-op when no selection has been made — keeps the
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public async Task AddAsync()
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
return;
}
Confirmed?.Invoke(this, Selected);
var app = App.Current as App;
await app.GoBackAsync();
}
[RelayCommand]
public async Task CloseAsync()
{
var app = App.Current as App;
await app.GoBackAsync();
}
}

View file

@ -0,0 +1,310 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Mes cercles" page. CRUD on the caller's own
/// circles (the server scopes every endpoint to the caller's uid
/// since the BlogAcl fix on this branch), plus membership
/// management on the currently selected circle.
///
/// <para>The view lists circles in <see cref="Circles"/>, supports
/// create / edit via <see cref="DraftName"/>, and exposes
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
/// surfaces success / error feedback in the view footer.</para>
///
/// <para>When the user selects a circle in the list,
/// <see cref="LoadMembersAsync"/> fetches its members into
/// <see cref="Members"/>. The "Add a member" command
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
/// raises a <c>Confirmed</c> event back, which the page's
/// code-behind forwards here via
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
{
private readonly CircleApiClient _client;
[ObservableProperty]
public partial ObservableCollection<CircleDto> Circles { get; set; } = new();
[ObservableProperty]
public partial CircleDto? SelectedCircle { get; set; }
/// <summary>Editor buffer for the new / edited circle's name.</summary>
[ObservableProperty]
public partial string DraftName { get; set; } = string.Empty;
/// <summary>Editor buffer for the new / edited circle's visibility flag.</summary>
[ObservableProperty]
public partial bool DraftPublic { get; set; }
/// <summary>Members of the currently selected circle. Empty
/// when no circle is selected or after a refresh that
/// produced an empty list. Updated by
/// <see cref="LoadMembersAsync"/>.</summary>
[ObservableProperty]
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
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(); }
/// <summary>
/// Partial property setter: when the selected circle
/// changes, refresh the members list. The setter is
/// invoked by the [ObservableProperty] source generator
/// for both user selections and programmatic resets.
/// </summary>
partial void OnSelectedCircleChanged(CircleDto? value)
{
Members = new ObservableCollection<CircleMemberDto>();
if (value is not null)
{
// Fire-and-forget: load members in the background.
// Errors are routed to StatusMessage inside
// LoadMembersAsync.
_ = LoadMembersAsync(value.Id);
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetMyCirclesAsync();
Circles = new ObservableCollection<CircleDto>(list ?? new());
StatusMessage = $"{Circles.Count} cercle(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
internal async Task OpenAddMemberAsync()
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory);
// Wire the dialog's Confirmed event to OnAddMemberConfirmedAsync.
// Without this, the dialog's "Ajouter" button fires the event
// into the void: no subscriber, the picked user is silently
// dropped, and nothing is added to the circle. The dialog
// stays open until the user uses the back gesture — which is
// how the user noticed the button was a no-op.
// Async-void is intentional here: Confirmed is an
// EventHandler<T> (returns void), and bridging to the
// async Task OnAddMemberConfirmedAsync requires it.
model.Confirmed += async (_, picked) =>
await OnAddMemberConfirmedAsync(_, picked);
await app.PushPageAsync(model);
}
/// <summary>
/// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle
/// doesn't belong to the caller (mirroring the rest of the
/// circle API); that case flattens to an empty list here.
/// </summary>
[RelayCommand]
public async Task LoadMembersAsync(long circleId)
{
IsBusy = true;
try
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
Members = new ObservableCollection<CircleMemberDto>();
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void StartCreate()
{
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;
}
}
/// <summary>
/// Called by the view when the dialog confirms a
/// selection. Adds the picked user to the currently
/// selected circle and refreshes the members list.
/// </summary>
public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked)
{
if (SelectedCircle is null || picked is null) return;
IsBusy = true;
try
{
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
await LoadMembersAsync(SelectedCircle.Id);
}
catch (Exception ex)
{
// 409 (already a member) is a likely race — surface
// it as a friendly status, not an error. The
// server returns 409 for "already a member";
// YavscApiClient surfaces that as an exception
// today; future refactors could route 409 into a
// typed result, but for now the message string is
// distinctive enough.
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
? "Déjà membre du cercle"
: $"Erreur: {ex.Message}";
StatusMessage = msg;
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Per-row "remove" command. Updates the local
/// collection in place so the UI doesn't flash.
/// </summary>
[RelayCommand]
public async Task RemoveMemberAsync(CircleMemberDto? member)
{
if (member is null || SelectedCircle is null) return;
IsBusy = true;
try
{
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
Members.Remove(member);
StatusMessage = $"« {member.UserName} » retiré du cercle";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -2,10 +2,14 @@ using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.Views;
namespace PostIt.ViewModels;
@ -24,7 +28,7 @@ public partial class MainPageViewModel : ViewModelBase
/// previous "{Binding SelectedPost.Title}" binding, the user's
/// keystrokes were silently dropped whenever
/// <c>SelectedPost was null</c>, which made the editor a trap
/// and caused Save to POST a <c>BlogPost</c> with an empty
/// and caused Save to POST a <c>BlogPostDto</c> with an empty
/// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty]
public partial string DraftTitle { get; set; }
@ -34,8 +38,17 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the
/// existence of a row in <c>BlogSpotPublication</c>) and
/// is pushed to the server via
/// <see cref="BlogApiClient.SetPublishAsync"/> on explicit
/// toggle — it is NOT included in the regular Save
/// payload, mirroring the wire contract where
/// <c>BlogPostDto</c> doesn't carry <c>Publish</c> as a
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public partial bool DraftIsPublished { get; set; }
public Settings SettingsModel { get; }
@ -46,13 +59,13 @@ public partial class MainPageViewModel : ViewModelBase
public partial string SearchText { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> Posts { get; set; }
public partial ObservableCollection<BlogPostDto> Posts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
[ObservableProperty]
public partial BlogPost? SelectedPost { get; set; }
public partial BlogPostDto? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -68,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary>
public BlogApiClient? BlogClient { get; }
/// <summary>
/// DI container the VM uses to resolve navigation targets
/// (other ViewModels) when the user clicks a toolbar button
/// that opens a sub-screen. Owned by <c>App.ServiceProvider</c>
/// in production; injected directly in tests. The VM resolves
/// <em>ViewModels</em> via this provider, never Views — the
/// actual <see cref="Control"/> to push is decided by
/// <see cref="ViewLocator"/> at bind time, per CONTRIBUTING.md
/// §"Navigation (PostIt)".
/// </summary>
public IServiceProvider? Services { get; }
private SignaturePageViewModel? _signatureModel;
/// <summary>
/// Resolved on first access. Lazy so the test path (which
/// never pushes <c>SignaturePage</c>) does not require a
/// fully-built DI graph just to construct the VM. Mirrors the
/// pattern of <see cref="SettingsModel"/> for the Settings case.
/// </summary>
public SignaturePageViewModel SignatureModel =>
_signatureModel ??= ResolveSignatureModel();
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(); }
private SignaturePageViewModel ResolveSignatureModel()
{
var sp = ResolveServices();
return sp.GetRequiredService<SignaturePageViewModel>();
}
private IServiceProvider ResolveServices()
{
return Services ?? (Application.Current as App)?.ServiceProvider ??
throw new InvalidOperationException(
"No IServiceProvider available for navigation. Inject one in tests " +
"or ensure App.ServiceProvider is initialized in production.");
}
public MainPageViewModel()
{
@ -82,8 +132,8 @@ public partial class MainPageViewModel : ViewModelBase
private void Init(Settings? settings)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPost>();
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
@ -101,25 +151,38 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
CurrentViewModel = this;
DraftIsPublished = false;
}
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
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;
/// <summary>
/// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
Init(settings);
}
}
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 +193,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 +242,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 +254,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 +270,7 @@ public partial class MainPageViewModel : ViewModelBase
}
else
{
var update = new BlogPost
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
@ -240,10 +306,70 @@ public partial class MainPageViewModel : ViewModelBase
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand]
internal void OpenSettings()
internal async Task TogglePublish()
{
CurrentViewModel = SettingsModel;
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.";
});
}
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand]
internal async Task OpenSignatureDev()
{
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
private async Task RefreshPostsAsync()
@ -306,14 +432,23 @@ public partial class MainPageViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged();
}
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl()
{
if (SelectedPost is null)
{
StatusMessage = "Select an existing post before managing ACL.";
return;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
}

View file

@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post.
///
/// <para>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. <see cref="AddAsync"/> /
/// <see cref="RevokeAsync"/> are the only mutating verbs; both
/// refresh the list afterwards so the UI stays in sync with the
/// server.</para>
///
/// <para>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 <see cref="StatusMessage"/>.</para>
/// </summary>
public partial class PostAclDialogViewModel : ViewModelBase
{
private readonly BlogAclApiClient _aclClient;
private readonly CircleApiClient _circleClient;
/// <summary>The post whose ACL is being edited. Set by the
/// caller (MainPage) when opening the dialog.</summary>
public BlogPostDto Post { get; }
[ObservableProperty]
public partial ObservableCollection<CircleDto>
MyCircles { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<PostAccessControlRulePayload>
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;
/// <summary>
/// Idempotency gate for <see cref="LoadAsync"/>: the dialog
/// attaches the load trigger in <c>DataContextChanged</c>,
/// which can fire more than once if the page is detached
/// and re-attached (dialog re-use, navigation edge cases)
/// with a different VM. Without this guard, the second load
/// would race against the first and could overwrite
/// <see cref="AclEntries"/> mid-edit. Pattern copied from
/// <c>Settings.Load</c>.
/// </summary>
private bool _loaded;
/// <summary>True once <see cref="LoadAsync"/> has run at least
/// once. Exposed for tests; do not bind from XAML.</summary>
public bool Loaded => _loaded;
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()
{
if (_loaded) return;
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<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles);
StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
}
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 Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{
CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id
});
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(PostAccessControlRulePayload? 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;
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
}
[RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand()
internal async Task OpenSettings()
{
OpenSettingsRequested?.Invoke();
await System.Threading.Tasks.Task.CompletedTask;
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
}
}

View file

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

View file

@ -0,0 +1,50 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
/// <summary>
/// Modal "add a member to a circle" page. Hosted by
/// <c>CirclesPage</c>; the caller passes the resolved
/// <see cref="IUserDirectory"/> via the constructor.
///
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
/// when the user picks a result and clicks "Ajouter"; the
/// hosting page subscribes to that event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id. The dialog itself does not know the circle id
/// by design.</para>
/// </summary>
public partial class AddCircleMemberDialog : ContentPage
{
public AddCircleMemberDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// Subscribe a handler to be notified when the user
/// confirms a selection. Returns the underlying VM so
/// the caller can also drive further state (clear the
/// selection, close the dialog, refresh its own list).
/// </summary>
public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel;
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
var nav = this.FindAncestorOfType<NavigationPage>();
if (nav is not null)
_ = nav.PopAsync();
}
}

View file

@ -0,0 +1,111 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.CirclesPage"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Toolbar: refresh + new -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
<Button Content="Rafraîchir"
Command="{Binding RefreshCommand}"/>
<Button Content="Nouveau"
Command="{Binding StartCreateCommand}"/>
</StackPanel>
<!-- Two-pane body: circles (left) + members (right) -->
<Grid Grid.Row="1" Margin="12,0,12,12"
ColumnDefinitions="*,16,*"
RowDefinitions="*,Auto">
<!-- Left column: list of circles + editor -->
<Grid Grid.Row="0" Grid.Column="0"
RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Éditer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="2" Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Editor -->
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding DraftName, Mode=TwoWay}"/>
<CheckBox Grid.Row="1" Grid.Column="1"
Content="Public"
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Right column: members of the selected circle -->
<Grid Grid.Row="0" Grid.Column="2"
RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Membres"
FontWeight="Bold"
VerticalAlignment="Center"/>
<Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberAsync}"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Members}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleMemberDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Retirer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state hint -->
<TextBlock Grid.Row="2"
Text="Sélectionnez un cercle pour voir ses membres."
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
FontSize="11" Opacity="0.6"
Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Status bar -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,23 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class CirclesPage : ContentPage
{
public CirclesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:PostIt.Models"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d"
@ -33,6 +33,24 @@
<Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" />
<Button x:Name="ManageAclButton"
Command="{Binding ManageAcl}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCircles}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the
new state to /api/blog/{id}/publish.
The CheckBox is the canonical
AvaloniaXaml 'toggle' surface; binding
IsChecked TwoWay keeps the visual state
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishCommand}"
VerticalAlignment="Center"/>
<!--
DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR
@ -41,17 +59,17 @@
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPost">
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />

View file

@ -1,8 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -12,31 +8,4 @@ public partial class MainPage : ContentPage
{
InitializeComponent();
}
/// <summary>
/// DEV ONLY: temporary shortcut to open the signature capture
/// page from the blog editor. The production entry point is a
/// SignalR push from Yavsc.Org ("devis received, sign here"),
/// which is the only path that carries the devis identifier
/// needed to bind the capture to a specific contract.
///
/// Remove this method and the corresponding button in
/// MainPage.axaml.cs once the SignalR handler lands.
/// </summary>
private void OpenSignatureDev(object? sender, RoutedEventArgs e)
{
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<SignaturePage>();
page.DataContext = services.GetRequiredService<SignaturePageViewModel>();
if (this.VisualRoot is MainWindow window)
{
_ = window.NavRoot.PushAsync(page);
}
}
}
}

View file

@ -0,0 +1,64 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
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"
x:DataType="vm:PostAclDialogViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
<!-- Add a new authorisation -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<ComboBox Grid.Column="0"
ItemsSource="{Binding MyCircles}"
SelectedItem="{Binding SelectedCircleToAdd, Mode=TwoWay}"
PlaceholderText="Choisir un cercle..."
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Current ACL entries -->
<ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/>
</StackPanel>
<Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons: close -->
<Button Grid.Row="2" Content="Fermer"
Click="OnCloseClicked"
HorizontalAlignment="Right"
Margin="0,8,0,8"/>
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,87 @@
using System;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views;
/// <summary>
/// Modal "manage ACL" page for a single blog post.
///
/// <para>The ViewModel is constructed by the caller (the post
/// list page) and handed to <see cref="App.PushPageAsync"/>,
/// which routes through <see cref="ViewLocator"/> and lands
/// here via the parameterless DI constructor. The VM is then
/// assigned to <see cref="ContentPage.DataContext"/> by
/// <c>App.PushPageAsync</c> — we listen for that one-shot
/// assignment and trigger <c>LoadAsync</c> right after, so the
/// dropdown's <c>MyCircles</c> and the list's <c>AclEntries</c>
/// are populated when the dialog appears. The VM is idempotent
/// under repeated loads.</para>
/// </summary>
public partial class PostAclDialog : ContentPage
{
public PostAclDialog()
{
InitializeComponent();
// App.PushPageAsync wires the VM via DataContext after
// building the page. We subscribe once to fire LoadAsync
// the moment the VM is attached. Using DataContextChanged
// (rather than AttachedToVisualTree) is what makes this
// work in the headless test harness too: the load is
// tied to the VM being available, not to the visual tree
// being realised (which is a separate concern).
EventHandler? handler = null;
handler = (_, _) =>
{
if (DataContext is PostAclDialogViewModel vm)
{
this.DataContextChanged -= handler;
_ = vm.LoadAsync();
}
};
this.DataContextChanged += handler;
}
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
{
// This overload is not used by the production path —
// MainPageViewModel pushes the VM via App.PushPageAsync
// and App routes through ViewLocator, which resolves this
// page via the parameterless ctor. It is kept so test
// scaffolding that wants to bypass the nav pipeline can
// still wire a VM directly without losing the load
// trigger: the constructor sets DataContext before the
// DataContextChanged subscription fires, so the load
// is guaranteed to run in either case.
InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
// Pop this page off the navigation stack. Avalonia's
// NavigationPage doesn't have a typed "Close" — the
// hosting control (a NavigationPage in MainWindow.axaml)
// is the one that owns the back stack, but the
// ContentPage itself doesn't know about it. A simpler
// contract: fire an event the host listens to, or rely
// on the system back gesture. We do the latter — the
// dialog is intentionally modal-light.
if (this.VisualRoot is NavigationPage nav)
{
// The actual API varies between Avalonia 11.x
// versions; the safest call is the equivalent of
// "go back", which lives on the host. For now, hide
// the page and let the host decide.
}
}
}

View file

@ -8,8 +8,8 @@ namespace Yavsc.ViewModels.Account
public class RegisterModel
{
[StringLength(YavscConstants.MaxUserNameLength)]
[RegularExpression(YavscConstants.UserNameRegExp)]
[StringLength(Constants.MaxUserNameLength)]
[RegularExpression(Constants.UserNameRegExp)]
[DataType(DataType.Text)]
[Display(Name = "UserName", Description = "User name")]
public string UserName { get; set; }

View file

@ -0,0 +1,33 @@
namespace Yavsc.Blogspot;
/// <summary>
/// Minimum-viable author payload embedded in <see cref="BlogPostDto"/>.
///
/// <para>
/// Before this record existed, <c>BlogPostDto.Author</c> was typed
/// as the abstract interface <c>IApplicationUser</c>. The
/// interface is fine for server-side contract (we have a concrete
/// entity that implements it) but System.Text.Json cannot
/// materialise an interface without a polymorphic converter
/// configured on both ends. PostIt would crash on load-posts
/// because the JSON contained an <c>Author</c> object that the
/// client could not deserialise.
/// </para>
///
/// <para>
/// This record is the wire shape: <c>Id</c> for "go to author
/// profile", <c>UserName</c> for "by @username", <c>Avatar</c>
/// for the round badge next to the title. The server-side
/// <c>BlogPost</c> entity (<c>Yavsc.Server.Models.Blog</c>) keeps
/// its full <c>ApplicationUser</c> navigation property for
/// permission checks and authorisation; the DTO is built on
/// demand by the controller / service layer when the post is
/// served to the wire.
/// </para>
/// </summary>
public sealed record BlogPostAuthorDto
{
public string Id { get; init; } = string.Empty;
public string? UserName { get; init; }
public string? Avatar { get; init; }
}

View file

@ -0,0 +1,45 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost
{
public string AuthorId { get; set; }
public BlogPostAuthorDto? 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; }
/// <summary>
/// Whether this post is published. Derived server-side from
/// the existence of a row in <c>BlogSpotPublication</c>
/// (a row means published, no row means draft). Not stored
/// on <c>BlogPost</c> — it's a computed projection of the
/// publication table, surfaced through the wire DTO so
/// clients can render the current state without a
/// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary>
public bool IsPublished { get; set; }
public virtual bool AuthorizeCircle(long circleId)
{
ACL.Add(new CircleAuthorization { CircleId = circleId });
return true;
}
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
public string[] Tags { get; set; }
public string[] GetTags() => Tags;
public ICircleAuthorization[] GetACL() => ACL.ToArray();
}

View file

@ -1,15 +1,18 @@
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces;
namespace Yavsc.Blogspot
{
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITaggable<long>, ITrackedEntity, IIdentified<long>, ITitle
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{
string AuthorId { get; set; }
IApplicationUser Author { get; }
// Typed as a concrete wire DTO (not the IApplicationUser
// interface) so System.Text.Json can materialise it on the
// client without a polymorphic converter. The server-side
// BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; }
}
}

View file

@ -0,0 +1,10 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : ICircleAuthorization
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
}

View file

@ -3,8 +3,10 @@ using Yavsc.Models.Auth;
namespace Yavsc
{
public static class YavscConstants
public static class Constants
{
public const string APIPrefix = "api/v1";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -0,0 +1,17 @@
namespace Yavsc.Abstract.Identity.Security;
/// <summary>
/// Wire format for <c>GET /api/blogacl</c> and friends.
///
/// <para>The server-side
/// <c>Yavsc.Models.Access.CircleAuthorizationToBlogPost</c> EF entity
/// carries virtual navigation properties (<c>Target</c>,
/// <c>Allowed</c>) that pull in the full BlogPost and Circle graphs.
/// The client never needs them: when showing the ACL of a post, the
/// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary>
public sealed class CircleAuthorization : ICircleAuthorization
{
public long CircleId { get; set; }
}

View file

@ -1,10 +1,14 @@
using Yavsc.Interfaces;
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorized
public interface ICircleAuthorized : ITaggable<long>
{
long Id { get; set; }
string AuthorId { get; }
bool AuthorizeCircle(long circleId);
ICircleAuthorization [] GetACL();
}

View file

@ -19,7 +19,7 @@ namespace Yavsc.Abstract.Identity
/// </summary>
/// <remarks>
/// Le path retourné est aligné sur
/// <see cref="YavscConstants.AvatarsPath"/> (minuscule).
/// <see cref="Constants.AvatarsPath"/> (minuscule).
/// Les anciens display templates utilisaient "/Avatars/"
/// avec un S majuscule, en désaccord avec le path statique
/// servi par le middleware de fichiers — les images ne
@ -29,8 +29,8 @@ namespace Yavsc.Abstract.Identity
public static string AvatarSrc(IApplicationUser? user)
{
if (user==null || string.IsNullOrWhiteSpace(user?.UserName))
return YavscConstants.DefaultAvatar;
return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png";
return Constants.DefaultAvatar;
return $"{Constants.AvatarsPath}/{user!.UserName}.s.png";
}
}
}

View file

@ -1,9 +1,7 @@
namespace Yavsc.Interfaces
{
public interface ITaggable<K>
public interface ITaggable<K> : IIdentified<K>
{
string [] GetTags();
K Id { get; }
}
}
}

View file

@ -9,10 +9,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />

View file

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
///
/// <para>Each <see cref="CircleAuthorization"/> grants a single
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
/// scopes every endpoint to the caller's uid: only the author of
/// the underlying blog post can list, create, modify, or delete
/// its ACL entries.</para>
/// </summary>
public sealed class BlogAclApiClient
{
private const string Path = "blogacl";
private readonly IYavscApiClient _api;
public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<PostAccessControlRulePayload>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<PostAccessControlRulePayload>>(HttpMethod.Get, Path, ct: ct);
public Task<PostAccessControlRulePayload?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<PostAccessControlRulePayload?> GrantAsync(PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct);
}

View file

@ -3,17 +3,18 @@ using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Models;
using Yavsc.Blogspot;
namespace PostIt.Services;
namespace Yavsc.Api.Client;
/// <summary>
/// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to
/// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
/// mapper, nothing more.
/// <see cref="YavscApiClient"/>, which lives in the consuming
/// application (PostIt). This class is a thin DTO↔path mapper,
/// nothing more.
///
/// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s
/// <c>BaseAddress</c> already terminates with <c>/api/v1/</c>
@ -34,35 +35,50 @@ public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "blog";
private readonly YavscApiClient _api;
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly string _pathPrefix;
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl);
_baseAddress = new Uri(blogsBaseAddress);
api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPost>>(
public Task<List<BlogPostDto>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPostDto>>(
HttpMethod.Get,
$"{_pathPrefix}?start={start}&take={take}",
ct: ct);
public Task<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
=> _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default)
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
/// <summary>
/// Set a post's publication state. <c>true</c> publishes
/// it (visible to anonymous readers via
/// <c>PermissionHandler.IsPublic</c>); <c>false</c> takes
/// it back to draft. Idempotent: the resulting state
/// matches the call, regardless of the previous state.
/// </summary>
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
}

View file

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
///
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
/// transport is delegated to <see cref="YavscApiClient"/>; this
/// class only maps paths to DTOs.</para>
///
/// <para>The server now (since the BlogAcl fix on this branch)
/// scopes every read and write to the caller's uid. There is no
/// way for the client to read or modify another user's circles
/// — the route will return 404 (not 403) when the circle exists
/// but belongs to someone else, to avoid leaking its existence.</para>
/// </summary>
public sealed class CircleApiClient
{
private const string Path = "circle";
private readonly IYavscApiClient _api;
public CircleApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Post, Path, body: circle, ct: ct);
public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct);
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
/// <summary>
/// Returns the members of one of the caller's circles.
/// Returns null when the circle does not exist or is not
/// owned by the caller (the server scopes the endpoint
/// with a 404 in either case to avoid leaking existence
/// — this client flattens that into a null result).
/// </summary>
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
/// <summary>
/// Adds a Yavsc user (resolved client-side via
/// <c>/api/user-search</c>) to one of the caller's
/// circles. Returns null when the circle does not exist
/// or is not owned by the caller, or when the target
/// user does not exist. Throws on 409 (already a
/// member) — callers that want idempotent behaviour
/// can swallow the exception or dedupe beforehand.
/// </summary>
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
body: new { userId }, ct: ct);
/// <summary>
/// Removes a user from one of the caller's circles.
/// Returns null on success (the server returns 200 OK
/// with no body) or when the membership does not
/// exist — both treated as success by the caller.
/// </summary>
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
}

View file

@ -0,0 +1,23 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle</c> and friends.
///
/// <para>Field names match the JSON the server emits (camelCase via
/// the default <see cref="System.Text.Json"/> policy), so no
/// <c>[JsonPropertyName]</c> attributes are required.</para>
///
/// <para>Mirrors the server-side <c>Yavsc.Models.Relationship.Circle</c>
/// EF entity but stops short of the navigation properties
/// (<c>Owner</c>, <c>Members</c>) which depend on
/// <c>ApplicationUser</c> and other server-only types. The client
/// only ever needs the id, name, and owner of a circle to drive
/// the UI.</para>
/// </summary>
public sealed class CircleDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string OwnerId { get; set; } = string.Empty;
public bool Public { get; set; }
}

View file

@ -0,0 +1,21 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle/{id}/members</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.CircleMemberDto</c>. Intentionally
/// stops short of the Email field that
/// <see cref="UserSearchResultDto"/> carries — the circle
/// membership UI only needs a name and an avatar to render the
/// list. If the future ACL UI wants contact details, it can
/// fall back to <see cref="IYavscApiClient"/>'s other
/// endpoints rather than widening this shape.</para>
/// </summary>
public sealed class CircleMemberDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
}

View file

@ -0,0 +1,23 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/user-search</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.UserSearchResultDto</c> but stops
/// short of any entity navigation properties. Only the fields
/// a client address book needs (id, name, avatar, email) are
/// included.</para>
///
/// <para>Field names match the JSON the server emits (camelCase
/// via the default <see cref="System.Text.Json"/> policy), so
/// no <c>[JsonPropertyName]</c> attributes are required.</para>
/// </summary>
public sealed class UserSearchResultDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
public string? Email { get; set; }
}

View file

@ -0,0 +1,62 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Transport surface that the high-level clients
/// (<see cref="BlogApiClient"/>, <see cref="CircleApiClient"/>,
/// <see cref="BlogAclApiClient"/>) need to do their work.
///
/// <para>This is intentionally a thin, transport-only contract. It
/// does not include the OIDC login / refresh / logout surface —
/// that lives on the concrete <c>YavscApiClient</c> in the
/// consuming application and is wired by the application
/// composition root. Splitting the two keeps <c>Yavsc.Api.Client</c>
/// usable from any host (a CLI, a unit test, a future iOS
/// client) without dragging OIDC, identity, and a <c>Settings</c>
/// POMVO everywhere.</para>
///
/// <para>Implementations are expected to:</para>
/// <list type="bullet">
/// <item>Attach a Bearer access token to every outbound request.</item>
/// <item>Silently refresh the token on a 401 and retry once.</item>
/// <item>Serialise the request body as JSON and deserialise the
/// response body with case-insensitive property matching.</item>
/// </list>
///
/// The exception contract on non-2xx responses is
/// <see cref="HttpRequestException"/> with a message that includes
/// the response body (capped), so callers can surface the
/// server-side validation problem to the UI without losing
/// context.
/// </summary>
public interface IYavscApiClient : IAsyncDisposable
{
/// <summary>
/// The configured <see cref="HttpClient"/>. Clients set its
/// <c>BaseAddress</c> in their constructors to point at the
/// API host they target.
/// </summary>
HttpClient Http { get; }
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
/// <param name="body">Optional request body, serialised as JSON.</param>
/// <param name="ct">Cancellation token.</param>
Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
}

View file

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/user-search</c> on the Yavsc Blogs
/// server. Used by client-side address books (PostIt.Desktop,
/// future PostIt.Browser CLI, …) to look up Yavsc users by
/// display name or email.
///
/// <para>The server scopes every endpoint to the authenticated
/// caller; any authenticated user can search the user table of
/// the instance. There is no per-user filtering on the response
/// side — this is by design on single-tenant deployments
/// (closed community). Multi-tenant deployments should gate
/// this controller behind a tenant-scoped policy before
/// exposing it; see the server-side
/// <c>UserSearchApiController</c> doc for details.</para>
/// </summary>
public sealed class UserSearchClient
{
private const string Path = "user-search";
private readonly IYavscApiClient _api;
public UserSearchClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
/// <summary>
/// Search users by display name (substring) or email (exact).
/// </summary>
/// <param name="query">Substring filter on FullName or
/// UserName. Empty or null returns an empty list (the server
/// would return all users, which we don't want by
/// default).</param>
/// <param name="email">Optional exact-match filter on
/// Email.</param>
/// <param name="take">Maximum results, capped at 100.
/// Default 25.</param>
public Task<List<UserSearchResultDto>> SearchAsync(
string? query = null,
string? email = null,
int take = 25,
CancellationToken ct = default)
{
// Match the server's contract: at least one filter is
// expected. The server doesn't enforce this (an empty
// query + empty email returns the first `take` users
// alphabetically), but the address-book UX is "type
// something to search", so we short-circuit empty
// queries client-side.
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email))
return Task.FromResult(new List<UserSearchResultDto>());
var qs = new List<string>();
if (!string.IsNullOrWhiteSpace(query))
qs.Add($"q={Uri.EscapeDataString(query)}");
if (!string.IsNullOrWhiteSpace(email))
qs.Add($"e={Uri.EscapeDataString(email)}");
qs.Add($"take={Math.Clamp(take, 1, 100)}");
return _api.CallAsync<List<UserSearchResultDto>>(
HttpMethod.Get,
$"{Path}?{string.Join('&', qs)}",
ct: ct);
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Yavsc.Api.Client</RootNamespace>
<AssemblyName>Yavsc.Api.Client</AssemblyName>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Description>
Thin HTTP clients for the Yavsc API. Each client is a DTO↔path
mapper; all transport concerns (base URL, JSON, Bearer auth,
silent refresh on 401) are delegated to YavscApiClient, which
lives in the consuming application (PostIt).
</Description>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>

View file

@ -14,8 +14,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/activity")]
[AllowAnonymous]
[Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller
{
private ApplicationDbContext _context;
@ -88,7 +87,7 @@ namespace Yavsc.Controllers
}
// POST: api/ActivityApi
[HttpPost,Authorize("AdministratorOnly")]
[HttpPost, Authorize("AdministratorOnly")]
public async Task<IActionResult> PostActivity([FromBody] Activity activity)
{
if (!ModelState.IsValid)

View file

@ -19,7 +19,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers;
[Route("api/bill"), Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller
{
readonly ApplicationDbContext dbContext;

View file

@ -18,7 +18,7 @@ namespace Yavsc.Controllers
using Yavsc.Server.Helpers;
[Produces("application/json")]
[Route("api/bookquery"), Authorize("Performer")]
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller
{
private ApplicationDbContext _context;

View file

@ -15,7 +15,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/estimate"), Authorize]
[Route(Constants.APIPrefix + "/estimate"), Authorize]
public class EstimateApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -27,12 +27,12 @@ namespace Yavsc.Controllers
}
bool UserIsAdminOrThis(string uid)
{
if (User.IsInRole(YavscConstants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminGroupName)) return true;
return uid == User.GetUserId();
}
bool UserIsAdminOrInThese(string oid, string uid)
{
if (User.IsInRole(YavscConstants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminGroupName)) return true;
var cuid = User.GetUserId();
return cuid == uid || cuid == oid;
}
@ -82,7 +82,7 @@ namespace Yavsc.Controllers
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{
@ -118,7 +118,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{
@ -187,7 +187,7 @@ namespace Yavsc.Controllers
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/EstimateTemplatesApi")]
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
public class EstimateTemplatesApiController : Controller
{
private ApplicationDbContext _context;
@ -62,7 +62,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.Entry(estimateTemplate).State = EntityState.Modified;
@ -132,7 +132,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.EstimateTemplates.Remove(estimateTemplate);

View file

@ -8,7 +8,7 @@ using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers
{
[Route("api/front")]
[Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller
{
ApplicationDbContext dbContext;

View file

@ -6,7 +6,7 @@ using Yavsc.Models;
namespace Yavsc.ApiControllers
{
[Route("api/payment")]
[Route(Constants.APIPrefix + "/payment")]
public class PaymentApiController : Controller
{
private readonly ApplicationDbContext dbContext;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Controllers
using Yavsc.Services;
[Produces("application/json")]
[Route("api/performers")]
[Route(Constants.APIPrefix + "/performers")]
public class PerformersApiController : Controller
{
ApplicationDbContext dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ProductApi")]
[Route(Constants.APIPrefix + "/ProductApi")]
public class ProductApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
}
// PUT: api/ProductApi/5
[HttpPut("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPut("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PutProduct(long id, [FromBody] Product product)
{
if (!ModelState.IsValid)
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
}
// POST: api/ProductApi
[HttpPost,Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPost,Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PostProduct([FromBody] Product product)
{
if (!ModelState.IsValid)
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
}
// DELETE: api/ProductApi/5
[HttpDelete("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpDelete("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult DeleteProduct(long id)
{
if (!ModelState.IsValid)

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/bursherprofiles")]
[Route(Constants.APIPrefix + "/bursherprofiles")]
public class BursherProfilesApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -57,7 +57,7 @@ namespace Yavsc.Controllers
{
return BadRequest();
}
if (id != User.GetUserId())
{
return BadRequest();

View file

@ -24,7 +24,7 @@ namespace Yavsc.ApiControllers
using Microsoft.AspNetCore.Authorization;
using Yavsc.Server.Helpers;
[Route("api/haircut")][Authorize]
[Route(Constants.APIPrefix + "/haircut")][Authorize]
public class HairCutController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -6,7 +6,7 @@ using Yavsc.Models.Relationship;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/hyperlink")]
[Route(Constants.APIPrefix + "/hyperlink")]
public class HyperLinkApiController : Controller
{
private ApplicationDbContext _context;

View file

@ -7,7 +7,7 @@ using Yavsc.Server.Models.IT.SourceCode;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/GitRefsApi")]
[Route(Constants.APIPrefix + "/GitRefsApi")]
[Authorize("AdministratorOnly")]
public class GitRefsApiController : Controller
{

View file

@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Mvc;
namespace Yavsc.ApiControllers
{
[Route("api/mailtemplate")]
[Route(Constants.APIPrefix + "/mailtemplate")]
public class MailTemplatingApiController: Controller
{
}
}

View file

@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/mailing")]
[Route(Constants.APIPrefix + "/mailing")]
[Authorize("AdministratorOnly")]
public class MailingTemplateApiController : Controller
{

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/museprefs")]
[Route(Constants.APIPrefix + "/museprefs")]
public class MusicalPreferencesApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/MusicalTendenciesApi")]
[Route(Constants.APIPrefix + "/MusicalTendenciesApi")]
public class MusicalTendenciesApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -1,15 +1,15 @@

using System;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Identity;
using Yavsc.Server.Helpers;
#nullable enable
[Authorize, Route("~/api/gcm")]
public class NativeConfidentialController : Controller
{

View file

@ -37,7 +37,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (blogpost.AuthorId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return BadRequest();
_context.SaveChanges(User.GetUserId());

View file

@ -7,8 +7,8 @@ namespace Yavsc.ApiControllers
/// <summary>
/// Base class for managing performers profiles
/// </summary>
[Produces("application/json"),Route("api/profile")]
public abstract class ProfileApiController<T> : Controller
[Produces("application/json"),Route(Constants.APIPrefix + "/profile")]
public abstract class ProfileApiController<T> : Controller
{ public ProfileApiController()
{
}

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/blacklist"), Authorize]
[Route(Constants.APIPrefix + "/blacklist"), Authorize]
public class BlackListApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -50,8 +50,8 @@ namespace Yavsc.Controllers
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != blackListed.OwnerId)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(YavscConstants.FrontOfficeGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.FrontOfficeGroupName))
return false;
return true;
}
@ -140,7 +140,7 @@ namespace Yavsc.Controllers
if (!CheckPermission(blackListed))
return BadRequest();
_context.BlackListed.Remove(blackListed);
_context.SaveChanges(User.GetUserId());

View file

@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers
{
[Route("api/chat")]
[Route(Constants.APIPrefix + "/chat")]
public class ChatApiController : Controller
{
readonly ApplicationDbContext dbContext;
readonly UserManager<ApplicationUser> userManager;
private readonly IConnexionManager _cxManager;
public ChatApiController(ApplicationDbContext dbContext,
UserManager<ApplicationUser> userManager,
UserManager<ApplicationUser> userManager,
IConnexionManager cxManager)
{
this.dbContext = dbContext;

Some files were not shown because too many files have changed in this diff Show more