Compare commits

...

268 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
ff7a6ac16d Merge pull request 'publish android' (#13) from build into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #13
2026-08-03 02:36:11 +01:00
c8b05a8950
publish android
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
2026-08-03 02:35:50 +01:00
42ad1b623d Merge pull request 'build' (#12) from build into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #12
2026-08-03 02:17:32 +01:00
7d1cca9df0
build
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
2026-08-03 02:16:57 +01:00
3744d9ae9c
Enable blogs on connected status
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-03 01:48:15 +01:00
b25e0e842e
refacto blogPost
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-03 01:36:12 +01:00
1d716380dd Merge pull request 'ci: publish APK to GitHub release on v* tag' (#11) from release into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #11
2026-08-02 23:29:21 +01:00
23262bcc17 ci: publish APK to GitHub release on v* tag
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
Adds a publish-release job that triggers only on tag pushes (refs/tags/v*).
It reuses the APK artifact uploaded by apk-deploy, publishes a GitHub
release via softprops/action-gh-release, and attaches the APK.

Result: a stable permalink to the latest APK at
  https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
2026-08-02 23:23:00 +01:00
ead74b7cff Merge pull request 'Le créateur vient de l'authentification, donc on ne le prend pas du post' (#10) from fix/OIDC-debugging into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #10
2026-08-02 23:04:09 +01:00
3b21a12c20 Le créateur vient de l'authentification, donc on ne le prend pas du post
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
2026-08-02 23:02:54 +01:00
573c12fe30 Merge pull request 'postit: allow self-signed OIDC TLS in Development' (#9) from fix/OIDC-debugging into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #9
2026-08-02 21:32:49 +01:00
286c29f4e3 Navigate to Main Page
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
2026-08-02 21:08:25 +01:00
786016344b refacto error handling
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
2026-07-12 17:56:23 +01:00
fda43ba2d1 Hsts 2026-07-12 16:27:13 +01:00
9622dbeaf2 Hsts 2026-07-12 16:26:43 +01:00
3d80a3f2a1 Post logout redirect uri 2026-07-12 16:14:37 +01:00
13964b9f7f org: show full error details in development
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
2026-07-12 16:07:28 +01:00
eba44b46e2 postit: allow self-signed OIDC TLS in Development
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
2026-07-12 15:51:55 +01:00
c778f7e46b Merge pull request 'fix/auth-redirect-message' (#8) from fix/auth-redirect-message into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #8
2026-07-12 15:22:20 +01:00
b827f531aa Localisation
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
2026-07-12 15:18:07 +01:00
5eb8019beb Audiences 2026-07-12 15:17:53 +01:00
0f73728b85 code format 2026-07-12 15:17:41 +01:00
9f061277c7 auth: fix JWT default scheme and multi-audience validation
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-07-12 14:53:17 +01:00
c354cbab78 Merge pull request 'fic/jwt-validation' (#7) from fic/jwt-validation into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #7
2026-07-12 06:46:05 +01:00
b1e8d37f21 blogs: use centralized JWT audience handling
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
2026-07-12 06:45:00 +01:00
1b0933c215 tests: log warning when OIDC token fallback is used 2026-07-12 06:43:07 +01:00
6e4b68c60d Merge remote-tracking branch 'origin/main' into fic/jwt-validation 2026-07-12 06:12:19 +01:00
7fa55a68c9 WIP audiences 2026-07-12 06:10:24 +01:00
bb4ad4fcb8 Merge pull request 'fix/testing' (#6) from fix/testing into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #6
2026-07-12 06:03:39 +01:00
6c100ff759 use an available port for authority
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
2026-07-12 06:01:42 +01:00
4aacaf5e51 tests: configure static fixture ports and update org test config 2026-07-12 05:48:47 +01:00
713f66f1a7 ? 2026-07-12 04:36:35 +01:00
09604092c5 Revert "Test host: bind Kestrel to Site:Authority instead of dynamic port"
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
This reverts commit 402bcc1db8.
2026-07-12 03:48:49 +01:00
1ab0bef9a4 using clauses cleanup 2026-07-12 03:48:30 +01:00
402bcc1db8 Test host: bind Kestrel to Site:Authority instead of dynamic port
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
The Yavsc.Org integration tests were failing 'Internal Server Error' on
the OIDC discovery document when run as part of the full test suite.

Root cause: WebHostFixture bound Kestrel to IPAddress.Loopback on a
dynamically-allocated port and exposed it via IServerAddressesFeature.
But the OIDC issuer URLs (and the issuer claim) come from Site:Authority,
which was left at the production value (mercure.pschneider.fr). So
IdentityServer8's discovery document advertised URLs unreachable from
the test process, and the discovery call returned a 500.

Fix:
  - WebServerFixture now overrides Site:Authority and Site:ExternalUrl
    in AddInMemoryCollection to 'https://localhost:44300' (the ASP.NET
    Core dev HTTPS convention).
  - WebHostFixture reads Site:Authority from configuration and binds
    Kestrel to that fixed URL. The exposed Addresses list is sourced
    from the same configuration value instead of the
    IServerAddressesFeature, so the listen URL and the OIDC issuer
    URLs always match.

Remoting.cs (Mandatory/Remoting.cs): add 'using
Microsoft.Extensions.DependencyInjection;' so the existing OIDC/DB
diagnostic block (capture raw HTTP response + dump OIDC-related DB
state on discovery failure) compiles. The diagnostic itself is left
in place — it's what surfaced the 500 in the first place.
2026-07-12 03:43:22 +01:00
becd233593 Merge pull request 'fix/issue-3-splitquery' (#5) from fix/issue-3-splitquery into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #5
2026-07-12 02:47:15 +01:00
f3bb039d2f WIP audiences 2026-07-12 02:42:13 +01:00
9846210fd6 ApplicationDbContext: drop redundant HasOne on 3 Client navs
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
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.

Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.

The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.

This commit also walks back b12c272d:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
  for the InMemory shaper, and the Postgres path it was a hedge
  against was a false alarm — there is no Postgres production
  bug here, only an InMemory shaper quirk surfaced by the
  redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
  the provider-hypothesis phase. They pointed at the right
  entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
  end-to-end regression sentinel: with the fix in place, it
  loads a Client with two RedirectUris and asserts both are
  rendered. Without the fix, it fails with IndexOutOfRange.
2026-07-12 02:39:13 +01:00
b12c272df7 ClientController: split LoadClientAsync into per-collection subqueries
LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.

AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).

Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
  reproducer that adds a second RedirectUri via POST then re-GETs
  the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
  .SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
  InMemory provider, one nav at a time. Pinpointed three
  problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
  on InMemory; kept as a regression net for any future shaper
  regressions on the InMemory provider (not the Postgres path).
2026-07-12 01:15:02 +01:00
cb20b8a2d5 Revert "repoduces the bug"
This reverts commit fa7794b7a0.
2026-07-11 22:17:58 +01:00
Lum
fa7794b7a0 repoduces the bug 2026-07-11 21:56:25 +01:00
7a066707b3 Tests: route Yavsc.Org test host through Testing environment
TestWebApplicationFactory used ASPNETCORE_ENVIRONMENT=Development, which
caused Program.Main's AddConfiguration("org") to load the tracked
appsettings-org.json (the reference file with the
'*** via dotnet user-secrets ou variable d'environnement ***'
placeholder connection string). Npgsql then failed to parse that
placeholder during host startup, failing six integration tests
(observed 2026-07-11: System.ArgumentException on
NpgsqlConnectionStringBuilder.set_Item).

Switching the test host to a dedicated Testing environment makes
AddConfiguration("org") pick up the new optional
appsettings-org.Testing.json file as the last source in the chain
(JSON → env vars), which overrides YavscConnection with the
InMemory marker and the Smtp section with the test stub values.

The .gitignore exception whitelists this file explicitly: it is a
configuration source for the test host, not a secrets file.

The WebServerFixture path is unchanged — it owns its
WebApplicationBuilder and adds the same in-memory override via its
BuildApp hook.
2026-07-11 20:50:39 +01:00
Lum
cb7526de9d Blog: add test guarding the display-template fix + document test architecture
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.

- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
  concatène plus directement Model.UserName (ancien code fautif)
  et qu'il utilise bien le helper. Si quelqu'un revert la ligne
  4 du cshtml, les tests cassent. Les autres usages de
  Model.UserName (alt, title, asp-route-id) sont autorisés : ils
  ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
  (conventions NonRegression/Mandatory/Smoke/Controllers, EF
  in-memory via InMemoryDatabaseRoot partagé, auth stubs,
  quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
  (WebHostFixture + son cycle de vie et ses hooks,
  TestAuthPolicyProvider, TestTokenIssuer) et des deux
  spécialisations dans le repo
  (Yavsc.Org.Tests.WebServerFixture et
  Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
2026-07-11 19:59:03 +01:00
Lum
bbdcc7f2ad Blog: render user avatar through a null-safe helper
GET /BlogSpot/Details/1 retournait 500 (avec un 500-sur-500 sur
la page d'erreur elle-même) parce que DisplayTemplates
/ApplicationUser.cshtml faisait `var avuri = "/Avatars/" +
Model.UserName + ".s.png"` : avec <Nullable>enable</Nullable>,
Razor émet un null-check implicite sur Model.UserName et lève
NullReferenceException quand l'auteur n'a pas de UserName
posé (donnée héritée, user partiellement initialisé).

- UserDisplayHelpers.AvatarSrc : helper statique pur dans
  Yavsc.Abstract.Identity qui retourne
  YavscConstants.DefaultAvatar pour user null / UserName vide
  ou whitespace, et un path /avatars/<name>.s.png sinon.
- ApplicationUser.cshtml : utilise le helper.
- Tests : 4 cas (null, vide, whitespace, valide) dans
  Yavsc.Org.Tests/NonRegression.

Bonus : le path d'avatar passe de "/Avatars/" (S majuscule,
ne résolvait pas dans le middleware de fichiers statiques) à
YavscConstants.AvatarsPath ("/avatars" minuscule), pour fermer
l'autre trou que centraliser le calcul permettait de fixer
proprement.
2026-07-11 19:41:09 +01:00
Lum
98613e7070 Blog: enforce Restrict FK on BlogPost.Author and Comment.Author
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.

- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
  en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
  EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
  Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
  puis drop+add des FK. Down laisse la cascade (état pré-migration).

Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
2026-07-11 18:45:27 +01:00
7f56afd7e0 Merge commit 'bed9c8a272'
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-07-11 03:36:31 +01:00
bed9c8a272 fixes the compile and timestamps to db 2026-07-11 03:26:13 +01:00
8e8c558295 Merge pull request 'postIt' (#2) from postIt into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #2
2026-07-11 02:57:28 +01:00
abfc68a809 Just post one, at least
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
2026-07-11 02:56:14 +01:00
03cd9843d3 PostIt/Yavsc.Blogs: surface 4xx body + pin controller + red UI test for Save
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.

Three pieces here make the regression diagnosable and pin a
test for the fix:

1. YavscApiClient: replace EnsureSuccessStatusCode() at both
   call sites with a small helper that reads the response
   body and embeds it in the thrown HttpRequestException. The
   VM's existing catch (Exception) in ExecuteAsync forwards
   ex.Message to the status bar, so the next 'click Save'
   tells the user exactly which field the server rejected.

2. Yavsc.Blogs.Tests: two integration tests on the real
   controller (no HTTP mock) — one pins that a well-formed
   PostIt-shaped payload (Title + Article + AuthorId + dates,
   Id=0) is accepted with 201, the other pins that a payload
   with Title=string.Empty is rejected with 400. Together they
   pin the contract the VM has to honour.

3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
   MainPage inside a headless Window, types a title into the
   TextBox without first selecting a post in the list, taps
   Save, and asserts the body of the first POST contains
   the typed title. Today this test fails with Title='',
   reproducing the production 400. The matching fix (a
   Title/Article buffer on MainPageViewModel that the XAML
   binds to, and that Save uses to build the outgoing
   BlogPost) is the next commit; the test is the safety net.
2026-07-11 02:52:50 +01:00
56846e2781 Log the identity main params 2026-07-10 00:27:18 +01:00
58c08fe2fc auto refresh token 2026-07-09 23:34:15 +01:00
fdb7acca56 using Material.Avalonia 2026-07-09 23:28:46 +01:00
5104ffeb81 postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner
Three related changes that close the loop on the DarkMode
field and lay the first stone of a UI test scaffold for
PostIt.

1. Settings.DarkMode was previously a dead field. It round-
   tripped through postit-settings.json and the SettingsPage
   CheckBox, OnDarkModeChanged flipped IsDirty, and that was
   it — no consumer ever read the value, so toggling the
   CheckBox had no visible effect. The fix is in
   App.OnFrameworkInitializationCompleted: read the value
   Load() just populated and set
   Application.Current.RequestedThemeVariant accordingly
   (so a dark-mode user lands on a dark window on first
   launch, not on a default-light window that flips after
   the user touches the toggle), then subscribe to
   settings.PropertyChanged and update the theme on every
   DarkMode change. The consumer lives in App.axaml.cs, not
   in Settings, so the Settings model stays free of any
   Avalonia.Application dependency and the SettingsLoadTests
   (which construct Settings outside an Avalonia host)
   still pass unchanged.

2. MainPageViewModel had a vestigial [ObservableProperty]
   ThemeVariant themeVariant = ThemeVariant.Default that no
   XAML, no code, and no test ever read. It was the start of
   a half-finished attempt to expose the theme variant on
   the page VM. The dark-mode wiring above makes it
   irrelevant: the theme is now driven by Application, not
   by a VM property. The field is removed, along with the
   using Avalonia.Styling; it pulled in (now unused).

3. SessionStatusBannerTests adds the first set of UI tests
   for PostIt. They mount a real MainWindow via the headless
   Avalonia host declared in TestApp.cs, attach a
   SessionStatusViewModel as the banner's DataContext, and
   assert the actual visual tree contents: three buttons
   render (Se déconnecter, Se connecter, Paramètres), the
   Login button is visible when logged out, the Logout
   button is hidden when logged out, the Paramètres button
   is visible regardless of session, and the session label
   text reflects the VM. The pattern follows what
   UnitTest1.MainPage_Should_Load already established:
   [AvaloniaFact] (from Avalonia.Headless.XUnit) plus
   new MainWindow() / window.Show(). A plain [Fact] cannot
   drive Window..ctor() because the headless platform's
   PlatformManager.CreateWindow() has no service registered
   outside a dispatcher-aware test context; the
   AvaloniaFact attribute provides that context. The
   DataContext is set on the banner directly because
   App.OnFrameworkInitializationCompleted is not called in
   a unit test (production wiring is exercised by the
   manual launch, not here).

Build: 0 errors. Tests: 5/5 SessionStatusBannerTests,
3/3 SettingsLoadTests, 1/1 MainPageTests (the existing
scaffold test, unchanged). The other PostIt.Tests suites
depend on the OIDC stub WebApplicationFactory and time out
on this network-restricted host.
2026-07-09 22:41:43 +01:00
1733dababb postIt: SettingsPage is a singleton, navigation is idempotent
Two related changes that close the loop on the SettingsPage
push semantics.

1. The SettingsPage used to be registered as Transient. Each
   click on the Paramètres button resolved a fresh instance,
   re-bound it to the Settings singleton, and pushed it onto
   the navigation stack. Repeated clicks accumulated stacked
   instances, each fully bound, and the user had to tap Back
   N times to leave. The fix is to register the page as a
   Singleton in the DI container. There is now one and only
   one SettingsPage ContentPage for the lifetime of the app:
     - its DataContext is wired once, at composition time
       (just after the ViewLocator is added to DataTemplates),
       not on every push;
     - the OpenSettingsRequested handler is a pure navigation
       concern, with no DI resolution and no rebinding;
     - the in-memory Settings state is preserved across visits
       (any in-flight edit stays in the same instance).

2. The OpenSettingsRequested handler is guarded so that if the
   SettingsPage is already at the top of NavigationStack, the
   push is a no-op. NavigationPage.PushAsync does not
   deduplicate; without the guard, calling it twice with the
   same instance pushes it a second time, and the user has to
   tap Back twice to leave. The guard is a reference comparison
   on NavigationStack[Count - 1] against the singleton
   instance, which is correct precisely because the page is
   a singleton.

doc/architecture/postit.md is updated to match: the DI table
reflects the new lifetime, and the 'Garde anti-empilement'
section is rewritten from 'to be implemented' to the actual
implementation, including the rationale for reference
comparison and the cross-dependency between the singleton
lifetime and the guard.

The Settings-singleton invariant (in the same doc) is
unchanged: Settings is still a singleton, and adding a
transient override would still be the bug it always was.
The new SettingsPage singleton sits alongside it cleanly.

Build: 0 errors. Tests: 3/3 SettingsLoadTests green.
2026-07-09 22:00:00 +01:00
120bae6f5c doc(arch): PostIt topology + up-to-date project list
Two long-standing gaps in the architecture documentation are
filled in this commit:

1. doc/architecture/postit.md is new. It covers everything the
   existing postit-oidc.md does not: the one-codebase /
   three-frontends topology (PostIt lib + PostIt.Desktop +
   PostIt.Android + PostIt.Browser), the custom ViewLocator
   that resolves ViewModel -> View through the DI provider
   (and why we don't use the Avalonia.Mvvm default), the
   composition root in App.OnFrameworkInitializationCompleted
   with the full DI registration table, the navigation flow
   driven by SessionStatusViewModel events, the ViewModel
   lifetime conventions (singleton vs transient), the
   [RelayCommand] XAML binding conventions (referenced to
   AGENTS.md for the canonical version), and the per-page
   DataContext / role table. The Settings-singleton invariant
   is called out as a guard rail, and the SettingsPage
   anti-empilement invariant is documented as the TODO the
   code still owes us.

2. doc/architecture/decoupage-organisation.md is brought up to
   date. Its project table listed 7 .csproj; the repo has 14
   (the four PostIt projects, the tests satellites, the cli
   tool). The table is extended, the ASCII diagram picks up
   the PostIt block, and an Outils et tests section lists
   the test / CLI satellites that were missing.

doc/README.md is updated to index the new postit.md. No code
changes in this commit, no behaviour change.
2026-07-09 21:54:06 +01:00
13d985e4e3 fixes the button access 2026-07-09 21:35:06 +01:00
d3664c5cdc postIt: scope list in SettingsPage, fix Settings DI re-registration
Two changes to the PostIt settings surface, both in service of
the same observation: opening the Settings page did not reflect
the loaded state, and edits to Authority / ClientId did not
persist.

1. Settings was registered twice in the DI container: once as
   a singleton (the already-Load()'d instance) and again as a
   transient, with the transient registration winning. The
   Settings page's DataContext was therefore a brand-new,
   empty Settings instance on every push — Authority and
   ClientId bound to null, and even if the user typed into the
   fields, the edits landed on the throwaway instance and were
   silently lost. The fix is the obvious one: keep Settings as
   a singleton and drop the transient override.

2. The Scopes field of AuthenticationSettings is a string[],
   which doesn't bind to a TextBox without a converter. The
   Settings page already shows the other auth fields as plain
   TextBoxes, so the same treatment is given to scopes via a
   new space-separated view property:

     - AuthenticationSettings.ScopeListText (string,
       [ObservableProperty], [JsonIgnore]) is the view.
     - OnScopeListTextChanged splits on any whitespace and
       re-assigns Scopes, skipping the write when the parsed
       array is element-wise equal to the current one to avoid
       a PropertyChanged loop with OnScopesChanged.
     - OnScopesChanged keeps ScopeListText in sync when
       Scopes is reassigned from outside (JSON hydration,
       MergeScopes, programmatic updates), again short-
       circuiting when the textual representation hasn't
       changed so the TextBox caret doesn't flicker on load.
     - RefreshScopeListText is the explicit re-sync entry
       point; Settings.ApplyJson calls it after a successful
       hydration to normalise any whitespace the JSON might
       have introduced.

   SettingsPage.axaml gets a new Scopes row between ClientId
   and the Blogs API URL; the Grid.RowDefinitions are bumped
   to 13 to match. Scopes remains the on-disk format — only
   ScopeListText is presentation.

   The shape of the on-disk postit-settings.json is
   unchanged: [JsonIgnore] on ScopeListText, and the
   serialization path in Settings still round-trips Scopes
   directly. MergeScopes in Settings.GetOidcClientOptions is
   untouched.

Tests: 3/3 SettingsLoadTests passing (PostIt.Tests);
PostIt.csproj builds clean (0 errors). The other PostIt.Tests
suites depend on the OIDC stub WebApplicationFactory and
time out on this network-restricted host, so we trust the
unit-level coverage and the build.
2026-07-09 21:24:48 +01:00
375e6482a6 test(yavsc.org): cover the kid derivation in ComputeKid
Extract the kid calculation out of LoadSigningCredentialsInner
into a new internal static HostingExtensions.ComputeKid(string),
and cover it with five focused unit tests in
Yavsc.Org.Tests.ComputeKidTests.

The kid is the bit of signing-credential metadata that ties a
JWT to the right key in the JWKS. Without it, resource servers
(Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500
'The signature key was not found', as fixed in 2c6d1157. That fix
inlined three lines of thumbprint-truncation logic at the top of
LoadSigningCredentialsInner, but left the calculation untested.
The tests in this commit pin its shape, value, stability, and
uniqueness, so a future refactor (e.g. switching from SHA-1 to
SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has
to update them deliberately instead of silently changing the
JWKS key id.

Concretely:

  - InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs
    gives the test project access to the new internal method
    without forcing LoadSigningCredentialsInner to leak
    further.
  - ComputeKid(string) is the single source of truth for the
    16-hex truncation; the production call site in
    LoadSigningCredentialsInner now reads
    'var kid = ComputeKid(certPath);'.
  - The inline comment block is updated to say SHA-1 (which is
    what X509Certificate2.GetCertHash() actually returns) instead
    of the previous SHA-256 claim. The behaviour is unchanged.
  - ComputeKid uses X509CertificateLoader.LoadCertificateFromFile
    rather than the obsolete 'new X509Certificate2(string)' ctor
    (SYSLIB0057); same on-disk behaviour, no obsolete warning.

Tests cover:
  - 16-char upper-case hex output matching the first 16 hex
    chars of the cert's GetCertHash();
  - stability across repeated reads of the same cert;
  - distinctness between two independently generated certs;
  - the SHA-1 size of the underlying thumbprint (20 bytes), so
    a future switch to SHA-256 forces a test update;
  - CryptographicException propagation for a missing cert file
    (Assert.ThrowsAny to stay portable across the Linux OpenSSL
    and Windows leaf exception types).
2026-07-09 20:28:58 +01:00
2c6d11577c Yavsc.Org: set KeyId on signing credentials
IdentityServer8 was emitting JWTs without a 'kid' header and
serving the JWKS without per-key identifiers, because
LoadSigningCredentialsInner constructed RsaSecurityKey /
ECDsaSecurityKey objects without an explicit KeyId. Resource
servers (Yavsc.Blogs, Yavsc.Api) cannot match a token to a key
in the JWKS without one, so every signature validation failed
with 'The signature key was not found' (Microsoft.IdentityModel
IDX10500).

Root cause: SigningCredentials were built directly from the
BC-parsed key parameters, bypassing the X509Certificate2 path
IdentityServer normally derives the kid from. The fix derives
a stable KeyId from the certificate's SHA-256 thumbprint
(truncated to 16 hex chars) and sets it on both SecurityKey
variants before constructing SigningCredentials.

The thumbprint-based kid is stable across process restarts as
long as the cert doesn't change, and changes naturally on
LetsEncrypt renewal (~90 days), which is the right behaviour:
old tokens age out, resource servers refresh their JWKS cache
to discover the new kid.

Production rollout: redeploy Yavsc.Org and re-login (or let
the refresh-token path rotate) so newly issued tokens carry
the kid. Pre-restart tokens will continue to be rejected with
IDX10500 until they expire or are refreshed.
2026-07-09 00:32:20 +01:00
6055117929 first, the blogs 2026-07-08 23:18:14 +01:00
0adc60d5ed simpler 2026-07-08 23:16:16 +01:00
2eadd6e1b7 better 2026-07-08 23:16:04 +01:00
b7873ebd7c GET posts [dev] 200 2026-07-08 22:46:46 +01:00
0aea6c0dbd PostIt: Sauver button on SettingsPage with dirty tracking
SettingsPage.axaml had TextBox / CheckBox TwoWay bindings to the
Settings singleton, but no Save button — user edits mutated the
in-memory instance and were lost on the next launch. This commit
addes the missing save path:

- Settings.Save() writes the current instance to
  ~/.config/PostIt/postit-settings.json (symmetrical to Load),
  with 0600 POSIX permissions matching TokenStore.Save.
- Settings.IsDirty ObservableProperty flips to true on every
  setter that flows through the four top-level
  [ObservableProperty] fields (DarkMode, BlogsApiUrl,
  BusinessApiUrl, plus the OnAuthenticationChanged partial for
  the Authentication sub-property). Sub-property edits
  (Authentication.Authority / ClientId / RedirectUri / Scopes)
  are caught by a PropertyChanged subscription wired up in
  OnAuthenticationChanged and re-wired on each Authentication
  reassignment.
- [RelayCommand(CanExecute = nameof(CanSave))] on Save itself
  emits the SaveCommand ICommand that the XAML binds to.
  OnIsDirtyChanged calls SaveCommand.NotifyCanExecuteChanged()
  so the button auto-enables / auto-disables. The Avalonia
  binding is 'SaveCommand' without a suffix — the source
  generator emits that property name from the Save method.
- ApplyJson resets IsDirty = false at the end so disk / embedded
  loads don't leave the page stuck in dirty state.
- SettingsPage.axaml: fixed the RowDefinition count (4 rows
  declared, 10 used — controls at rows 4..9 were rendering
  outside the grid), and added a Sauver button at row 10 bound
  to SaveCommand with IsEnabled driven by !IsDirty.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Tests: 45 / 45 passing.
2026-07-08 20:05:09 +01:00
e9df13a477 test(postit): pin blogs scope on bearer, fix post-refactor tests
Two intertwined jobs here:

1. Diagnostic test for the production 401 we see when PostIt
   talks to Yavsc.Blogs. The hypothesis this test isolates:
   the access token sent on the wire is missing the 'blogs'
   scope that Yavsc.Blogs' BlogScope policy requires (see
   Yavsc.Blogs/Program.cs: RequireClaim(JwtClaimTypes.Scope,
   "blogs")). The test fakes a single HttpMessageHandler,
   captures the outbound bearer, decodes the JWT, and asserts
   the 'scope' claim contains 'blogs'. It does not stand up a
   server, an OIDC stub, or any network listener. Result: the
   scope is present in the access_token we construct, so the
   401 is not on the client side — most likely the IdP at
   Yavsc.Org is not issuing 'blogs' as a recognised scope.

2. Mechanical fix of the three test files that broke during
   the Settings model refactor (PostIt.Settings ->
   PostIt.ViewModels.Settings; ApiUrl -> BusinessApiUrl;
   Scopes/RedirectUri moved under Authentication;
   DefaultDesktopRedirectUri is on AuthenticationSettings in
   the global namespace). Also restored the BaseAddress
   setup that BlogApiClient does in production in
   LoginAndPersistAsync / the reloaded-client path of
   YavscApiClientTests, so the two integration tests that
   call CallAsync("posts") directly don't trip on
   'request URI must be absolute or BaseAddress must be set'.

Test status: 45 / 45 passing in PostIt.Tests.
2026-07-08 19:36:40 +01:00
4a6609e2f1 PostIt: route Paramètres button to SettingsPage
The "Paramètres" button on SessionStatusBanner was wired to a stub
OpenSettingsCommand with a TODO. With the Settings model refactor
(VM consolidated to ViewModels/Settings.cs, SettingsViewModel.cs
dropped, App.axaml.cs registering Settings instead of the old VM),
the navigation is now plumbed end to end:

- SessionStatusViewModel gains an OpenSettingsRequested event
  alongside LogoutCompleted / LoginSucceeded, and the
  [RelayCommand] body just raises it. VM stays decoupled from
  NavigationPage and window lifetime, same pattern as the
  existing banner events.
- App.axaml.cs handles the event in the desktop branch: resolves
  SettingsPage (transient) and the canonical Settings singleton
  (the one we Load()'d at startup and bound via
  Settings.BindToServiceProvider) from DI, then PushAsync the
  page on top of the current NavRoot stack. Two-way bindings on
  SettingsPage mutate the singleton in place.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Existing CS8602 / NU1507 / CS8632 warnings unchanged.
2026-07-08 19:03:54 +01:00
0480e3e3e8 fix(blogs): drop debug MapGet("/identity") claim-dumper
The endpoint was a copy-paste from the IdentityServer template
documentation. It serialised the entire HttpContext.User claim set
to anonymous JSON, with no auth gate. In a public-facing deployment
that's exactly the kind of surface scrapers and botnets love
(it tells them whether their token is valid and what shape the
issuer uses), and it served no production purpose.

Side benefit: removes the ASP0004 analyser warning ("IActionResult
should not be returned from a MapGet Delegate") that came with
this line.
2026-07-07 22:13:29 +01:00
006e05a375 fix(blogs): register controller endpoints via MapControllers
All [ApiController] classes (BlogApi, BlogTags, PostTags, FileSystem,
FileSystemStream, Comments, TagsApi) returned 404 on every route,
even though Kestrel was up. The pipeline in Program.cs was missing
MapControllers(), so the controllers were never attached to the
endpoint data source. MapIdentityApi and MapGet("/identity")
worked because they're explicit minimal-API routes; the attribute-
routed controllers didn't.

Confirmed end-to-end: GET /api/v1/blog now returns 401 (auth
required by [Authorize("BlogScope")]) instead of 404.
2026-07-07 21:48:02 +01:00
4dc1946c82 PostIt: drop unused Settings.folder + align ViewLocator nullability
Two leftover bits of dead code that were just compiler noise:

  - Settings.folder (IStorageFolder?, never read) plus the three
    Avalonia / Avalonia.Platform.Storage usings that only existed
    to type it. The picker-based flow was replaced by a direct
    file-path read in Settings.Load, so the field has been a
    CS0414 for a while. Just delete it.

  - ViewLocator.Build / Match took 'object data' while the
    IDataTemplate interface expects 'object? data', which is why
    the compiler was complaining with CS8767 about nullability
    mismatch on every implementation. Add an explicit null arm
    in the switch so the default branch doesn't have to
    dereference a possibly-null data either.
2026-07-07 20:59:26 +01:00
9e272a8147 PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).

MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.

Removed:
  - Views/LoginPage.axaml + .axaml.cs
  - ViewModels/LoginPageViewModel.cs
  - HomePage Login button + OnLoginClick code-behind
  - DI registrations for LoginPage / LoginPageViewModel
  - ViewLocator mapping
  - dangling <c>LoginPage*</c> cref / comments in Platform.cs,
    PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
    YavscApiClient.cs
2026-07-07 20:47:00 +01:00
c3f2408c4a test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.

Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.

Notes for future-me:
  - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
    called once on the first Issue() to keep the 'sub' claim
    literal; without it UserHelpers.GetUserId (which reads 'sub')
    gets ClaimTypes.NameIdentifier instead, returns null, and the
    owner check fails for every PUT. The companion
    options.MapInboundClaims = false on the validation pipeline
    keeps both sides in sync.
  - Production still uses AddYavscJwtBearer against the OIDC
    authority; the test-only HS256 path is local to the test
    process and never crosses a network boundary.

Coverage:
  - GetBlog_returns_401_when_no_token_is_provided — anonymous
    request, real policy fails closed.
  - PutBlog_with_valid_token_and_owner_returns_204_and_Get_
    reflects_update — POST then PUT then GET, all behind a real
    JWT, asserting 204 + list contains the updated title.

Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
6d222cf819 fix(blogs): accept JSON on POST /api/v1/blog (no file)
BlogApiController.PostBlog called Request.Form.Files
unconditionally, which throws on a plain JSON body — the
exception is "This request does not have a Content-Type header.
Forms are available from requests with bodies like POSTs and a
form Content-Type of either application/x-www-form-urlencoded or
multipart/form-data."

That broke PostIt's first-bill-of-blog flow: the client posts a
BlogPost as JSON and has no files to attach. The endpoint
contract is [FromBody] BlogPost, so the JSON body is deserialised
into 'blog' as expected — only the IFormFileCollection argument
to BlogSpotService.Create needs a real-or-empty value.

Branch on Request.HasFormContentType: pass the form files when
present, pass an empty FormFileCollection otherwise. BlogSpotService
already short-circuits on a null/empty file collection, so the
JSON-only path is now a clean code path.

Tests: add PostBlog_creates_a_post_and_Get_returns_it_in_the_list
(POST a draft, assert 201 + server-assigned Id, GET the index,
assert exactly one entry with that Id). Add a per-test
ResetDatabase helper because the in-memory store is shared across
the lifetime of the BlogsWebServerFixture instance.
2026-07-06 22:03:41 +01:00
9f5a1505e3 test(blogs): GET /api/v1/blog returns 200 with empty list
First behavioural test for the Yavsc.Blogs API surface. Sends a
GET on the blog index with the X-Test-Role auth bypass and asserts
the response is 200 with an empty JSON array — the in-memory
ApplicationDbContext has no rows, and BlogSpotService.Index returns
an empty enumeration.

While here, fix a routing miss: AddControllers() in the test
fixture was only scanning the test assembly, so BlogApiController
was never registered. Add the Yavsc.Blogs application part
explicitly. Without this, every request to /api/v1/blog came back
as 404 — the same symptom PostIt was seeing in production.

The POST flow lands in the next commit, once BlogApiController is
made to accept JSON (it currently requires multipart/form-data
because of Request.Form.Files).
2026-07-06 21:58:14 +01:00
8c38bab45a feat(tests): scaffold Yavsc.Blogs.Tests with BlogsWebServerFixture
Adds the test project that the next commit will use to assert the
blog API endpoints. The fixture inherits from the shared
WebHostFixture (commit "refactor: extract WebHostFixture…") and
wires up only the bits the blog API needs:

* In-memory ApplicationDbContext — BlogSpotService is used as-is,
  no mock. The first tests will exercise the real service against
  an empty table.
* Trivial IFileSystemAuthManager stub (the GET index path never
  reads the file system).
* TestAuthPolicyProvider swapped in, so X-Test-Role satisfies
  [Authorize("BlogScope")].

Two smoke tests verify the fixture boots and the controller
pipeline is reachable. The first behavioural test
(GET /api/v1/blog → 200) lands in the next commit.

Also promotes two xunit.v3.* package versions to the root
Directory.Packages.props so future test projects can share them.
2026-07-06 21:49:53 +01:00
49b9619af7 Merge pull request 'postIt' (#1) from postIt into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #1
2026-07-06 21:35:47 +01:00
349ddc03f5 refactor: extract WebHostFixture + TestAuthPolicyProvider to shared lib
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
Yavsc.Blogs.Tests will need a fixture too. Lifting the cross-cutting
plumbing (Kestrel + self-signed cert + address discovery + lazy init)
into a new Yavsc.Tests.Shared project lets the next fixture inherit
from it without copying 200+ lines of setup boilerplate, and keeps
the Org.Tests fixture focused on its IdentityServer + SMTP seed.

* New project src/Yavsc.Tests.Shared with WebHostFixture (abstract)
  and TestAuthPolicyProvider (test auth bypass via X-Test-Role).
* WebServerFixture in Org.Tests now inherits from WebHostFixture;
  BuildApp + ConfigurePipelineAsync hold only Org-specific work.
* Two shared package versions promoted to the root Directory.Packages.props.
* Tests still 30/30 green.
2026-07-06 21:33:57 +01:00
20a6f22ec3 PostIt: document the BaseAddress / pathPrefix URL convention
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
The previous "PostIt: fix blog API double-prefix" commit changed
DefaultPathPrefix from "api/blog" to "blog" without spelling out
the convention. Future-me (or anyone else touching ApiUrl) needs
to know that BaseAddress already terminates in /api/v1/ and that
pathPrefix is relative to that.

* BlogApiClient: add a <para> in the class summary that names the
  convention, points at the matching controller route, and
  cross-references the fix commit.
* postit-oidc.md: add a row in the "Composants partagés" table
  with the same warning, in the architectural-doc voice.
2026-07-06 21:03:10 +01:00
0d2d4160af PostIt: fix blog API double-prefix; drop redundant New
BlogApiClient's "api/blog" path combined with the BaseAddress's
"api/v1/" prefix to produce 404s on every call. Drop the redundant
"api/" segment, let Save handle the create case (no selection) and
remove the now-redundant New button + command.
2026-07-06 20:58:58 +01:00
f2ae01729a clean up
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-07-06 19:19:28 +01:00
dce17888ac code cleanup 2026-07-06 03:31:05 +01:00
f11913ec08 build 2026-07-06 03:26:57 +01:00
a5acfcfc05 revert 2026-07-06 03:25:19 +01:00
89aa2bc37d migration 2026-07-06 03:17:23 +01:00
4a70abc0f9 Merge branch 'feat/estimate' 2026-07-06 01:07:43 +01:00
835cb47b18 could fix the CI 2026-07-06 01:06:50 +01:00
aca3ceffe2
Merge pull request #66 from pazof/feat/estimate
Feat/estimate
2026-07-06 00:58:04 +01:00
19e3b30830
Potential fix for pull request finding 'CodeQL / Missing cross-site request forgery token validation'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-06 00:57:47 +01:00
3932d8823f
Merge pull request #65 from pazof/dependabot/github_actions/all-actions-640176b5ab
build(deps): bump actions/checkout from 4 to 7 in the all-actions group across 1 directory
2026-07-06 00:50:17 +01:00
6ac264fa2c tests 2026-07-06 00:47:35 +01:00
c08ff81776 fixes the startup 2026-07-06 00:14:22 +01:00
333b066e66 Identity reloaded 2026-07-05 23:56:10 +01:00
dependabot[bot]
a2c7cf9f9c
build(deps): bump actions/checkout
Bumps the all-actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-05 16:04:33 +00:00
ef0f5ddcac refacto FrontmatterParser 2026-07-04 22:58:29 +01:00
7b3a236bcc refacto query status 2026-07-04 22:35:53 +01:00
ee6cb34c23 renaming Reviewed 2026-07-04 22:25:59 +01:00
c74ac71b5d layouts 2026-07-04 20:09:58 +01:00
bce6280750 titres 2026-07-04 20:03:56 +01:00
c8894c4220 gives titles 2026-07-04 19:57:03 +01:00
34b4203cd1 Ui fixes 2026-07-04 19:49:48 +01:00
17838bc78e fixes 2026-07-04 18:46:24 +01:00
a0342ea988 Revert "search all user by email at forgotten password"
This reverts commit 406e2ff03a.
2026-07-04 17:29:49 +01:00
406e2ff03a search all user by email at forgotten password 2026-07-04 17:24:12 +01:00
742da7c3f0 Activity moderated 2026-07-04 17:11:43 +01:00
90dfe9c13f drop the deigner.cs 2026-07-04 16:06:02 +01:00
Lum
f4eb14d083 feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.

Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
  with FK to Estimate, FK to ApplicationUser (Signer), Type
  (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
  (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
  versions per (EstimateId, Type) are allowed; the controller
  reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
  composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
  (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
  OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
  cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
  ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
  Writes a yavsc.signature/v1 JSON envelope to
  UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
  Quota update lives in the controller, not the helper, because
  the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
  BillingController. Authz is body-driven (the bearer token is the
  PostIt OAuth client, not the end user, so signerUserId is in
  the JSON body, validated against Estimate.OwnerId/ClientId).
  Returns 201 Created with the new Signature's metadata.

Plumbing
- SignatureSubmission (body type) lives next to BillingController
  in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
  the IFormFile PNG contract; the new endpoint is the JSON
  counterpart.

Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
  (8 tests, all green): filename format incl. lowercase type and
  ticks, envelope v1 round-trip (parsed via JsonDocument, not
  text matching), null payload rejected, non-positive
  estimateId rejected. Disk side effects are isolated to a
  per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
  all compile clean.

Out of scope
- EF migration: the Signatures table doesn't exist in the
  database yet. The migration is intentionally a separate
  commit so the generated SQL can be reviewed against the
  composite index and the int[] column type before it touches
  any prod database. Until the migration lands, the new
  endpoint will 500 on SaveChanges; the [DEV] button in
  PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
  'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
1d26cbdf3d refacto chathub 2026-07-04 15:28:07 +01:00
Lum
b939c403f6 feat(postit): signature capture page (dev entry, file persistence)
Builds on b0495514 (SignaturePadControl + SignaturePadData) with a
full Avalonia page that captures signatures, renders them as Polylines,
and persists the wire-format payload to ~/.local/share/PostIt/signatures
as JSON v1.

Scope
- New SignaturePage (axaml + code-behind) hosts the render-agnostic
  control: a fixed-size Border is the hit-test surface, an overlaid
  Canvas is rebuilt on every RedrawRequested from the Strokes buffer.
- SignaturePageViewModel wraps the control: exposes StrokeCount /
  PointCount / StatusMessage, Clear and CaptureAsync commands, and
  Attach/Detach for view-lifetime ownership.
- CaptureAsync writes a JSON envelope { format, coordinateMax,
  capturedAtUtc, strokes, strokeCount } to
  LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json.
  This is a stop-gap; the production transport will be
  POST /api/signature/{devisId} on Yavsc.Api (commit 3+).
- Entry point is a [DEV] button on MainPage that pushes the page
  onto the NavigationPage. The production trigger is a SignalR push
  from Yavsc.Org ("devis received, sign here") landing on a hub
  handler — the button and its Click handler are explicitly marked
  dev-only and tracked for removal in the same commit that wires
  the SignalR handler.

Plumbing
- App.axaml.cs: SignaturePage and SignaturePageViewModel registered
  as Transient in the DI container.
- ViewLocator: routes SignaturePageViewModel to SignaturePage.
- SignaturePadData: adds PointCount (sum of pairs across strokes),
  used by the VM status bar and the test surface.

Tests (57/57 green, 9 new in this commit)
- SignaturePageViewModelTests: constructors and dimension validation,
  Attach/Detach idempotence, StrokeCompleted and Clear propagate to
  the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a
  non-empty buffer writes a v1 envelope with the expected
  structure (parsed back via JsonDocument, not text matching), and
  creates the destination directory if missing.
- All previously-green tests (48) remain green.

Out of scope
- POST /api/signature endpoint on Yavsc.Api (commit 3).
- SignalR handler that opens the page on a "devis received" push.
- Rasterization: this commit only proves capture and persistence;
  the visible ink is a Polyline reconstruction, not a PNG, by
  design (per the wire-format decision in commit 1).

Note on SignaturePadData
- The PointCount property was added after b0495514 landed. It is
  folded into this commit rather than amending b0495514 to keep
  the existing history readable; the change is mechanical and
  tested by the new SignaturePageViewModelTests.
2026-07-04 15:11:22 +01:00
b049551448 a Signature Pad 2026-07-04 14:43:39 +01:00
ec54108cf0 presentation 2026-07-01 00:13:57 +01:00
0cc82fec5d bug fix IUserSettings 2026-06-30 23:55:43 +01:00
0965caaf40 fixes and renaming 2026-06-30 23:32:05 +01:00
1751145be8 Exploiting GitVersion 2026-06-28 14:11:26 +01:00
Lum
0617fc6bda postit: make Settings thread-safe and route PropertyChanged through UI dispatcher
The postit://callback re-launch crashed Avalonia inside
DataValidationErrors.SetErrors with 'The calling thread cannot
access this object because a different thread owns it'. Two
Settings instances raced on PropertyChanged: one was the DI
singleton registered by App.OnFrameworkInitializationCompleted,
the other was a freshly-constructed fallback in
LoginPageViewModel() and LoginPage.axaml.cs's DataContext-null
branch. Avalonia's binding sink caught the cross-thread
notification and crashed before the LoginPage could render.

Fix at three layers:

1. Settings: lock the mutation gate so concurrent Load() /
   ApplyJson() callers cannot tear reads; override
   OnPropertyChanged to marshal every notification onto the
   Avalonia UI thread via a new UiDispatcher helper (no more
   cross-thread SetErrors). Add BindToServiceProvider /
   RequireCurrent so production code paths cannot silently
   allocate a second instance.

2. LoginPageViewModel(): resolve the canonical Settings from
   the DI container (Settings.RequireCurrent) instead of
   new Settings(). The cross-thread crash is now caught loudly
   with a clear 'Settings.Current is not bound' error if
   something instantiates the VM outside a bound App.

3. HomePage.axaml.cs and LoginPage.axaml.cs: resolve the
   next view-model and BlogApiClient through App.Services
   instead of constructing them with 'new'. Same instance
   tree as the rest of the app; the postit://callback race
   disappears by construction.

MainPageViewModel and HomePageViewModel keep their existing
'?? new Settings()' fallback for test friendliness, but the
fallback is now harmless because Settings itself is
thread-safe.

Adds two regression tests in SettingsLoadTests covering
concurrent Load+mutate and concurrent idempotent Load.
2026-06-28 13:33:06 +01:00
bd3dba0089 refactor(postit): rewrite MainPage layout in Grid
- ContentPage now stretches to fill the window
- root StackPanel replaced by a 3-row Grid (Auto, *, Auto)
  so the post list absorbs the middle band and the detail
  panel sits below
- detail panel inner StackPanel replaced by a Grid (Auto,
  Auto, Auto, *, Auto) so the AvaloniaEdit TextEditor fills
  all remaining width and height; MinHeight=320 keeps a
  sane floor on tiny windows
2026-06-28 01:47:12 +01:00
2f7df74000 petit refacto 2026-06-28 01:43:39 +01:00
fd6d6ab2d8 postit: change MainPage base class from NavigationPage to ContentPage
HomePage.OnLoginClick does Navigation.PushAsync(new MainPage
{ ... }). NavigationPage.PushAsync only accepts a Page (or
Page subclass), not a MultiPage. MainPage was declared as

  public partial class MainPage : NavigationPage

in MainPage.axaml.cs and the root of MainPage.axaml was

  <NavigationPage ...>

which means 'new MainPage()' produced a MultiPage, not a
Page. PushAsync against a MultiPage argument does not route
through the standard Page push path; the visible result is
that the login succeeds, LoginSucceeded fires, but the UI
stays on LoginPage — the user is left looking at the
post-login state without any navigation.

The XAML content of MainPage (a StackPanel with the post
CRUD UI, a ListBox, a TextEditor) does not need the
multi-page container semantics — it's a single screen.
Switch both the code-behind base class and the XAML root
element to ContentPage so that MainPage is what
PushAsync expects.
2026-06-28 01:13:08 +01:00
4e8402e766 postit: drop ConfigureAwait(false) after interactive login
LoginAsync uses ConfigureAwait(false) on the await of
LoginInteractiveCoreAsync. Since the surrounding code is
already executing on the UI thread (it was reached via a
RelayCommand that the UI dispatcher dispatched), the
ConfigureAwait drops the SynchronizationContext, and the
subsequent setters — IsBusy, AccessToken, StatusMessage,
LoginSuccess, and the LoginSucceeded?.Invoke() — run on a
thread-pool worker.

The downstream effects are all UI-bound: PropertyChanged
events fire, the BindingEngine republishes them as
AvaloniaObject.SetValue calls, and SetValue calls
Dispatcher.VerifyAccess. VerifyAccess throws because the
AvaloniaObject was created on the UI thread (owned by it)
and the SetValue is being attempted from a thread-pool
worker. Avalonia 11.12 throws SynchronousException through
DispatcherOperation.InvokeCore instead of dispatching back,
so the X11 message loop crashes the process with
System.InvalidOperationException: 'The calling thread cannot
access this object because a different thread owns it.'

Reproduced with the freshly installed postit_1.0.0-1_amd64.deb
package on a Debian 13 host — the .NET runtime loaded the
app, Avalonia started the X11 message loop, the operator
clicked 'Se connecter', the OIDC flow reached the post-login
phase, and the post-await setter chain crashed the process.

Drop ConfigureAwait(false) so the await captures the UI
thread SynchronizationContext and the setters resume on the
UI thread. The inner LoginInteractiveCoreAsync still uses
ConfigureAwait(false) for its own await, which is fine —
the inner method does not touch observables, only mutates
Platform.CreateBrowser and awaits the OIDC roundtrip, so it
can run anywhere.
2026-06-28 01:05:02 +01:00
1069 changed files with 28833 additions and 220043 deletions

View file

@ -1,7 +1,6 @@
**/bin/ **/bin/
**/obj/ **/obj/
**/.playwright/ **/.playwright/
.git/
.vs/ .vs/
.github/ .github/
# Exclure uniquement les dossiers de sortie de compilation # Exclure uniquement les dossiers de sortie de compilation
@ -14,5 +13,4 @@ test/*/obj/
# Exclure les caches lourds # Exclure les caches lourds
**/.playwright/ **/.playwright/
.git/
.vs/ .vs/

View file

@ -21,33 +21,28 @@ on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main", "release/*" ]
jobs: 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: build:
runs-on: debian-latest runs-on: docker
steps: steps:
- uses: actions/checkout@v6 - name: Clone yavsc
- name: Setup .NET run: |
uses: actions/setup-dotnet@v5 cd /src
with: git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
dotnet-version: 9.0.x 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 - name: Restore dependencies
run: dotnet restore run: cd /src/_src && dotnet restore
- name: Build - name: Build
run: dotnet build --no-restore run: cd /src/_src && dotnet build --no-restore
- name: Test - 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

@ -59,7 +59,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
# Add any setup steps before running the `github/codeql-action/init` action. # Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node` # This includes steps like installing compilers or runtimes (`actions/setup-node`

View file

@ -4,7 +4,20 @@ on:
push: push:
branches: branches:
- main - main
tags:
- '*'
workflow_dispatch: 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.
permissions:
contents: write
jobs: jobs:
apk-deploy: apk-deploy:
@ -12,6 +25,9 @@ jobs:
steps: steps:
- name: Checkout du code - name: Checkout du code
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
# 1. Votre étape de build actuelle (on nomme l'image "postit-android") # 1. Votre étape de build actuelle (on nomme l'image "postit-android")
# --target build-env : on ne veut que le stage de build (qui # --target build-env : on ne veut que le stage de build (qui
@ -34,3 +50,134 @@ jobs:
path: ./PostIt.Android.apk path: ./PostIt.Android.apk
retention-days: 7 retention-days: 7
# 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/')
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
uses: actions/download-artifact@v7
with:
name: application-apk-release
path: ./
- name: Publier la release GitHub et uploader l'APK
uses: softprops/action-gh-release@v2
with:
# Le nom de fichier final dans la release. C'est ce qui
# 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
# 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 }}

9
.gitignore vendored
View file

@ -24,6 +24,15 @@ data/
appsettings.*.json appsettings.*.json
appsettings-*.*.json appsettings-*.*.json
# Exception: the Testing-environment override for Yavsc.Org is a tracked
# configuration source, not a secrets file. TestWebApplicationFactory
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
# AddConfiguration("org") in Program.Main loads this file as the
# last in the chain (it is optional). It overrides the connection
# string and SMTP section for the in-memory test host and contains
# no production secrets.
!src/Yavsc.Org/appsettings-org.Testing.json
generated/ generated/
*.tmp *.tmp
DataDir/ DataDir/

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

2
.vscode/launch.json vendored
View file

@ -14,7 +14,7 @@
"name": "Yavsc.Org", "name": "Yavsc.Org",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
}, },
{ {
"name": "Yavsc.Blogs", "name": "Yavsc.Blogs",

16
.vscode/settings.json vendored
View file

@ -12,8 +12,10 @@
"DOTNET", "DOTNET",
"ecdsa", "ecdsa",
"envsubst", "envsubst",
"Hsts",
"Newtonsoft", "Newtonsoft",
"Npgsql", "Npgsql",
"PKCE",
"postit", "postit",
"pschneider", "pschneider",
"SLNDIR", "SLNDIR",
@ -26,5 +28,17 @@
"cSpell.language": "fr,en", "cSpell.language": "fr,en",
"makefile.configureOnOpen": false, "makefile.configureOnOpen": false,
"search.useGlobalIgnoreFiles": true, "search.useGlobalIgnoreFiles": true,
"search.useParentIgnoreFiles": true "search.useParentIgnoreFiles": true,
"chat.mcp.serverSampling": {
"yavsc/.vscode/mcp.json: openclaw": {
"allowedModels": [
"copilot/auto",
"copilotcli/claude-haiku-4.5",
"copilotcli/gpt-4.1",
"copilotcli/gpt-5-mini",
"copilotcli/mai-code-1-flash-picker",
"copilotcli/gpt-5.3-codex"
]
}
}
} }

27
.vscode/tasks.json vendored
View file

@ -13,16 +13,17 @@
"isBackground": true "isBackground": true
}, },
{ {
"label": "build-web", "label": "test blogs backend",
"type": "process", "type": "process",
"problemMatcher": ["$msCompile"], "problemMatcher": ["$msCompile"],
"command": "dotnet", "command": "dotnet",
"args": ["build"], "args": ["test"],
"options": { "options": {
"cwd": "src/Yavsc.Org" "cwd": "src/Yavsc.Blogs.Tests"
}, },
"group": { "group": {
"kind": "build" "kind": "test",
"isDefault": false
} }
}, },
{ {
@ -40,17 +41,23 @@
"isBackground": true "isBackground": true
}, },
{ {
"label": "build-web", "label": "test blogs",
"type": "process", "type": "process",
"problemMatcher": ["$msCompile"], "problemMatcher": ["$msCompile"],
"command": "dotnet", "command": "dotnet",
"args": ["build"], "args": ["test"],
"runOptions": {}, "runOptions": {
"instanceLimit": 1
},
"options": { "options": {
"cwd": "src/Yavsc.Web" "cwd": "src/Yavsc.Blogs",
"env": {
"DOTNET_CLI_UI_LANGUAGE": "en-US",
"ASPNETCORE_ENVIRONMENT": "Development"
}
}, },
"group": { "group": {
"kind": "build" "kind": "test"
}, },
"isBackground": true, "isBackground": true,
"presentation": { "presentation": {
@ -82,7 +89,7 @@
], ],
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"runOptions": { "runOptions": {
} }
} }

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 ». item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. - `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 ## Conventions de code
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en 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 - Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`). (`Int32`, `String`).
- Préférer les expressions de pattern matching aux casts explicites. - 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 ## Branches & commits

View file

@ -1,5 +1,16 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<RootNamespace>Yavsc</RootNamespace> <RootNamespace>Yavsc</RootNamespace>
<!--
GitVersion.MsBuild is referenced as a build-time package from
every csproj under src/. Setting UseProjectNamespaceForGitVersionInformation
here (in Directory.Build.props) means each assembly exposes a
GitVersionInformation type under its own namespace, e.g.
PostIt.GitVersionInformation, Yavsc.GitVersionInformation, so
the assembly metadata reflects the source tree it was built
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -2,31 +2,30 @@
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<!--
Shared package versions: declared in two-or-more top-level products in src/.
Each product directory (src/<Product>/) has its own Directory.Packages.props
that <Import>s this file via GetPathOfFileAbove and adds the
product-specific versions. Adding a new shared package means editing this
file only; adding a product-local package means editing the per-product
Directory.Packages.props only.
-->
<ItemGroup> <ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.0.5-preview-net9" /> <PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.0.5-preview-net9" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" 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.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -46,10 +46,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source # (2) Tout le code source
COPY . . 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/v3/index.json --allow-insecure-connections
# (4) Restore # (4) Restore
RUN dotnet 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 # 4. Copie de l'intégralité du code source
COPY . . COPY . .
# 3. Restauration des dépendances avec vos workloads actifs
RUN dotnet nuget add source https://isn.pschneider.fr/v3/index.json --allow-insecure-connections
# 4. Restauration des dépendances pour tous les projets # 4. Restauration des dépendances pour tous les projets
RUN dotnet restore RUN dotnet restore

View file

@ -10,8 +10,8 @@ include .env
all: all:
dotnet build --nologo dotnet build --nologo
clean: clean:
dotnet clean dotnet clean -c $(CONFIG)
src/Yavsc/bin/output/wwwroot: src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
@ -31,7 +31,7 @@ src/Yavsc.Server/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.Server.dll:
src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll: src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll:
dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj
$(DESTDIR): $(DESTDIR):
mkdir $(DESTDIR) mkdir $(DESTDIR)
install: $(DESTDIR) install: $(DESTDIR)
@ -48,5 +48,77 @@ docker-build:
docker-run: docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc 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 # 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é. 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 # 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) * [![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)
@ -152,6 +162,13 @@ d'abord `appsettings-org.json` du serveur ; sinon, laisse-le en place.
(utilisateur, mot de passe, hôte, base). Privilégier (utilisateur, mot de passe, hôte, base). Privilégier
`dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*` `dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*`
plutôt qu'un mot de passe en clair dans le fichier. plutôt qu'un mot de passe en clair dans le fichier.
- Au démarrage, Yavsc.Org applique automatiquement ses migrations EF
Core. Sur cette base de code, EF Core 10 peut encore lever un
`PendingModelChangesWarning` malgré des migrations et snapshots déjà
alignés ; ce faux positif est ignoré sur les contextes PostgreSQL pour
éviter un démarrage inutilement en mode dégradé. Si une erreur de
migration apparaît encore en production, elle doit être traitée comme
une vraie divergence de schéma ou de connexion.
- `Smtp.*` — hôte, port, identifiants SMTP pour l'envoi d'e-mails - `Smtp.*` — hôte, port, identifiants SMTP pour l'envoi d'e-mails
transactionnels. transactionnels.
- `Authentication.PayPal.*` et `Authentication.Google.*` — clés d'API - `Authentication.PayPal.*` et `Authentication.Google.*` — clés d'API

View file

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

@ -0,0 +1,16 @@
info:
name: Get Posts
type: http
seq: 1
http:
method: GET
url: https://jsonplaceholder.typicode.com/users
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: This request retrieves a list of users from the JSONPlaceholder API.

View file

@ -0,0 +1,15 @@
info:
name: Untitled
type: http
seq: 1
http:
method: GET
url: ""
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,22 @@
info:
name: blog post
type: http
seq: 2
http:
method: POST
url: "{{Blogs}}/api/v1/blog"
body:
type: json
data: |-
{
"Title": "lkijlk",
"Article": "test"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

15
contrib/bruno/blogs.yml Normal file
View file

@ -0,0 +1,15 @@
info:
name: blogs
type: http
seq: 1
http:
method: GET
url: "{{Blogs}}/api/v1/blog"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,6 @@
name: Development
variables:
- name: Blogs
value: https://localhost:5003
- name: Authority
value: https://localhost:5001

View file

@ -0,0 +1,6 @@
name: Production
variables:
- name: Authority
value: https://yavsc.pschneider.fr
- name: Blogs
value: https://blogs.pschneider.fr

View file

@ -0,0 +1,43 @@
opencollection: 1.0.0
info:
name: blogs
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
request:
auth:
type: oauth2
flow: authorization_code
authorizationUrl: "{{Authority}}/connect/authorize"
accessTokenUrl: "{{Authority}}/connect/token"
refreshTokenUrl: https://yavsc.pschneider.fr/connect/token
callbackUrl: "{{Authority}}"
credentials:
clientId: postit
placement: basic_auth_header
scope: openid blogs profile
pkce: {}
tokenConfig:
id: credentials
placement:
header: Bearer
source: access_token
settings:
autoFetchToken: true
autoRefreshToken: true
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git

View file

@ -15,7 +15,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme | | [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme |
| [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter |
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
## Roadmap & design exploration ## Roadmap & design exploration

View file

@ -31,7 +31,19 @@
└────────────────┘ └────────────────┘
Clients externes : Clients externes :
- PostIt : client desktop Avalonia (cf. postit-oidc.md). - PostIt (Avalonia, code-base unique multi-cible) :
· PostIt — lib partagée (pages, VM, services)
· PostIt.Desktop — front-end Linux/Windows
· PostIt.Android — front-end APK
· PostIt.Browser — front-end WASM
Cf. postit.md et postit-oidc.md.
Outils et tests :
- cli — outillage CLI
- Yavsc.Tests.Shared — helpers de tests partagés
- Yavsc.Org.Tests — tests du front web
- Yavsc.Blogs.Tests — tests du backend blogs
- PostIt.Tests — tests du client PostIt
``` ```
## Par projet ## Par projet
@ -44,6 +56,14 @@ Clients externes :
| `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. | | `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. |
| `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. | | `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. |
| `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. | | `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. |
| `Yavsc.Blogs.Tests`| Test (xUnit) | Tests d'isolation du backend blogs (`Yavsc.Blogs`). |
| `Yavsc.Tests.Shared` | Library | Helpers de tests partagés (fixtures, fakes, builders) entre les projets de tests. |
| `PostIt` | Library | Code-base partagée du client PostIt (Avalonia) : pages, ViewModels, services, `ViewLocator` custo. Multi-cible — produit PostIt.Desktop / PostIt.Android / PostIt.Browser. |
| `PostIt.Desktop` | Avalonia.Desktop | Front-end Desktop Linux/Windows : `Program.Main`, `Platform.CreateBrowser` (CustomSchemeBrowser), custom URI scheme `postit://`. |
| `PostIt.Android` | Avalonia.Android | Front-end Android : `MainActivity` SingleTask, Chrome Custom Tabs, scheme `android://postit-signin`. |
| `PostIt.Browser` | Avalonia.Browser | Front-end WASM : pas de process distinct, IBrowser N/A. |
| `PostIt.Tests` | Test (xUnit) | Tests du client PostIt : settings, scopes Bearer, OIDC stub (`OidcStubAuthority`). |
| `cli` | exe / tool | Outillage CLI (build, packaging, génération de clés). |
## Pourquoi ce découpage ## Pourquoi ce découpage

View file

@ -56,6 +56,7 @@ pas vers un serveur HTTP.
|---------------------------------|-------------------------------------------------------------------| |---------------------------------|-------------------------------------------------------------------|
| `Services/OidcLoginPhase` | Enum des étapes du flow : `Idle / Discovering / OpeningBrowser / AwaitingCallback / ExchangingCode / Success / Error` | | `Services/OidcLoginPhase` | Enum des étapes du flow : `Idle / Discovering / OpeningBrowser / AwaitingCallback / ExchangingCode / Success / Error` |
| `Services/YavscApiClient` | Client HTTP de l'API Yavsc. Porte `LoginInteractiveAsync(IProgress<OidcLoginPhase>)` et `TrySilentLoginAsync`. Refresh silencieux sur 401 et sur access-token bientôt expiré. | | `Services/YavscApiClient` | Client HTTP de l'API Yavsc. Porte `LoginInteractiveAsync(IProgress<OidcLoginPhase>)` et `TrySilentLoginAsync`. Refresh silencieux sur 401 et sur access-token bientôt expiré. |
| `Services/BlogApiClient` | Mapper DTO↔path pour la sous-API blog. **Note** : `pathPrefix` est *relatif* à `/api/v1/` (que porte déjà `BaseAddress`) — ex. `"blog"` pour matcher `[Route(APIPrefix + "/blog")]`. Ne pas ré-inclure `api/`. |
| `Services/SingleInstance` | Named-pipe helper. `TryHandOffAsync` côté 2ᵉ instance, `StartServerAsync` côté instance vivante. | | `Services/SingleInstance` | Named-pipe helper. `TryHandOffAsync` côté 2ᵉ instance, `StartServerAsync` côté instance vivante. |
| `Services/CustomSchemeBrowser` | `IBrowser` OidcClient qui ouvre le système + attend le pipe. | | `Services/CustomSchemeBrowser` | `IBrowser` OidcClient qui ouvre le système + attend le pipe. |
| `Services/SchemeUrlDetector` | Détection pure, testable, du `postit://callback` dans argv. | | `Services/SchemeUrlDetector` | Détection pure, testable, du `postit://callback` dans argv. |

282
doc/architecture/postit.md Normal file
View file

@ -0,0 +1,282 @@
# PostIt — Topologie, navigation, DI
> **Récapitulatif** : PostIt est le client Avalonia du projet
> Yavsc. C'est un code-base unique (`src/PostIt/PostIt/PostIt.csproj`)
> **multi-cible** vers trois front-ends distincts
> (`PostIt.Desktop`, `Postit.Android`, `PostIt.Browser`). Cette
> fiche couvre la topologie des projets, le DI, le `ViewLocator`
> custo et la navigation — c'est-à-dire tout ce que la fiche
> [postit-oidc.md](postit-oidc.md) ne détaille pas déjà (l'OIDC,
> le flow d'auth, la persistance des tokens). Détail dans cette
> page, racine de l'architecture : [Architecture.md](../Architecture.md).
## Surface : un code-base, trois front-ends
```
┌────────────────────────┐
│ PostIt (lib) │
│ src/PostIt/PostIt/ │
│ Pages, ViewModels, │
│ Services, ViewLocator │
│ (aucun rendu natif) │
└──────┬───┬─────┬───────┘
│ │ │
┌───────────────┘ │ └────────────────┐
│ │ │
┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐
│ PostIt.Desktop │ │ PostIt.Android │ │ PostIt.Browser │
│ Avalonia.Desktop │ │ Avalonia.Android │ │ Avalonia.Browser │
│ Linux/Windows │ │ APK │ │ WASM │
│ + custom scheme │ │ + Chrome Custom │ │ (no native proc) │
│ postit:// │ │ Tabs │ │ │
│ + IBrowser custo │ │ + IBrowser custo │ │ │
└───────────────────┘ └──────────────────┘ └───────────────────┘
```
Le code partagé vit dans `PostIt/`. Chaque front-end est un
**projet Satellite SDK** Avalonia qui ne contient que le
`Program.Main`, le `Platform.CreateBrowser`, et les manifestes
spécifiques (IntentFilter Android, `app.manifest` Desktop).
Toute la logique (VM, services, navigation, settings, OIDC) est
dans le code-base partagé.
## ViewLocator custo
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
`IDataTemplate` Avalonia **explicitement câblé sur le
`IServiceProvider`** :
```csharp
public Control Build(object? data) => data switch
{
MainPageViewModel => _services.GetRequiredService<MainPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
public bool Match(object? data) => data is ViewModelBase;
```
**Pourquoi un custo, et pas le `ViewLocatorBase` par défaut
d'Avalonia.Mvvm ?** Pour deux raisons :
1. **Sortie du `Activator.CreateInstance`** — les pages
PostIt sont enregistrées dans le DI et peuvent avoir des
dépendances (par construction, aujourd'hui aucune, mais
l'extension future est ouverte). Le `ViewLocatorBase`
historique fait `new View()`, ce qui rend impossible
l'injection et complique les tests.
2. **Filtrage par `ViewModelBase`**`Match` n'accepte que les
types dérivés de `ViewModelBase`. Toute tentative d'afficher
un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le
`TextBlock` "No view for X", pas sur un crash Avalonia.
Le `ViewLocator` est ajouté aux `DataTemplates` de l'app dans
`App.OnFrameworkInitializationCompleted` :
```csharp
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider));
```
**Conséquence pratique** : pour qu'une nouvelle page soit
affichée par un `ContentControl` qui binde un ViewModel, il
faut *deux* enregistrements : la page en `AddTransient` (ou
`AddSingleton`) dans le DI, **et** une case dans le `switch`
de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash.
## Composition root (`App.axaml.cs`)
`App.OnFrameworkInitializationCompleted` est le seul endroit où
le DI est construit. Ordre, dans cet ordre :
1. `new Settings()` + `settings.Load()` — lit
`~/.config/PostIt/postit-settings.json` (ou le fallback
embarqué dans `PostIt.dll`).
2. `new TokenStore(...)` + `new YavscApiClient(settings, tokenStore)`.
3. `new ServiceCollection()` + enregistrements en bloc.
4. `services.BuildServiceProvider()`.
5. `Settings.BindToServiceProvider(provider)` — pose le
singleton statique pour les helpers hors-DI
(`Settings.GetCurrent()`, `Settings.RequireCurrent()`).
6. `DataTemplates.Add(new ViewLocator(provider))`.
7. Branche `IClassicDesktopStyleApplicationLifetime` /
`ISingleViewApplicationLifetime` (Browser/Android).
### Enregistrements DI
| Service | Lifetime | Pourquoi |
|-------------------------------|------------|-------------------------------------------------------------------------------------------|
| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. |
| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. |
| `BlogApiClient` | Singleton | Mapper stateless, partagé. |
| `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). |
| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. |
| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. |
| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). |
> **Invariant** : `Settings` est **uniquement** un singleton. Un
> `AddTransient<Settings>()` supplémentaire (qui réécrase le
> singleton dans le container) ferait que chaque push de
> `SettingsPage` crée une instance vide, casse les bindings
> Authority/ClientId, et perd toute édition. Si tu dois toucher
> à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
posé sur `MainWindow.axaml`. La pile est gérée par deux
mécanismes distincts :
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. La garde est implémentée
dans `App.PushPageAsync` (et consommée par tous les chemins
de nav utilisateur) :
```csharp
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return Task.CompletedTask; // déjà au sommet, no-op silencieux
}
return window.NavRoot.PushAsync(page);
```
La comparaison est par référence, pas par type : on ne veut
empêcher qu'un push de *cette* instance particulière, pas
celui d'une éventuelle autre `SettingsPage` (il n'en existe
qu'une, mais l'invariant est plus clair comme ça). La garde
repose sur le fait que `SettingsPage` est un singleton ; si on
repassait en `Transient`, `ReferenceEquals` resterait correct
mais la pertinence de la garde s'évaporerait (chaque push
apporterait une nouvelle instance et l'anti-empilement
reposerait sur l'invariant « la même est déjà au sommet »,
qui ne tiendrait plus).
## ViewModels et invariants d'état
- `Settings` est un objet-modèle exposé comme `DataContext`
des pages. Il n'hérite pas de `ViewModelBase` (c'est un
POCO `[ObservableProperty]`-généré par
`CommunityToolkit.Mvvm`). Le fait qu'il soit utilisé comme
DataContext est un raccourci de composition acceptable ici,
pas un pattern à généraliser.
- `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 é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
instance est créée à chaque push, l'ancienne est libérée
quand la page est dépilée. Pas d'état partagé entre
occurrences ; pour passer une donnée d'une page à l'autre,
on passe par un singleton (souvent `YavscApiClient` ou
`Settings`).
## Bindings XAML : conventions de nommage
Pour les `[RelayCommand]` (cf. `CommunityToolkit.Mvvm`), le
binding XAML reprend **le nom exact de la méthode, sans
suffixe** :
| Méthode C# | Binding XAML |
|-----------------------|-----------------------------|
| `Save()` | `{Binding Save}` |
| `SaveAsync()` | `{Binding SaveAsync}` |
| `LoginCommand()` | `{Binding LoginCommand}` (nom littéral, *pas* de suffixe ajouté) |
| `Clear()` | `{Binding Clear}` |
| `CaptureAsync()` | `{Binding CaptureAsync}` |
**JAMAIS** `SaveCommand`, `SaveCmd`, `DoSave`, etc. Le source
generator `[RelayCommand]` émet une propriété `ICommand` du
même nom que la méthode. Un binding qui pointe vers une
propriété inexistante casse l'app au moment du câblage (le
bouton ne se câble pas, et selon la version ça peut faire
planter l'init de la page).
Référence canonique : `AGENTS.md`, section
"Avalonia + CommunityToolkit.Mvvm : conventions de binding
pour `[RelayCommand]`".
## Pages et leurs rôles
| Page | DataContext | Rôle |
|----------------------------|--------------------------|-----------------------------------------------------------------------|
| `MainWindow` | `HomePageViewModel` (initial) | Host de la `NavigationPage`. |
| `SessionStatusBanner` | `SessionStatusViewModel` | Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres. |
| `HomePage` | `HomePageViewModel` | Page d'accueil publique. |
| `MainPage` | `MainPageViewModel` | Éditeur de post de blog (après login). |
| `SignaturePage` | `SignaturePageViewModel` | Capture de signature (estimateur). |
| `SettingsPage` | `Settings` | Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via `Save` (RelayCommand). |
## Conséquences pratiques
- **Ajouter une page** : créer la View + le ViewModel +
enregistrer les deux dans le DI **et** dans le `switch` de
`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") : 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.
- **Modifier les `Settings`** : ne pas casser le singleton
(cf. invariant ci-dessus). Toute propriété présentationnelle
ajoutée (par ex. `ScopeListText`) doit porter `[JsonIgnore]`
pour ne pas polluer le format sur disque.
## Voir aussi
- [Architecture.md](../Architecture.md) — racine.
- [postit-oidc.md](postit-oidc.md) — flow OIDC, custom scheme,
silent refresh, persistance des tokens.
- [decoupage-organisation.md](decoupage-organisation.md) —
place de `PostIt` dans le découpage global des projets
.NET du repo.

View file

@ -1,278 +0,0 @@
# Client editor overhaul — Yavsc.Org administration
## Goal
Bring the OAuth2 client administration UI (`/Client/Edit/{id}` and friends)
in Yavsc.Org to feature parity with the IdentityServer8 `Client` entity
model. Today the editor only exposes a handful of scalar fields and a few
single-line inputs for collections; the bulk of the entity and its
related collections are unreachable from the UI.
## Inventory — current state
### Properties exposed by `Views/Client/Edit.cshtml`
| Field | Type | Notes |
| ------------------------ | ----------- | ---------------------------------- |
| `ClientId` | string | hidden, identifier |
| `Enabled` | bool | checkbox |
| `ClientName` | string | display name |
| `FrontChannelLogoutUri` | string | only front-channel, no back-channel |
| `RedirectUris` | collection | rendered as a single text input |
| `IdentityTokenLifetime` | int | seconds |
| `AbsoluteRefreshTokenLifetime` | int | seconds |
| `ClientSecrets` | collection | rendered as a single text input |
| `AccessTokenType` | enum | dropdown (custom `SetAppTypesInputValues`) |
### Properties of `IdentityServer8.EntityFramework.Entities.Client` **NOT** in the editor
Core scalars (16 fields missing):
- `Description`
- `ClientUri`
- `LogoUri`
- `RequireConsent`
- `RequirePkce`
- `RequireRequestObject`
- `RequireClientSecret`
- `AllowPlainTextPkce`
- `AllowOfflineAccess`
- `AllowRememberConsent`
- `AlwaysIncludeUserClaimsInIdToken`
- `AlwaysSendClientClaims`
- `AuthorizationCodeLifetime`
- `BackChannelLogoutUri`
- `BackChannelLogoutSessionRequired`
- `CibaLifetime`
- `ClientClaimsPrefix`
- `ConsentLifetime`
- `Created`
- `DeviceCodeLifetime`
- `EnableLocalLogin`
- `Enabled`
- `FrontChannelLogoutSessionRequired`
- `IncludeJwtId`
- `LastAccessed`
- `LogoUri`
- `NonEditable`
- `PairwiseSubjectSalt`
- `PollingInterval`
- `ProtocolType`
- `RefreshTokenExpiration`
- `RefreshTokenUsage`
- `SlidingRefreshTokenLifetime`
- `UpdateAccessTokenClaimsOnRefresh`
- `Updated`
- `UserCodeType`
- `UserSsoLifetime`
Collections (8 missing — currently either not exposed at all, or jammed
into a single-line text input that doesn't work for an IEnumerable):
- `AllowedGrantTypes``ClientGrantType` (GrantType)
- `AllowedScopes``ClientScope` (Scope)
- `RedirectUris``ClientRedirectUri` (RedirectUri) — exposed but broken
- `PostLogoutRedirectUris``ClientPostLogoutRedirectUri` (PostLogoutRedirectUri)
- `AllowedCorsOrigins``ClientCorsOrigin` (Origin)
- `IdentityProviderRestrictions``ClientIdPRestriction` (Provider)
- `Claims``ClientClaim` (Type, Value)
- `Properties``ClientProperty` (Key, Value)
- `ClientSecrets``ClientSecret` (Type, Value, Description, Created, Expiration) — exposed but broken
- `AllowedSigningAlgorithms` → scalar string collection on Client itself
## Pages to add
Pattern: one Razor page per collection under
`Views/Client/Edit{Collection}.cshtml`. Each page lists existing rows,
offers an "Add" form with the relevant fields, and a per-row
remove button. The main `Edit.cshtml` becomes a hub page with links
to each subpage plus the scalar fields it already has.
| Page | Route | Form fields |
| ------------------------------------- | ------------------------------------------ | ------------------------------------------------- |
| `Edit.cshtml` | `GET /Client/Edit/{id}` (existing) | scalar fields + nav links |
| `EditRedirectUris.cshtml` | `GET /Client/EditRedirectUris/{id}` | `RedirectUri` |
| `EditPostLogoutRedirectUris.cshtml` | `GET /Client/EditPostLogoutRedirectUris/{id}` | `PostLogoutRedirectUri` |
| `EditScopes.cshtml` | `GET /Client/EditScopes/{id}` | `Scope` (with select of known scopes) |
| `EditGrantTypes.cshtml` | `GET /Client/EditGrantTypes/{id}` | `GrantType` (with select of known types) |
| `EditCorsOrigins.cshtml` | `GET /Client/EditCorsOrigins/{id}` | `Origin` |
| `EditIdPRestrictions.cshtml` | `GET /Client/EditIdPRestrictions/{id}` | `Provider` |
| `EditClaims.cshtml` | `GET /Client/EditClaims/{id}` | `Type`, `Value` |
| `EditProperties.cshtml` | `GET /Client/EditProperties/{id}` | `Key`, `Value` |
| `EditSecrets.cshtml` (replacement) | `GET /Client/EditSecrets/{id}` | `Type`, `Value`, `Description`, `Expiration` |
Partial view `_EditableList.cshtml` factored once and consumed by all
of the above.
## Controller actions to add
For each collection `Foo`:
- `GET EditFoo(int id)` — load the client, render the page
- `POST AddFoo(int id, …)` — append a row, redirect to `EditFoo`
- `POST RemoveFoo(int id, int rowId)` — delete a row, redirect
## Verification
- `dotnet build src/Yavsc.Org/Yavsc.Org.csproj` → 0 errors
- No tests in `Yavsc.Org.Tests` exercise the controller today (per
`find … -name "ClientController*" -not -path "*/bin/*"`). Smoke-test
by logging in as admin, hitting `/Client/Edit/1`, then each
`Edit*/1` page, and verifying the add/remove POSTs.
- Existing seed flow (`MigratePostItClientToPublic` in
`HostingExtensions.cs`) must keep working — the editor changes are
additive, not destructive.
## Out of scope
- Tests (no MVC test infrastructure currently exists for this controller)
- Migration of existing collection fields (the broken `RedirectUris`
text input will simply be replaced by the new subpage)
- Per-collection authorization policies (the controller is already
`[Authorize("AdministratorOnly")]`)
- Client cloning / templating / JSON import-export
## Status
2026-06-21 16:04 — kickoff. Inventory done. Pages not yet started.
2026-06-21 16:11 — first delivery, **build does not compile by design**
(per Paul: "Tu peux même me laisser un travail qui ne compile
pas"). The structural work is done; the residual errors are easy
fixes Paul will do in a debug session.
Files added (working tree, not yet committed):
- `src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs`
— partial class with the per-collection GET / Add / Remove actions.
- `src/Yavsc.Org/Views/Client/EditRedirectUris.cshtml`
- `src/Yavsc.Org/Views/Client/EditPostLogoutRedirectUris.cshtml`
- `src/Yavsc.Org/Views/Client/EditScopes.cshtml`
- `src/Yavsc.Org/Views/Client/EditGrantTypes.cshtml`
- `src/Yavsc.Org/Views/Client/EditCorsOrigins.cshtml`
- `src/Yavsc.Org/Views/Client/EditIdPRestrictions.cshtml`
- `src/Yavsc.Org/Views/Client/EditClaims.cshtml`
- `src/Yavsc.Org/Views/Client/EditProperties.cshtml`
- `src/Yavsc.Org/Views/Client/EditSecrets.cshtml`
- `src/Yavsc.Org/Views/Client/_EditableStringList.cshtml`
— partial consumed by the single-string-field collection pages.
Files modified:
- `src/Yavsc.Org/Controllers/Administration/ClientController.cs`
`class``partial class`; the `Edit(int id)` GET now uses
`LoadClientAsync` to load all navigations (so the new Edit.cshtml
can render counts in its nav links).
- `src/Yavsc.Org/Views/Client/Edit.cshtml`
— significantly enriched: nav links to the 9 sub-pages, all the
scalar fields split into fieldsets (Security, Logout, Tokens,
Device / CIBA, Tokens-extra), ClientId / Id hidden.
### Known residual compile errors (4 errors total)
Paul is fixing these in a debug session. The structure is sound; the
errors are missing properties on the `Client` entity, a Razor
nullable quirk, and a `Localizer` injection miss.
1. `Edit.cshtml:249``PairwiseSubjectSalt` doesn't exist on
`IdentityServer8.EntityFramework.Entities.Client`. **Fix**: drop
the field from Edit.cshtml; IdentityServer8 likely uses a
different property name (e.g. on a related entity) or doesn't
expose it.
2. `Edit.cshtml:221``CibaLifetime` doesn't exist on `Client`.
**Fix**: same as above. CIBA flow may be configured elsewhere
(resource-level) or via a different property.
3. `ClientController.Collections.cs` lines 181, 217, 253, 304 —
`Localizer` is not available in the partial class. **Fix**: inject
`IStringLocalizer<ClientController>` via the constructor, or
inline the strings ("BothTypeAndValueRequired", "KeyRequired",
"ValueRequired", "SecretValueRequired").
4. `EditSecrets.cshtml:44``s.Expiration?.ToString("u")` on a
`DateTime?`. **Fix**: just `s.Expiration?.ToString("u")` works
if you write `s.Expiration.Value.ToString("u")`, or use
`(s.Expiration is null ? "" : s.Expiration.Value.ToString("u"))`,
or `s.Expiration?.ToString("u") ?? string.Empty`.
### Suggested next session
Once the 4 compile errors are fixed and the pages render:
1. Smoke test by logging in as admin, hitting `/Client/Edit/1`,
then each `Edit*/1` page, and verifying add/remove POSTs.
2. Add a confirmation prompt (or 2-step form) for Remove actions —
removing a Redirect URI is destructive and one click is too easy.
3. Wire up some collection-level validation (e.g. redirect URI must
be a valid URL) at the controller level.
4. Add tests — the project doesn't have MVC test infrastructure
today; consider adding a `Yavsc.Org.Tests` project that drives
the controller via `WebApplicationFactory<Program>`.
## Test bootstrap notes (session of 2026-06-21 17:00+)
When adding new integration tests against `WebServerFixture`:
1. **Skip `/Account/Login` roundtrip.** The fixture ships without
`MapRazorPages()` (commented out in `HostingExtensions.ConfigurePipeline`),
so `/Identity/Account/Login` is 404, and the custom
`/Account/Login` route requires a complex antiforgery dance.
Instead, build a `ClaimsPrincipal` for the test user via
`UserManager` + `IUserClaimsPrincipalFactory<ApplicationUser>`,
then call `IAuthenticationService.SignInAsync` on a synthetic
`DefaultHttpContext` and replay the resulting `Set-Cookie` header
into the test `HttpClient`. See
`ClientControllerCollectionTests.IssueIdentityCookie`.
2. **Create the `Administrator` role before assigning it.** ASP.NET
Identity stores roles in `AspNetRoles`; there is no automatic seed.
The constant name is `YavscConstants.AdminGroupName` = `"Administrator"`.
Use `RoleManager<IdentityRole>.CreateAsync(new IdentityRole("Administrator"))`
before `AddToRoleAsync`.
3. **Use `InMemory` connection string to bypass the prod signing-cert
requirement.** `HostingExtensions.AddIdentityServer` requires a
PEM cert unless `builder.Environment.IsDevelopment()` OR
`UsesInMemoryProvider(connectionString)`. The fixture already
uses `InMemory`, so `AddDeveloperSigningCredential()` is called
automatically — but only after we wired this check in (see
commit history).
4. **Field-name gotchas** (from disassembling HigginsSoft
IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9):
- `PairWiseSubjectSalt` (capital W on "Wise"), not `PairwiseSubjectSalt`.
- `CibaLifetime` and `PollingInterval` do NOT exist on `Client` in
this version.
- `ConsentLifetime` and `UserSsoLifetime` are `int?`.
5. **`MapStaticAssets()` fails on test projects.** Calling
`MapStaticAssets()` resolves a manifest file
(`<project>.staticwebassets.endpoints.json`) that test projects
don't produce. Skip when `WebRootPath` points at the test
assembly directory.
6. **Routing 404 on /Client/Edit/{id} via WebServerFixture.** As of
this session, the GET endpoint returns 404 even with admin
header. The route mapping is intact
(`MapDefaultControllerRoute()`), so this is likely an MVC
convention routing issue with the
`Controllers/Administration/` subdirectory. To investigate
next session: log middleware pipeline or hit `/Client` index
first to see if any Client route resolves.
7. **`MapStaticAssets()` is unconditional in prod, but blocks tests.**
`WebApplication.CreateBuilder` defaults `ContentRootPath` to
`AppContext.BaseDirectory`. In test runs that resolves to
`src/Yavsc.Org.Tests/bin/Debug/net10.0/`, where
`Yavsc.Org.Tests.staticwebassets.endpoints.json` doesn't exist
(it's generated only by projects with the Web SDK). The
`app.MapStaticAssets()` call inside `ConfigurePipeline` then
throws and the fixture fails to start — taking every test in
the `[Collection("Yavsc Server")]` down with it.
This is a pre-existing fragility of the WebServerFixture that
the new test work surfaced. Fixing it cleanly requires either:
(a) moving the test project to the Web SDK so it produces its
own manifest, (b) copying the manifest at build time via an
MSBuild target, or (c) routing `MapStaticAssets` through an
assembly-resolution fallback. None attempted in this session —
recorded for next session.

88
doc/testing.md Normal file
View file

@ -0,0 +1,88 @@
# Stratégie de test
Yavsc utilise **xUnit** (`xunit.v3`) avec un mix d'unitaire pur
et d'intégration légère. Les projets de tests sont sous
`src/<projet>.Tests/` et consomment le scaffold partagé
`src/Yavsc.Tests.Shared/`.
## Vue d'ensemble
| Sujet | Document |
|---|---|
| Scaffold partagé (`WebHostFixture`, JWT de test, etc.) | [src/Yavsc.Tests.Shared/README.md](../src/Yavsc.Tests.Shared/README.md) |
| Convention des dossiers de tests | [Conventions](#conventions-des-dossiers-de-tests) |
| Driver EF Core en test | [EF Core en test](#ef-core-en-test) |
| Stubs d'authentification et de permissions | [Auth et permissions](#auth-et-permissions) |
## Conventions des dossiers de tests
Sous `src/<projet>.Tests/`, on trouve quatre dossiers de premier
niveau qui classifient les tests par intention :
| Dossier | Usage |
|---|---|
| `NonRegression/` | Régressions : un bug constaté, un test qui le détecte si on le réintroduit |
| `Mandatory/` | Tests bloquants : ils doivent passer avant tout merge |
| `Smoke/` | Smoke tests HTTP rapides, montent un host léger |
| `Controllers/` | Tests unitaires des contrôleurs (mock du service, assertions sur le mapping HTTP) |
Les `NonRegression` sont la cible par défaut quand on fixe un
bug : ils doivent être **rouges avant le fix, verts après**, et
continuer à **casser** si quelqu'un revert le fix. Pas de test
qui passe à vide.
## EF Core en test
Pour les tests qui ont besoin d'un `ApplicationDbContext`, on
utilise **`UseInMemoryDatabase`** avec un `InMemoryDatabaseRoot`
partagé au niveau de la fixture. Pas de SQLite, pas de Docker,
pas de mock du contexte : le service testé s'exécute contre
un vrai `DbContext` sur in-memory.
```csharp
private static readonly InMemoryDatabaseRoot _dbRoot = new();
var opts = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase("Yavsc.Org.Tests.MyFixture", _dbRoot)
.Options;
```
Le `InMemoryDatabaseRoot` partagé est important : sans lui, EF
crée un store indépendant par `DbContext` dans certaines
configurations, et un test qui seed + read sur deux contextes
voit un store vide. Le pattern est documenté dans
`BlogsWebServerFixture` ([src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs](../src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs)).
> **Limite connue** : le provider in-memory **ignore** les
> `Migration` EF et ne respecte pas les FK **sur les raw
> SQL** (`ExecuteSqlRaw`). Pour tester des contraintes FK, on
> écrit la configuration dans `OnModelCreating` et on s'appuie
> sur le fait qu'EF la respecte à l'`Add`/`SaveChanges`. Pour
> tester des migrations, c'est l'environnement de staging.
## Auth et permissions
L'authorization policy provider de prod est swappé contre
`TestAuthPolicyProvider` (dans `Yavsc.Tests.Shared`) par les
fixtures spécialisées. Les tests qui ont besoin qu'un user soit
"Administrator" envoient un header `X-Test-Rôle` ; ceux qui
veulent un user anonyme omettent le header.
Pour les tests unitaires qui n'ont pas besoin du pipeline
HTTP, on stub `IAuthorizationService` directement (cf.
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
pour éviter de monter un host complet.
## Quand ne PAS écrire de test
Un test qui ne détecte rien n'est pas un test. Si l'invariant
qu'on cherche à protéger est déjà enforced par EF, par le
compilateur, ou par une couche applicative en amont, le test
est du bruit. Mieux vaut :
- Un test qui assert un **comportement observable** (code
retour HTTP, exception typée, valeur de retour)
- Ou pas de test, et une note dans le code
La non-régression se prouve par un test qui casse si on
réintroduit le bug. Pas par un test qui passe aujourd'hui et
qui continuera à passer après un revert.

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

@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
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;
namespace PostIt.Tests;
/// <summary>
/// Diagnostic coverage for the 401 we're seeing in production when
/// PostIt talks to <c>Yavsc.Blogs</c>. The hypothesis this file
/// isolates: "the access token sent on the wire is missing the
/// <c>blogs</c> scope that <c>Yavsc.Blogs</c>'s <c>BlogScope</c>
/// policy requires". The policy lives in
/// <c>Yavsc.Blogs/Program.cs</c> as
/// <c>RequireClaim(JwtClaimTypes.Scope, "blogs")</c>.
///
/// <para>
/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
/// any network listener. The test fakes a single
/// <see cref="HttpMessageHandler"/> that captures the outbound
/// request, deserialises the bearer JWT, and asserts the
/// <c>scope</c> claim contains the segment the policy needs. This
/// pins the client side of the contract so a future regression in
/// <see cref="YavscApiClient"/> or <see cref="Settings"/> (e.g. a
/// silently dropped scope, a wrong merge order, a scope string
/// that no longer matches the server policy) trips the test before
/// it reaches production.
/// </para>
/// </summary>
public class BearerScopeTests
{
/// <summary>
/// Hard-coded <c>blogs</c> scope string. Mirrors the value in
/// <c>Yavsc.Blogs/Program.cs</c>'s <c>BlogScope</c> policy; if
/// the server ever moves to <c>"blog.read"</c> or similar this
/// constant should be updated to match.
/// </summary>
private const string RequiredScope = "blogs";
[Fact]
public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt()
{
// Build the exact scope list a user would have in
// postit-settings.json. MergeScopes (called inside
// YavscApiClient when issuing the authorize request) would
// have appended "openid profile offline_access", so the
// access token in real life carries all of them. The test
// pins that the scope the *server* needs survived the
// round trip from settings.json to the access_token.
var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope };
var scopeInAccessToken = string.Join(' ', userScopes);
// Mint a fake access token whose only payload claim is
// "scope". No signature: the client never verifies, and the
// production server doesn't see this token (we mock the
// HttpMessageHandler, so the message never leaves the
// process).
var accessToken = MintUnsignedJwt(scopeInAccessToken);
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.invalid",
ClientId = "postit-tests",
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json");
try
{
// Pre-seed the token store so YavscApiClient believes
// it has a valid session and CallAsync does not refuse
// to send.
var store = new TokenStore(tokensPath);
store.Save(new RefreshTokenRecord(
AccessToken: accessToken,
RefreshToken: "irrelevant-for-this-test",
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
IdToken: null));
// CapturingHttpHandler is the assertion point. It
// records the first request's Authorization header and
// returns 200 with an empty array (BlogApiClient
// deserialises to List<BlogPostDto>).
var captured = new CapturingHttpHandler();
var client = new YavscApiClient(
settings,
store,
// Bypass OidcClient construction (it would try to
// resolve an Authority we don't have a real IdP
// for). The handler we inject below is what the
// bearer attaches the token to; refresh paths are
// not exercised in this test.
oidc: null!);
// YavscApiClient builds its own HttpClient around a
// BearerTokenHandler(new HttpClientHandler()) in its
// constructor; the handler is not exposed for
// replacement. The seam we use: CallAsync is virtual,
// so a subclass that talks to a caller-supplied
// HttpMessageHandler lets us assert on the outbound
// request without standing up any server.
var subClient = new TestableYavscApiClient(
settings, store, captured, accessToken);
// 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, "http://localhost/");
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
// The test only makes sense if we did capture
// something. If we got here with an empty capture, the
// BlogApiClient chose a non-HTTP path and this whole
// setup is wrong.
Assert.NotNull(captured.Authorization);
Assert.StartsWith("Bearer ", captured.Authorization);
var jwt = captured.Authorization.Substring("Bearer ".Length).Trim();
var scopes = ExtractScopes(jwt);
Assert.Contains(RequiredScope, scopes);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
// --- helpers -------------------------------------------------------
/// <summary>
/// Build an unsigned JWT carrying a single <c>scope</c> claim.
/// Mirrors the read-only fallback in
/// <see cref="YavscApiClient.ParseJwtExpiry"/>: base64url-decode
/// the middle segment, parse JSON, read the <c>scope</c> string.
/// The header and signature are placeholders — nobody in the
/// test path verifies the signature.
/// </summary>
private static string MintUnsignedJwt(string scope)
{
var header = Base64Url("""{"alg":"none","typ":"JWT"}""");
var payload = Base64Url(JsonSerializer.Serialize(new
{
sub = "test-user",
iss = "https://example.invalid",
aud = "postit",
exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(),
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
scope,
}));
return $"{header}.{payload}.";
}
private static string Base64Url(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Pull the <c>scope</c> claim out of a (possibly unsigned) JWT
/// and split on whitespace, the canonical encoding per RFC 8693
/// §4.2 and OpenID Connect Core 1.0 §5.1.
/// </summary>
private static IReadOnlyCollection<string> ExtractScopes(string jwt)
{
var parts = jwt.Split('.');
Assert.True(parts.Length >= 2, "JWT must have a payload segment");
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
if (!doc.RootElement.TryGetProperty("scope", out var scopeEl))
{
return Array.Empty<string>();
}
var raw = scopeEl.GetString() ?? string.Empty;
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>
/// Minimal <see cref="HttpMessageHandler"/> that records the
/// first request's <c>Authorization</c> header and replies 200
/// with an empty JSON array. Anything beyond the first request
/// is a regression in the test setup, not the production code
/// path under test.
/// </summary>
private sealed class CapturingHttpHandler : HttpMessageHandler
{
public string? Authorization { get; private set; }
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Authorization = request.Headers.Authorization?.ToString();
RequestUri = request.RequestUri;
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"/>. The base ctor wires
/// <c>Http</c> as <c>new HttpClient(BearerTokenHandler(...))</c>;
/// we don't replace that — we override the public call seam
/// <see cref="YavscApiClient.CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>
/// (declared <c>virtual</c>) and talk to our own HttpClient
/// from there. The <c>EnsureFreshToken</c> / 401-retry path
/// is intentionally not exercised here — that lives in
/// <c>YavscApiClientTests</c>; isolating the bearer
/// attachment is the whole point of this test.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
private readonly string _accessToken;
public TestableYavscApiClient(
PostIt.ViewModels.Settings settings,
TokenStore store,
HttpMessageHandler handler,
string accessToken)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
_accessToken = accessToken;
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
// Reproduce just enough of the production request
// shape: a real HttpRequestMessage with the bearer
// attached, so the assertion in the test is faithful.
// We skip the EnsureFreshToken/401-retry machinery on
// purpose — that path is already covered by
// YavscApiClientTests, and isolating the bearer
// attachment is exactly what this test exists for.
//
// The base YavscApiClient relies on HttpClient.BaseAddress
// being set by BlogApiClient's ctor; in this test our
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
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!);
}
}
}

View file

@ -0,0 +1,66 @@
using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Models;
namespace PostIt.Tests;
/// <summary>Per-call ledger shared between the test and the
/// recording fake, so the assertion can inspect what the VM
/// actually sent on the wire without coupling to the fake's
/// internals.</summary>
internal sealed class CallRecorder
{
public (HttpMethod method, string path, object? body) FirstCall =>
Calls[0];
public List<(HttpMethod method, string path, object? body)> Calls { get; } = new();
}
/// <summary>Test fake that records every CallAsync invocation
/// and answers them with a canned sequence: the first call gets
/// 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>
internal sealed class RecordingYavscApiClient : YavscApiClient
{
private readonly CallRecorder _recorder;
public RecordingYavscApiClient(CallRecorder recorder)
: base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{
_recorder = recorder;
}
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
_recorder.Calls.Add((method, path, body));
// 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(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<BlogPostDto>))
return Task.FromResult((T)(object)new List<BlogPostDto>
{
new() { Id = 42, Title = "Mon premier billet" }
});
return Task.FromResult(default(T)!);
}
}

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

@ -10,7 +10,7 @@ namespace PostIt.Tests;
/// URL emitted by OidcClient, extracts its <c>state</c>, and returns a /// URL emitted by OidcClient, extracts its <c>state</c>, and returns a
/// BrowserResult that mimics the OIDC redirect-with-code callback. /// BrowserResult that mimics the OIDC redirect-with-code callback.
/// ///
/// The paired <see cref="OidcStubAuthority"/>'s token endpoint accepts /// The paired <see cref="OIDCStubAuthority"/>'s token endpoint accepts
/// any authorization code, so we don't need to mint a real one here. /// any authorization code, so we don't need to mint a real one here.
/// </summary> /// </summary>
public sealed class FakeAuthorizingBrowser public sealed class FakeAuthorizingBrowser

View file

@ -1,257 +0,0 @@
using System;
using System.Threading.Tasks;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
public class LoginPageViewModelTests
{
[Fact]
public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority()
{
// Arrange: spin up a stub OIDC authority and a fake browser that
// short-circuits the system browser. The authority signs its
// access_token with RS256; the fake browser captures the redirect
// URI so the authority can complete the token exchange.
using var authority = await OidcStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
// Act
await vm.LoginAsync();
// Assert: the ViewModel surfaced a token, not an error.
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login did not produce a token. StatusMessage={vm.StatusMessage ?? "<null>"}");
Assert.False(
vm.StatusMessage?.StartsWith("Error") == true,
$"Login reported error: {vm.StatusMessage}");
}
[Fact]
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
{
// Regression: when no user settings file exists and the embedded
// default somehow fails to load (e.g. resource stripped at publish
// time), the ViewModel must NOT hand a blank Authority to
// OidcClient — IdentityModel would build a bogus authorize URL
// like "http://127.0.0.1:1/" which the browser rejects with a
// confusing error. Surface a clear, actionable message instead.
//
// SettingsLoadOverride is set to a no-op so the test fixture's
// pre-loaded Settings object survives the call to LoginAsync.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "",
ClientId = "postit-tests",
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" },
};
var browserInvoked = false;
var vm = new LoginPageViewModel(settings, () =>
{
browserInvoked = true;
return null;
})
{
// Skip the disk / embedded read so the Authority stays empty.
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
};
await vm.LoginAsync();
Assert.False(
browserInvoked,
"Browser factory was invoked even though Authority was empty.");
Assert.NotNull(vm.StatusMessage);
Assert.Contains("Configuration manquante", vm.StatusMessage);
Assert.Contains("postit-settings.json", vm.StatusMessage);
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
}
[Fact]
public async Task LoginAsync_works_when_authority_has_trailing_slash()
{
// Regression: with Authority ending in "/" (the production
// postit-settings.json shape for https://yavsc.pschneider.fr/),
// the discovery URL OidcClient computes must NOT contain a
// double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient
// must bridge.
using var authority = await OidcStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer + "/",
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
await vm.LoginAsync();
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
}
[Fact]
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings);
// Trailing slash on Authority is normalised away.
Assert.Equal(
"https://yavsc.example.com/Account/Register",
vm.RegisterUrl);
Assert.Equal(
"https://yavsc.example.com/Account/ForgotPassword",
vm.ForgotPasswordUrl);
Assert.True(vm.HasRegisterUrl);
Assert.True(vm.HasForgotPasswordUrl);
}
[Fact]
public void RegisterUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.RegisterUrl);
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
Assert.False(vm.HasRegisterUrl);
Assert.False(vm.HasForgotPasswordUrl);
}
[Fact]
public void ConfigMissing_is_true_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.True(vm.ConfigMissing);
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
}
[Fact]
public void ConfigMissing_is_false_when_authority_is_set()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
}
};
var vm = new LoginPageViewModel(settings);
Assert.False(vm.ConfigMissing);
}
[Theory]
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings { Authority = authority }
};
var vm = new LoginPageViewModel(settings);
Assert.Equal(expected, vm.DiscoveryUrl);
// ExternalUrl is the slash-normalised form of Authority.
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
}
[Fact]
public void DiscoveryUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.DiscoveryUrl);
}
[Fact]
public async Task LoginAsync_failure_message_includes_discovery_url()
{
// Arrange: settings point at an unreachable authority; the test
// browser throws synchronously to guarantee the catch branch runs.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://does-not-exist.invalid/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
// Act
await vm.LoginAsync();
// Assert: the surfaced error mentions the canonical discovery URL,
// so it can be copy-pasted into a browser to diagnose reachability.
Assert.NotNull(vm.StatusMessage);
Assert.StartsWith("Error:", vm.StatusMessage);
Assert.Contains(
"https://does-not-exist.invalid/.well-known/openid-configuration",
vm.StatusMessage);
}
[Fact]
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => null);
await vm.LoginAsync();
Assert.Contains(
"https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage);
}
}

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

@ -0,0 +1,90 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
/// controls through their public surface and assert on what
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
///
/// <para>The bug we are pinning: the title <c>TextBox</c> is
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
/// When <c>SelectedPost is null</c> (i.e. the user has not yet
/// clicked an item in the posts list — which is the only state
/// 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 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>
/// buffer that the XAML binds to and the Save command consumes.</para>
/// </summary>
public class MainPageSaveTests
{
[AvaloniaFact]
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
{
// Arrange: VM with a recording API client, mounted in a
// headless window via a Frame (MainPage is a ContentPage,
// not a Control, so it needs a navigation host).
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the
// same one App.axaml.cs drives at boot (PushAsync, fire-
// and-forget in prod because the page is the top of the
// stack immediately).
var nav = new NavigationPage();
_ = nav.PushAsync(page);
var window = new Window { Content = nav };
window.Show();
// Act: type a title into the editor's TextBox without
// first selecting a post in the list — the only state in
// which a new post can be created. Then click Save.
var titleBox = window.GetVisualDescendants()
.OfType<TextBox>()
.First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null);
// The Save command is async (RelayCommand over Task) but
// ExecuteAsync would await; the sync Execute enqueues the
// task on the dispatcher. Give the dispatcher a chance to
// run so the awaited CallAsync has actually fired before
// we inspect the recorder.
await Task.Delay(200);
// 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<BlogPostDto>(body);
Assert.Equal(typed, sent.Title);
}
}

View file

@ -20,7 +20,7 @@ namespace PostIt.Tests;
/// the browser intercepts the authorize redirect, the server completes /// the browser intercepts the authorize redirect, the server completes
/// the token exchange. /// the token exchange.
/// </summary> /// </summary>
public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
{ {
private readonly HttpListener _listener; private readonly HttpListener _listener;
private readonly RSA _rsa; private readonly RSA _rsa;
@ -30,7 +30,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
public string Issuer { get; } public string Issuer { get; }
public string LoopbackRedirectUri { get; } public string LoopbackRedirectUri { get; }
private OidcStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback) private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{ {
_listener = listener; _listener = listener;
_rsa = rsa; _rsa = rsa;
@ -39,7 +39,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
LoopbackRedirectUri = loopback; LoopbackRedirectUri = loopback;
} }
public static async Task<OidcStubAuthority> StartAsync() public static async Task<OIDCStubAuthority> StartAsync()
{ {
// Pick a free loopback port. // Pick a free loopback port.
var port = GetFreePort(); var port = GetFreePort();
@ -53,7 +53,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
var rsa = RSA.Create(2048); var rsa = RSA.Create(2048);
var kid = "test-key-1"; var kid = "test-key-1";
var authority = new OidcStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback); var authority = new OIDCStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback);
_ = Task.Run(() => authority.AcceptLoopAsync(authority._cts.Token)); _ = Task.Run(() => authority.AcceptLoopAsync(authority._cts.Token));
return authority; return authority;
} }

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,6 +6,10 @@
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace> <RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<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>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
@ -21,4 +25,7 @@
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -1,21 +1,13 @@
using System.Collections.Generic; using Yavsc.Blogspot;
using System.Linq; using Yavsc.Api.Client;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using Xunit;
namespace PostIt; namespace PostIt.Tests;
public class PostItViewModelTests public class PostItViewModelTests
{ {
[Fact] [Fact]
public void SearchCommand_filters_posts_by_title_article_or_author() public void SearchCommand_filters_posts_by_title_article_or_author()
{ {
@ -23,12 +15,12 @@ public class PostItViewModelTests
// default; tests construct one with a fake YavscApiClient that // default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi); var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPostDto { 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 BlogPostDto { 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 = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search"; viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null); viewModel.SearchCommand.Execute(null);
@ -49,13 +41,13 @@ public class PostItViewModelTests
// The new BlogApiClient delegates transport to YavscApiClient. // The new BlogApiClient delegates transport to YavscApiClient.
// We feed it a fake YavscApiClient that returns the expected // We feed it a fake YavscApiClient that returns the expected
// list straight from CallAsync. // list straight from CallAsync.
var expected = new List<BlogPost> var expected = new List<BlogPostDto>
{ {
new() { Id = 1, Title = "Hello" }, new() { Id = 1, Title = "Hello" },
new() { Id = 2, Title = "World" } new() { Id = 2, Title = "World" }
}; };
var api = new StubYavscApiClient(expected); var api = new StubYavscApiClient(expected);
var blog = new BlogApiClient(api); var blog = new BlogApiClient(api, "http://localhost/");
var posts = await blog.GetPostsAsync(); var posts = await blog.GetPostsAsync();
@ -69,11 +61,11 @@ public class PostItViewModelTests
public ThrowingYavscApiClient() : base( public ThrowingYavscApiClient() : base(
new Settings new Settings
{ {
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://stub.invalid", Authority = "https://stub.invalid",
ClientId = "stub", ClientId = "stub",
Scopes = new[] { "openid" },
}, },
}, },
new TokenStore(System.IO.Path.GetTempFileName())) new TokenStore(System.IO.Path.GetTempFileName()))
@ -85,16 +77,16 @@ public class PostItViewModelTests
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary> /// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
private sealed class StubYavscApiClient : YavscApiClient private sealed class StubYavscApiClient : YavscApiClient
{ {
private readonly List<BlogPost> _posts; private readonly List<BlogPostDto> _posts;
public StubYavscApiClient(List<BlogPost> posts) public StubYavscApiClient(List<BlogPostDto> posts)
: base( : base(
new Settings new Settings
{ {
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://stub.invalid", Authority = "https://stub.invalid",
ClientId = "stub", ClientId = "stub",
Scopes = new[] { "openid" },
}, },
}, },
new TokenStore(System.IO.Path.GetTempFileName())) new TokenStore(System.IO.Path.GetTempFileName()))
@ -106,7 +98,7 @@ public class PostItViewModelTests
{ {
// The canned fake only knows about a list of posts; the // The canned fake only knows about a list of posts; the
// BlogApiClient test asserts on that list directly. // 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((T)(object)_posts);
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }

View file

@ -0,0 +1,125 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>.
///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
/// established: a test attribute <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
/// attribute schedules the test body inside a dispatcher, which
/// is the precondition for the headless Window's
/// <c>PlatformManager.CreateWindow()</c> to find a registered
/// service. A plain <c>[Fact]</c> test that calls
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// </summary>
public class SessionStatusBannerTests
{
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var buttons = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(3, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
}
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
var login = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
// The XAML binds IsVisible to IsLoggedOut. After Show,
// the binding has been evaluated.
Assert.True(login.IsVisible);
}
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
var logout = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
Assert.False(logout.IsVisible);
}
[AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var settings = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
// Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
Assert.True(settings.IsVisible);
}
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = window.SessionBanner.GetVisualDescendants()
.OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
// Default SessionLabel is "Déconnecté" until Refresh()
// is called with a valid session. This pins the default
// so a future refactor that breaks the initial value
// (e.g. by removing the field initialiser) is caught.
Assert.Equal("Déconnecté", label.Text);
}
}

View file

@ -1,5 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Xunit; using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -25,11 +27,132 @@ public class SettingsLoadTests
return; // nothing to assert: user file wins. return; // nothing to assert: user file wins.
} }
var settings = new PostIt.Settings(); var settings = new PostIt.ViewModels.Settings();
settings.Load(); settings.Load();
// The bundled postit-settings.json points at yavsc.pschneider.fr. // The bundled postit-settings.json points at yavsc.pschneider.fr.
Assert.False(string.IsNullOrWhiteSpace(settings.Authentication?.Authority)); Assert.False(string.IsNullOrWhiteSpace(settings.Authentication?.Authority));
Assert.Equal("postit", settings.Authentication.ClientId); Assert.Equal("postit", settings.Authentication.ClientId);
} }
}
/// <summary>
/// Regression test for the <c>postit://callback</c> crash: two
/// Settings instances racing on <c>PropertyChanged</c> from a
/// background thread crashed Avalonia's binding sink inside
/// <c>DataValidationErrors.SetErrors</c>. We can't spin up an
/// Avalonia dispatcher in xUnit, but we can prove the property
/// mutation path is now thread-safe: concurrent loads + concurrent
/// observable mutations complete without throwing and the
/// resulting state is internally consistent.
/// </summary>
[Fact]
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
{
var settings = new PostIt.ViewModels.Settings();
// First load pre-populates Authentication.Authority so the
// early-return path in Load() runs (we don't want file I/O
// racing itself in this test — the thread-safety claim is
// about the mutation gate and the Load idempotency check,
// not the file read).
settings.Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests",
Scopes = new[] { "openid" },
};
// Load() takes the early-return path because Authority is
// already populated; flips Loaded=true under the gate.
settings.Load();
Assert.True(settings.Loaded);
// Hammer the observable properties from multiple threads
// simultaneously. Without the gate, this is a torn-read and
// a race on Loaded; with the gate, every observer sees a
// consistent snapshot. Keep the iteration count small so the
// test finishes quickly on CI; the goal is to catch races,
// not benchmark throughput.
const int workers = 4;
const int iterations = 50;
var barrier = new Barrier(workers);
var failures = new System.Collections.Concurrent.ConcurrentBag<Exception>();
var tasks = new Task[workers];
for (int w = 0; w < workers; w++)
{
int workerId = w;
tasks[w] = Task.Run(() =>
{
try
{
barrier.SignalAndWait();
for (int i = 0; i < iterations; i++)
{
bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip;
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DefaultDesktopRedirectUri;
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";
// Concurrent Load() calls must be safe and
// idempotent. We assert the structural
// invariants that the gate protects.
Assert.True(settings.Loaded);
Assert.NotNull(settings.Authentication);
Assert.NotNull(settings.Authentication.Scopes);
}
}
catch (Exception ex)
{
failures.Add(ex);
}
});
}
await Task.WhenAll(tasks);
Assert.Empty(failures);
// Final state is one of the valid combinations; the test only
// cares that no observer caught a torn read or a thrown
// exception.
Assert.True(settings.Loaded);
Assert.NotNull(settings.Authentication);
}
/// <summary>
/// PropertyChanged fires exactly once per mutation even when
/// called concurrently. We don't subscribe to PropertyChanged
/// (xUnit can't pull an Avalonia dispatcher), but we verify the
/// mutation gate is taken by hitting Load() from many threads
/// and checking that Loaded flips exactly once (no torn reads).
/// </summary>
[Fact]
public void Load_is_idempotent_under_concurrent_calls()
{
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests"
}
};
const int workers = 16;
var barrier = new Barrier(workers);
var tasks = new Task[workers];
for (int i = 0; i < workers; i++)
{
tasks[i] = Task.Run(() =>
{
barrier.SignalAndWait();
settings.Load();
});
}
Task.WaitAll(tasks);
Assert.True(settings.Loaded);
}
}

View file

@ -0,0 +1,188 @@
using System;
using System.Linq;
using PostIt.Controls;
using PostIt.Models;
using Xunit;
namespace PostIt.Tests;
/// <summary>
/// Targeted tests for <see cref="SignaturePadControl"/> and
/// <see cref="SignaturePadData"/>.
///
/// The control exposes <c>internal</c> test hooks so we can drive
/// the buffer without standing up a headless XAML tree just to
/// deliver synthetic pointer events. The headless surface is used
/// only to assert that the control's pointer handlers are wired
/// when a template is applied; see
/// <see cref="Pointer_handlers_attach_when_capture_area_is_set"/>.
/// </summary>
public class SignaturePadControlTests
{
// --- SignaturePadData (pure) ---------------------------------------
[Fact]
public void Data_empty_array_is_empty()
{
var d = new SignaturePadData(Array.Empty<int>());
Assert.True(d.IsEmpty);
Assert.Equal(0, d.StrokeCount);
}
[Fact]
public void Data_single_dot_is_one_stroke_with_k_equals_one()
{
var d = new SignaturePadData(new[] { 1, 5_000, 5_000 });
Assert.False(d.IsEmpty);
Assert.Equal(1, d.StrokeCount);
}
[Fact]
public void Data_two_strokes_are_independent()
{
var d = new SignaturePadData(new[]
{
2, 100, 100, 200, 200,
1, 9_000, 9_000,
});
Assert.Equal(2, d.StrokeCount);
}
[Fact]
public void Data_malformed_payload_does_not_throw_on_read()
{
// k=0 at the head would underflow the walker. The reader
// short-circuits instead of throwing.
var d = new SignaturePadData(new[] { 0, 1, 2, 3 });
Assert.Equal(0, d.StrokeCount);
}
[Fact]
public void Data_constructor_rejects_null()
{
Assert.Throws<ArgumentNullException>(() => new SignaturePadData(null!));
}
// --- SignaturePadControl (buffer / events) -------------------------
[Fact]
public void New_control_has_empty_buffer()
{
var pad = new SignaturePadControl();
Assert.Empty(pad.Strokes);
Assert.True(pad.Snapshot().IsEmpty);
}
[Fact]
public void Snapshot_returns_a_distinct_array_each_call()
{
var pad = new SignaturePadControl();
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
pad.SealStrokeForTest();
var first = pad.Snapshot();
var second = pad.Snapshot();
// Distinct array instances — the consumer of the first
// snapshot can hold onto it after the control mutates.
Assert.NotSame(first.Strokes, second.Strokes);
// Same logical content (no mutation in between).
Assert.Equal(first.Strokes, second.Strokes);
pad.AppendPointForTest(5_000, 6_000);
pad.SealStrokeForTest();
var third = pad.Snapshot();
Assert.NotEqual(first.Strokes, third.Strokes);
}
[Fact]
public void Clear_empties_buffer_and_raises_redraw()
{
var pad = new SignaturePadControl();
pad.AppendPointForTest(1, 1);
pad.SealStrokeForTest();
Assert.NotEmpty(pad.Strokes);
int redraws = 0;
pad.RedrawRequested += (_, _) => redraws++;
pad.Clear();
Assert.Empty(pad.Strokes);
Assert.True(pad.Snapshot().IsEmpty);
Assert.Equal(1, redraws);
}
[Fact]
public void SealStrokeForTest_raises_redraw()
{
var pad = new SignaturePadControl();
int redraws = 0;
pad.RedrawRequested += (_, _) => redraws++;
pad.AppendPointForTest(1, 1);
pad.AppendPointForTest(2, 2);
pad.SealStrokeForTest();
Assert.Equal(1, redraws);
}
[Fact]
public void SealStrokeForTest_with_no_pending_points_is_a_no_op()
{
var pad = new SignaturePadControl();
int redraws = 0;
pad.RedrawRequested += (_, _) => redraws++;
pad.SealStrokeForTest();
Assert.Equal(0, redraws);
}
[Fact]
public void Two_sealed_strokes_produce_two_length_prefixes()
{
var pad = new SignaturePadControl();
// Stroke 0: one point.
pad.AppendPointForTest(1_000, 1_000);
pad.SealStrokeForTest();
// Stroke 1: two points.
pad.AppendPointForTest(2_000, 2_000);
pad.AppendPointForTest(3_000, 3_000);
pad.SealStrokeForTest();
var s = pad.Strokes;
// Layout: [k0, x0, y0, k1, x1, y1, x2, y2]
Assert.Equal(1, s[0]);
Assert.Equal(1_000, s[1]);
Assert.Equal(1_000, s[2]);
Assert.Equal(2, s[3]);
Assert.Equal(2_000, s[4]);
Assert.Equal(2_000, s[5]);
Assert.Equal(3_000, s[6]);
Assert.Equal(3_000, s[7]);
}
[Fact]
public void StrokeCompleted_fires_on_seal()
{
var pad = new SignaturePadControl();
int events = 0;
pad.StrokeCompleted += (_, _) => events++;
pad.AppendPointForTest(1, 1);
pad.SealStrokeForTest();
pad.AppendPointForTest(2, 2);
pad.SealStrokeForTest();
Assert.Equal(2, events);
}
[Fact]
public void StrokeCompleted_carries_a_snapshot_with_k_count()
{
var pad = new SignaturePadControl();
SignaturePadData? captured = null;
pad.StrokeCompleted += (_, d) => captured = d;
pad.AppendPointForTest(1, 1);
pad.AppendPointForTest(2, 2);
pad.SealStrokeForTest();
Assert.NotNull(captured);
Assert.Equal(1, captured!.StrokeCount);
}
}

View file

@ -0,0 +1,163 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using PostIt.Controls;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
/// <summary>
/// Tests for <see cref="SignaturePageViewModel"/>: the contract
/// between the page's view model and the <see cref="SignaturePadControl"/>.
/// The view (XAML + code-behind rendering) is not tested here — the
/// control is render-agnostic, and the rendering is plain Polyline
/// reconstruction that we'll exercise manually in PostIt.Desktop.
/// </summary>
public class SignaturePageViewModelTests
{
[Fact]
public void Default_constructor_uses_default_dimensions()
{
var vm = new SignaturePageViewModel();
Assert.Equal(SignaturePageViewModel.DefaultWidth, vm.Width);
Assert.Equal(SignaturePageViewModel.DefaultHeight, vm.Height);
}
[Fact]
public void Constructor_rejects_non_positive_dimensions()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new SignaturePageViewModel(0, 100));
Assert.Throws<ArgumentOutOfRangeException>(
() => new SignaturePageViewModel(100, 0));
Assert.Throws<ArgumentOutOfRangeException>(
() => new SignaturePageViewModel(-1, 100));
}
[Fact]
public void Attach_then_Detach_is_idempotent()
{
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
vm.Detach();
// Second detach is a no-op: must not throw.
vm.Detach();
}
[Fact]
public void Attach_rejects_null()
{
var vm = new SignaturePageViewModel();
Assert.Throws<ArgumentNullException>(() => vm.Attach(null!));
}
[Fact]
public void StrokeCompleted_updates_status_and_counts()
{
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
// Drive the control via the test hooks so we don't depend
// on Avalonia pointer events.
pad.AppendPointForTest(1_000, 1_000);
pad.AppendPointForTest(2_000, 2_000);
pad.SealStrokeForTest();
Assert.Equal(1, vm.StrokeCount);
Assert.Equal(2, vm.PointCount);
Assert.Contains("1 trait", vm.StatusMessage);
}
[Fact]
public void Clear_resets_counts_and_buffer()
{
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
pad.AppendPointForTest(1, 1);
pad.SealStrokeForTest();
Assert.Equal(1, vm.StrokeCount);
vm.Clear();
Assert.Equal(0, vm.StrokeCount);
Assert.Equal(0, vm.PointCount);
Assert.Empty(pad.Strokes);
Assert.Contains("Effacé", vm.StatusMessage);
}
[Fact]
public async Task CaptureAsync_on_empty_buffer_reports_and_writes_nothing()
{
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
await vm.CaptureAsync();
Assert.Contains("Rien", vm.StatusMessage);
Assert.Null(vm.LastCapturedPath);
}
[Fact]
public async Task CaptureAsync_writes_a_yavsc_signature_v1_file()
{
// The VM uses Environment.SpecialFolder.LocalApplicationData,
// which we cannot redirect per-call without a constructor
// seam. We test the produced file's structure rather than
// its text formatting, because System.Text.Json's pretty-
// printer is not part of the contract we're locking down.
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
pad.SealStrokeForTest();
await vm.CaptureAsync();
Assert.NotNull(vm.LastCapturedPath);
Assert.True(File.Exists(vm.LastCapturedPath!), $"file missing: {vm.LastCapturedPath}");
using var doc = JsonDocument.Parse(File.ReadAllText(vm.LastCapturedPath!));
var root = doc.RootElement;
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
Assert.Equal(1, root.GetProperty("strokeCount").GetInt32());
var strokes = root.GetProperty("strokes");
Assert.Equal(JsonValueKind.Array, strokes.ValueKind);
// [k=2, x0, y0, x1, y1]
Assert.Equal(5, strokes.GetArrayLength());
Assert.Equal(2, strokes[0].GetInt32()); // k (2 points)
Assert.Equal(1_000, strokes[1].GetInt32()); // x0
Assert.Equal(2_000, strokes[2].GetInt32()); // y0
Assert.Equal(3_000, strokes[3].GetInt32()); // x1
Assert.Equal(4_000, strokes[4].GetInt32()); // y1
}
[Fact]
public async Task CaptureAsync_creates_directory_if_missing()
{
var vm = new SignaturePageViewModel();
var pad = new SignaturePadControl();
vm.Attach(pad);
pad.AppendPointForTest(1, 1);
pad.SealStrokeForTest();
// The directory must exist after the call (CreateDirectory
// in the VM handles this).
await vm.CaptureAsync();
var dir = Path.GetDirectoryName(vm.LastCapturedPath!);
Assert.NotNull(dir);
Assert.True(Directory.Exists(dir), $"directory missing: {dir}");
}
}

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,10 +8,14 @@ using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels;
using Xunit; using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -20,7 +24,7 @@ namespace PostIt.Tests;
/// End-to-end coverage of <see cref="YavscApiClient"/>: silent /// End-to-end coverage of <see cref="YavscApiClient"/>: silent
/// refresh on a near-expiry access token, 401-driven refresh + retry, /// refresh on a near-expiry access token, 401-driven refresh + retry,
/// and persistence of the token bundle via <see cref="TokenStore"/>. /// and persistence of the token bundle via <see cref="TokenStore"/>.
/// Uses the project's <see cref="OidcStubAuthority"/> for the IdP and /// Uses the project's <see cref="OIDCStubAuthority"/> for the IdP and
/// a tiny in-process HTTP listener for the API server side. /// a tiny in-process HTTP listener for the API server side.
/// </summary> /// </summary>
public class YavscApiClientTests public class YavscApiClientTests
@ -45,7 +49,7 @@ public class YavscApiClientTests
// in-memory access token as expired and re-run a call. The // in-memory access token as expired and re-run a call. The
// refresh path must rotate the refresh token transparently // refresh path must rotate the refresh token transparently
// and the API call must succeed with the new token. // and the API call must succeed with the new token.
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -61,9 +65,15 @@ public class YavscApiClientTests
// Reload — YavscApiClient constructor reads the store. // Reload — YavscApiClient constructor reads the store.
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath)); var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
// Same BaseAddress dance as LoginAndPersistAsync: a fresh
// YavscApiClient starts with no BaseAddress, and the test
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>( var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts"); HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotNull(posts); Assert.NotNull(posts);
Assert.NotEmpty(posts); Assert.NotEmpty(posts);
@ -85,7 +95,7 @@ public class YavscApiClientTests
{ {
// API server returns 401 on the first request, 200 on the next. // API server returns 401 on the first request, 200 on the next.
// YavscApiClient must refresh, then retry exactly once. // YavscApiClient must refresh, then retry exactly once.
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(forceFirstRequest: true); using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -97,7 +107,7 @@ public class YavscApiClientTests
settings, authority, tokensPath); settings, authority, tokensPath);
var posts = await client.CallAsync<List<StubApiServer.Post>>( var posts = await client.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts"); HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotEmpty(posts); Assert.NotEmpty(posts);
Assert.Equal(2, apiServer.RequestCount); Assert.Equal(2, apiServer.RequestCount);
@ -111,28 +121,29 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task CallAsync_throws_when_no_token_and_no_interactive_login() public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
{ {
var settings = new PostIt.Settings var settings = new Settings
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://127.0.0.1:5001", Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests", ClientId = "postit-tests",
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
}, },
RedirectUri = "postit://callback", BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
Scopes = new[] { "openid" },
ApiUrl = "https://127.0.0.1:5003/api/v1",
}; };
var client = new YavscApiClient(settings, new TokenStore(Path.Combine( var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(
client.CallAsync<JsonElement>(HttpMethod.Get, "posts")); () =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
} }
[Fact] [Fact]
public async Task HasValidSession_is_true_after_login() public async Task HasValidSession_is_true_after_login()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -154,24 +165,30 @@ public class YavscApiClientTests
// --- helpers -------------------------------------------------------- // --- helpers --------------------------------------------------------
private static PostIt.Settings BuildSettings(OidcStubAuthority authority, string apiBaseUrl) => new() private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = authority.Issuer, Authority = authority.Issuer,
ClientId = "postit-tests", ClientId = "postit-tests",
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
}, },
RedirectUri = authority.LoopbackRedirectUri, BusinessApiUrl = apiBaseUrl
Scopes = new[] { "openid", "profile", "blog" },
ApiUrl = apiBaseUrl,
}; };
private static async Task<YavscApiClient> LoginAndPersistAsync( private static async Task<YavscApiClient> LoginAndPersistAsync(
PostIt.Settings settings, OidcStubAuthority authority, string tokensPath) Settings settings, OIDCStubAuthority authority, string tokensPath)
{ {
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath)); var client = new YavscApiClient(settings, new TokenStore(tokensPath));
// The two integration tests that call CallAsync("posts", ...)
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
// Force the API client to use the test browser by routing the // Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper. // LoginInteractiveAsync call through a small wrapper.
await LoginWithBrowserAsync(client, browser.CreateBrowser()); await LoginWithBrowserAsync(client, browser.CreateBrowser());
@ -181,7 +198,7 @@ public class YavscApiClientTests
/// <summary> /// <summary>
/// YavscApiClient.LoginInteractiveAsync delegates to /// YavscApiClient.LoginInteractiveAsync delegates to
/// Platform.CreateBrowser. We can't override that static cleanly /// Platform.CreateBrowser. We can't override that static cleanly
/// from xunit.v3, so we rebuild the call by re-routing the /// from XUnit.v3, so we rebuild the call by re-routing the
/// Platform.CreateBrowser delegate for the duration of the call. /// Platform.CreateBrowser delegate for the duration of the call.
/// </summary> /// </summary>
private static async Task LoginWithBrowserAsync( private static async Task LoginWithBrowserAsync(
@ -214,7 +231,7 @@ public class YavscApiClientTests
File.WriteAllText(tokensPath, JsonSerializer.Serialize(record)); File.WriteAllText(tokensPath, JsonSerializer.Serialize(record));
} }
// --- OidcLoginPhase progress tests --------------------------------- // --- OIDCLoginPhase progress tests ---------------------------------
/// <summary> /// <summary>
/// Collecting Progress<T> is documented to capture reports /// Collecting Progress<T> is documented to capture reports
@ -225,7 +242,7 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task LoginInteractiveAsync_reports_Discovering_then_Success() public async Task LoginInteractiveAsync_reports_Discovering_then_Success()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -234,18 +251,18 @@ public class YavscApiClientTests
var client = new YavscApiClient(settings, new TokenStore(tokensPath)); var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var reported = new System.Collections.Generic.List<OidcLoginPhase>(); var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported); var progress = new SyncProgress<OIDCLoginPhase>(reported);
try try
{ {
await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress); await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress);
// SyncProgress captures reports synchronously — no flush needed. // SyncProgress captures reports synchronously — no flush needed.
Assert.Contains(OidcLoginPhase.Discovering, reported); Assert.Contains(OIDCLoginPhase.Discovering, reported);
Assert.Contains(OidcLoginPhase.OpeningBrowser, reported); Assert.Contains(OIDCLoginPhase.OpeningBrowser, reported);
Assert.Contains(OidcLoginPhase.ExchangingCode, reported); Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
Assert.Equal(OidcLoginPhase.Success, Last(reported)); Assert.Equal(OIDCLoginPhase.Success, Last(reported));
} }
finally finally
{ {
@ -256,24 +273,24 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task LoginInteractiveAsync_reports_Error_when_browser_missing() public async Task LoginInteractiveAsync_reports_Error_when_browser_missing()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl); var settings = BuildSettings(authority, apiServer.BaseUrl);
var client = new YavscApiClient(settings, new TokenStore(TokensPath())); var client = new YavscApiClient(settings, new TokenStore(TokensPath()));
var reported = new System.Collections.Generic.List<OidcLoginPhase>(); var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported); var progress = new SyncProgress<OIDCLoginPhase>(reported);
var original = Platform.CreateBrowser; var original = Platform.CreateBrowser;
try try
{ {
Platform.CreateBrowser = () => null; // simulate no browser wired up Platform.CreateBrowser = () => null; // simulate no browser wired up
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => client.LoginInteractiveAsync(progress)); () => client.LoginInteractiveAsync(progress, TestContext.Current.CancellationToken));
// SyncProgress captures reports synchronously — no flush needed. // SyncProgress captures reports synchronously — no flush needed.
Assert.Equal(OidcLoginPhase.Error, Last(reported)); Assert.Equal(OIDCLoginPhase.Error, Last(reported));
} }
finally finally
{ {
@ -284,7 +301,7 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task TrySilentLoginAsync_returns_false_when_no_bundle_on_disk() public async Task TrySilentLoginAsync_returns_false_when_no_bundle_on_disk()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -293,7 +310,7 @@ public class YavscApiClientTests
// Tokens file deliberately doesn't exist. // Tokens file deliberately doesn't exist.
var client = new YavscApiClient(settings, new TokenStore(tokensPath)); var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var ok = await client.TrySilentLoginAsync(); var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
Assert.False(ok); Assert.False(ok);
Assert.False(client.HasValidSession); Assert.False(client.HasValidSession);
} }
@ -301,7 +318,7 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task TrySilentLoginAsync_returns_true_when_access_token_still_valid() public async Task TrySilentLoginAsync_returns_true_when_access_token_still_valid()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -314,7 +331,7 @@ public class YavscApiClientTests
{ {
await LoginWithBrowserAsync(client, browser.CreateBrowser()); await LoginWithBrowserAsync(client, browser.CreateBrowser());
// Login fresh → access token is far from expiry. // Login fresh → access token is far from expiry.
var ok = await client.TrySilentLoginAsync(); var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
Assert.True(ok); Assert.True(ok);
Assert.True(client.HasValidSession); Assert.True(client.HasValidSession);
} }
@ -327,7 +344,7 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task TrySilentLoginAsync_returns_true_when_refresh_succeeds() public async Task TrySilentLoginAsync_returns_true_when_refresh_succeeds()
{ {
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(); using var apiServer = new StubApiServer();
await apiServer.StartAsync(); await apiServer.StartAsync();
@ -351,13 +368,13 @@ public class YavscApiClientTests
// matches the disk: access expired, refresh still good. // matches the disk: access expired, refresh still good.
var client = new YavscApiClient(settings, store); var client = new YavscApiClient(settings, store);
var reported = new System.Collections.Generic.List<OidcLoginPhase>(); var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported); var progress = new SyncProgress<OIDCLoginPhase>(reported);
var ok = await client.TrySilentLoginAsync(progress); var ok = await client.TrySilentLoginAsync(progress, TestContext.Current.CancellationToken);
Assert.True(ok, "silent refresh should succeed via the stub authority."); Assert.True(ok, "silent refresh should succeed via the stub authority.");
Assert.Contains(OidcLoginPhase.ExchangingCode, reported); Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
Assert.Equal(OidcLoginPhase.Success, Last(reported)); Assert.Equal(OIDCLoginPhase.Success, Last(reported));
} }
finally finally
{ {
@ -430,7 +447,7 @@ public class YavscApiClientTests
/// overload stays for tests that don't care about phase events. /// overload stays for tests that don't care about phase events.
/// </summary> /// </summary>
private static async Task LoginWithBrowserAsync( private static async Task LoginWithBrowserAsync(
YavscApiClient client, IBrowser browser, IProgress<OidcLoginPhase>? progress = null) YavscApiClient client, IBrowser browser, IProgress<OIDCLoginPhase>? progress = null)
{ {
var original = Platform.CreateBrowser; var original = Platform.CreateBrowser;
try try

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

@ -12,8 +12,10 @@
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <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="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" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -5,10 +5,10 @@ namespace PostIt.Android;
/// <summary> /// <summary>
/// One-shot platform bootstrap. Called from /// One-shot platform bootstrap. Called from
/// <see cref="MainActivity.OnCreate"/> so that the shared /// <see cref="MainActivity.OnCreate"/> so that the shared OIDC login
/// <c>LoginPageViewModel</c> sees the Android-specific redirect URI and a /// path sees the Android-specific redirect URI and a working
/// working <c>IBrowser</c> (Chrome Custom Tabs) without referencing /// <c>IBrowser</c> (Chrome Custom Tabs) without referencing Android
/// Android APIs from the shared library. /// APIs from the shared library.
/// </summary> /// </summary>
internal static class PlatformBootstrap internal static class PlatformBootstrap
{ {
@ -19,11 +19,11 @@ internal static class PlatformBootstrap
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return; return;
Platform.DefaultRedirectUri = Settings.AndroidRedirectUri; Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri;
Platform.CreateBrowser = () => Platform.CreateBrowser = () =>
{ {
var activity = MainActivity.Current; var activity = MainActivity.Current;
return activity is null ? null : new AndroidSystemBrowser(activity); return activity is null ? null : new AndroidSystemBrowser(activity);
}; };
} }
} }

View file

@ -12,6 +12,10 @@
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers> <RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<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>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
@ -26,4 +30,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
</Project> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup>
</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,6 +4,10 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<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>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />
@ -11,4 +15,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -5,8 +5,8 @@ namespace PostIt.Desktop;
/// <summary> /// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that /// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c> /// the shared OIDC login path sees a working <c>IBrowser</c> — the
/// — the custom-scheme browser that hands the OIDC callback off to the /// custom-scheme browser that hands the OIDC callback off to the
/// running instance through the named pipe. Desktop builds do NOT use /// running instance through the named pipe. Desktop builds do NOT use
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered /// a loopback HTTP listener: the <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user /// with the OS at install time and the browser is whatever the user
@ -24,7 +24,7 @@ internal static class PlatformBootstrap
// Use the custom-scheme redirect on Desktop. Loopback is only // Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit:// // a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path). // (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri; Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit"; Platform.CustomScheme = "postit";
} }
} }

View file

@ -5,6 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.--> See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<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>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
@ -15,8 +19,12 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Material.Avalonia" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

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

View file

@ -1,11 +1,14 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -13,6 +16,18 @@ namespace PostIt;
public partial class App : Application public partial class App : Application
{ {
/// <summary>
/// DI container the platform entry points hand to ViewModels so
/// they can resolve the canonical <see cref="Settings"/> singleton
/// (and any other shared service) instead of falling back to a
/// freshly-constructed <c>new Settings()</c>. The earlier fallback
/// path is what created two Settings instances on
/// <c>postit://callback</c> re-launches and crashed Avalonia's
/// binding sink with a cross-thread exception inside
/// <c>DataValidationErrors.SetErrors</c>.
/// </summary>
public IServiceProvider? ServiceProvider { get; private set; }
private MainWindow window;
public App() public App()
{ {
} }
@ -34,59 +49,54 @@ public partial class App : Application
// build is ever reconfigured to skip the early check. // build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
var settings = new Settings(); this.ServiceProvider = BuildServices(new ServiceCollection());
settings.Load(); AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var tokenStore = new TokenStore(System.IO.Path.Combine( var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), var api = ServiceProvider.GetRequiredService<YavscApiClient>();
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
services.AddTransient<LoginPage>();
services.AddTransient<SettingsPage>();
services.AddTransient<HomePage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient<MainPageViewModel>();
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<LoginPageViewModel>();
services.AddTransient<HomePageViewModel>();
// 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>();
var provider = services.BuildServiceProvider();
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider)); DataTemplates.Add(new ViewLocator(ServiceProvider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
ServiceProvider.GetRequiredService<SettingsPage>().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
// CheckBox, but no consumer ever read it. Wire it here to
// Application.RequestedThemeVariant so the toggle takes
// effect immediately, and seed the initial theme from the
// value Load() just populated (so a dark-mode user lands on
// a dark window on first launch, not on a default-light
// window that flips after the user touches the toggle).
ApplyDarkMode(settings);
settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Settings.DarkMode))
{
ApplyDarkMode(settings);
}
};
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homePage = provider.GetRequiredService<HomePage>(); var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
homePage.DataContext = provider.GetRequiredService<HomePageViewModel>();
var window = new MainWindow(); window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus; window.SessionBanner.DataContext = sessionStatus;
// Build the navigation stack from scratch: HomePage is the // Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on // root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds. // top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window; desktop.MainWindow = window;
_ = window.NavRoot.PushAsync(homePage); _ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We // When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack // ReplaceAsync the current top so we don't grow the stack
@ -96,22 +106,138 @@ public partial class App : Application
{ {
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot; var nav = w.NavRoot;
var hp = provider.GetRequiredService<HomePage>();
hp.DataContext = provider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync(); _ = nav.PopToRootAsync();
}; };
window.Opened += async (_, _) => await BootAsync(provider, api, window); // When the user signs in interactively (Login button on
// the session banner), push MainPage on top of HomePage.
sessionStatus.LoginSucceeded += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
_ = PushMainPageAsync();
};
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{ {
singleView.MainView = new MainWindow singleView.MainView = new MainWindow
{ {
DataContext = provider.GetRequiredService<HomePageViewModel>() DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>()
}; };
} }
} }
/// <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 =
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
/// <summary> /// <summary>
/// Run once after the main window is shown: try to refresh the /// Run once after the main window is shown: try to refresh the
/// cached OIDC tokens silently; on success, push MainPage on top /// cached OIDC tokens silently; on success, push MainPage on top
@ -122,18 +248,29 @@ public partial class App : Application
/// </summary> /// </summary>
private static async Task BootAsync( private static async Task BootAsync(
IServiceProvider provider, IServiceProvider provider,
YavscApiClient api, YavscApiClient api)
MainWindow window)
{ {
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
if (!refreshed) return; if (!refreshed) return;
var mainVm = provider.GetRequiredService<MainPageViewModel>(); await PushMainPageAsync().ConfigureAwait(true);
var mainPage = provider.GetRequiredService<MainPage>(); }
mainPage.DataContext = mainVm;
await window.NavRoot.PushAsync(mainPage); /// <summary>
/// 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 Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
return app.PushPageAsync(mainVm);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -168,4 +305,53 @@ public partial class App : Application
return true; 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

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using PostIt.Models;
namespace PostIt.Controls;
/// <summary>
/// Pointer-driven capture surface that records a signature as a list
/// of strokes, each stroke being a length-prefixed sequence of (x, y)
/// coordinates normalised to <c>[0, CoordinateMax]</c>.
///
/// The control is render-agnostic: it does not draw anything. The
/// host view templates a <see cref="InputElement"/> (typically a
/// <c>Border</c>) as <c>PART_CaptureArea</c> for pointer capture,
/// and binds a separate visual layer (e.g. a <c>Canvas</c>) to
/// <see cref="Strokes"/> for redraw. Keeping the control headless of
/// rendering makes it usable from a headless test where no
/// composition happens.
///
/// Wire format (see <see cref="SignaturePadData"/>):
/// <code>int[] = [k0, x0, y0, ..., k1, x0, y0, ...]</code>
/// with <c>x, y ∈ [0, 10_000]</c>.
///
/// Threading: pointer events are dispatched on the UI thread, which
/// is the only thread that ever mutates <see cref="Strokes"/>. The
/// buffer is safe to read from any thread as long as no read
/// straddles a pointer event — for cross-thread transfer use
/// <see cref="Snapshot"/>, which copies.
/// </summary>
public class SignaturePadControl : TemplatedControl
{
/// <summary>
/// Styled property pointing at the <see cref="InputElement"/>
/// that receives pointer events. Set it in the control's
/// template (<c>PART_CaptureArea</c>).
/// </summary>
public static readonly StyledProperty<InputElement?> CaptureAreaProperty =
AvaloniaProperty.Register<SignaturePadControl, InputElement?>(nameof(CaptureArea));
public InputElement? CaptureArea
{
get => GetValue(CaptureAreaProperty);
set => SetValue(CaptureAreaProperty, value);
}
/// <summary>
/// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI
/// thread, between pointer events.
/// </summary>
public IReadOnlyList<int> Strokes => _strokes;
/// <summary>
/// Raised when the user finishes a stroke (pointer release).
/// The argument is a snapshot of the buffer at release time.
/// </summary>
public event EventHandler<SignaturePadData>? StrokeCompleted;
/// <summary>
/// Raised when the buffer changes: at the end of every stroke
/// and on <see cref="Clear"/>. Mid-stroke points do not raise
/// this event (pointer-move is too dense); bind a separate
/// visual layer if you need a live preview.
/// </summary>
public event EventHandler? RedrawRequested;
private readonly List<int> _strokes = new(capacity: 256);
private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix
private bool _capturing;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (CaptureArea is { } previous)
{
previous.PointerPressed -= OnCapturePressed;
previous.PointerMoved -= OnCaptureMoved;
previous.PointerReleased -= OnCaptureReleased;
}
if (CaptureArea is { } area)
{
area.PointerPressed += OnCapturePressed;
area.PointerMoved += OnCaptureMoved;
area.PointerReleased += OnCaptureReleased;
}
}
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea);
_capturing = true;
_pendingPoints = 0;
AppendPoint(e.GetPosition(CaptureArea));
}
private void OnCaptureMoved(object? sender, PointerEventArgs e)
{
if (!_capturing) return;
AppendPoint(e.GetPosition(CaptureArea));
}
private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_capturing) return;
AppendPoint(e.GetPosition(CaptureArea));
_capturing = false;
if (_pendingPoints == 0)
{
// Press + immediate release without movement yields no
// point at all (the press fired AppendPoint, so this
// branch is unreachable — kept for clarity if a future
// change skips the press append).
return;
}
// Seal the current stroke by inserting its length at the
// head of its slice. The slice is the trailing
// 2 * _pendingPoints entries.
int sliceStart = _strokes.Count - 2 * _pendingPoints;
_strokes.Insert(sliceStart, _pendingPoints);
_pendingPoints = 0;
StrokeCompleted?.Invoke(this, Snapshot());
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
private void AppendPoint(Point p)
{
var (nx, ny) = Normalise(p);
_strokes.Add(nx);
_strokes.Add(ny);
_pendingPoints++;
}
private (int x, int y) Normalise(Point p)
{
if (CaptureArea is null) return (0, 0);
var bounds = CaptureArea.Bounds;
double w = bounds.Width;
double h = bounds.Height;
if (w <= 0 || h <= 0) return (0, 0);
int nx = (int)Math.Round(Math.Clamp(p.X / w, 0.0, 1.0) * SignaturePadData.CoordinateMax);
int ny = (int)Math.Round(Math.Clamp(p.Y / h, 0.0, 1.0) * SignaturePadData.CoordinateMax);
return (nx, ny);
}
/// <summary>
/// Forget every captured stroke. Raises <see cref="RedrawRequested"/>.
/// </summary>
public void Clear()
{
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the
/// view needs to ship the data off (e.g. to a backend).
/// </summary>
public SignaturePadData Snapshot() => new(_strokes.ToArray());
// --- Test-only surface (visible to PostIt.Tests) -------------------
/// <summary>
/// Test hook: append a single normalised point without going
/// through the pointer pipeline. Does not raise
/// <see cref="RedrawRequested"/>.
/// </summary>
internal void AppendPointForTest(int x, int y)
{
_strokes.Add(x);
_strokes.Add(y);
_pendingPoints++;
}
/// <summary>
/// Test hook: seal the currently-pending stroke with a length
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at
/// pointer release time, including the
/// <see cref="StrokeCompleted"/> and <see cref="RedrawRequested"/>
/// events, so test scenarios observe the same notification
/// contract as production. Idempotent: a second call without
/// intermediate appends is a no-op.
/// </summary>
internal void SealStrokeForTest()
{
if (_pendingPoints == 0) return;
int sliceStart = _strokes.Count - 2 * _pendingPoints;
_strokes.Insert(sliceStart, _pendingPoints);
_pendingPoints = 0;
StrokeCompleted?.Invoke(this, Snapshot());
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
}

View file

@ -1,16 +0,0 @@
using System;
namespace PostIt.Models;
public class BlogPost
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Article { get; set; }
public string? Photo { get; set; }
public string? AuthorId { get; set; }
public DateTime DateCreated { get; set; }
public string? UserCreated { get; set; }
public DateTime DateModified { get; set; }
public string? UserModified { get; set; }
}

View file

@ -0,0 +1,103 @@
namespace PostIt.Models;
/// <summary>
/// Serialized form of a signature captured by
/// <see cref="PostIt.Controls.SignaturePadControl"/>.
///
/// Wire format (length-prefixed, normalised):
/// <code>
/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1},
/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1},
/// ...]
/// </code>
/// <list type="bullet">
/// <item><c>k_i</c> — number of (x, y) pairs in stroke <c>i</c>.</item>
/// <item><c>x, y</c> — coordinates normalised to <c>[0, CoordinateMax]</c>
/// (inclusive) on the control's client area. <see cref="CoordinateMax"/>
/// is <c>10_000</c> by default — a 4-decimal fixed-point fraction of
/// the surface, which is enough to discriminate 0.01% of the diagonal
/// on any reasonable screen and stays well inside <c>int</c>.</item>
/// <item>Total array length is even: each stroke contributes
/// <c>1 + 2 * k_i</c> integers, and <c>1 + 2k</c> is always odd.
/// Sum of <c>1 + 2k_i</c> over strokes is therefore odd * N, which
/// is odd when N is odd and even when N is even — so the overall
/// "size pair" property is not enforced, only the per-stroke shape
/// is. If the consumer needs a strictly even total, pad the last
/// stroke with a duplicate terminal point (or use
/// <see cref="IsEmpty"/> to drop the array entirely).</item>
/// </list>
///
/// Empty signature (no strokes) is represented by an empty array
/// (length 0). A single dot — pen down + pen up at the same point —
/// is a single stroke with <c>k = 1</c>: <c>[1, x, y]</c>.
/// </summary>
public sealed class SignaturePadData
{
/// <summary>
/// Upper bound of normalised coordinates. <c>10_000</c> means a
/// surface unit is represented as 0.0001 of the whole.
/// </summary>
public const int CoordinateMax = 10_000;
/// <summary>
/// Raw payload. See <see cref="SignaturePadData"/> for the layout.
/// Never <c>null</c>; an empty array means "no strokes".
/// </summary>
public int[] Strokes { get; }
public SignaturePadData(int[] strokes)
{
if (strokes is null) throw new System.ArgumentNullException(nameof(strokes));
Strokes = strokes;
}
/// <summary>True if no stroke has been captured.</summary>
public bool IsEmpty => Strokes.Length == 0;
/// <summary>
/// Number of distinct strokes (pen-down / pen-up cycles).
/// Returns 0 when <see cref="IsEmpty"/> is true.
/// </summary>
public int StrokeCount
{
get
{
if (Strokes.Length == 0) return 0;
int n = 0;
int i = 0;
while (i < Strokes.Length)
{
int k = Strokes[i];
// Defensive: a malformed entry is treated as 0 so we
// never throw on read. The capture side never produces
// these, this is only for robustness on the wire.
if (k <= 0) return n;
i += 1 + 2 * k;
n++;
}
return n;
}
}
/// <summary>
/// Total number of (x, y) pairs across all strokes. Useful
/// for sanity-checks and for displaying capture density
/// without re-walking the wire format.
/// </summary>
public int PointCount
{
get
{
int n = 0;
int i = 0;
while (i < Strokes.Length)
{
int k = Strokes[i];
if (k <= 0) break;
n += k;
i += 1 + 2 * k;
}
return n;
}
}
}

View file

@ -4,6 +4,10 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<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>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -21,6 +25,7 @@
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">
@ -32,4 +37,7 @@
<LogicalName>PostIt.postit-settings.json</LogicalName> <LogicalName>PostIt.postit-settings.json</LogicalName>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
</Project> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -1,53 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Models;
namespace PostIt.Services;
/// <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.
///
/// The class is intentionally non-IDisposable: it does not own the
/// <see cref="YavscApiClient"/> it depends on. Lifetimes are managed
/// by the consumer (typically a singleton service registered with
/// the application).
/// </summary>
public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "api/blog";
private readonly YavscApiClient _api;
private readonly string _pathPrefix;
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPost>>(
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<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
=> _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task UpdatePostAsync(long id, BlogPost 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);
}

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

@ -12,7 +12,7 @@ namespace PostIt.Services;
/// The set is deliberately small: each value is a milestone an /// The set is deliberately small: each value is a milestone an
/// operator can grep for in logs / StatusMessage, not a heartbeat. /// operator can grep for in logs / StatusMessage, not a heartbeat.
/// </summary> /// </summary>
public enum OidcLoginPhase public enum OIDCLoginPhase
{ {
/// <summary>No login in flight (or login has settled).</summary> /// <summary>No login in flight (or login has settled).</summary>
Idle, Idle,

View file

@ -7,8 +7,8 @@ namespace PostIt.Services;
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does /// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
/// not reference any UI framework; platform projects (PostIt.Android, /// not reference any UI framework; platform projects (PostIt.Android,
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so /// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
/// the shared <c>LoginPageViewModel</c> can drive a native browser without /// the shared OIDC login path can drive a native browser without taking
/// taking a hard dependency on any specific UI toolkit. /// a hard dependency on any specific UI toolkit.
/// </summary> /// </summary>
public static class Platform public static class Platform
{ {

View file

@ -0,0 +1,72 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
namespace PostIt.Services;
/// <summary>
/// Tiny marshalling helper around <see cref="Dispatcher.UIThread"/> so
/// the rest of the codebase does not have to import Avalonia.Threading
/// directly. We want exactly one place that decides "is the current
/// thread the Avalonia UI thread, and if not, post there" so that
/// <see cref="ObservableObject"/>-derived types (Settings, the various
/// ViewModels) can fire <c>PropertyChanged</c> safely from background
/// work — which is exactly the cross-thread case that previously blew
/// up inside <c>DataValidationErrors.SetErrors</c> on Avalonia 11.
///
/// The helper is intentionally tiny: a sync post when we are off the
/// UI thread, a no-op when we are already on it, and an async fire-
/// and-forget variant for places where awaiting would deadlock the
/// caller (e.g. <c>Settings.Load</c> continuation paths).
/// </summary>
public static class UiDispatcher
{
/// <summary>
/// True when the calling thread is the Avalonia UI thread. Property
/// setters that touch bindings should check this before mutating
/// state; the safe path is <see cref="InvokeIfNeeded"/>.
/// </summary>
public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess();
/// <summary>
/// Run <paramref name="action"/> on the UI thread. If the caller is
/// already on the UI thread, run synchronously to preserve stack
/// traces and ordering; otherwise post to the dispatcher and wait.
/// Never throws on shutdown — a missing dispatcher is treated as
/// "best-effort skipped", matching Avalonia's own behaviour when
/// the application lifetime has been torn down.
/// </summary>
public static void InvokeIfNeeded(Action action)
{
if (action is null) return;
if (IsOnUiThread) { action(); return; }
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ }
}
/// <summary>
/// Fire-and-forget variant: schedules <paramref name="action"/> on
/// the UI thread but does not block the caller. Use this from
/// background workers (OIDC discovery, HTTP callbacks, file I/O)
/// where awaiting the dispatcher would deadlock the calling sync
/// context.
/// </summary>
public static void Post(Action action)
{
if (action is null) return;
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone */ }
}
/// <summary>
/// Awaitable variant. Useful inside <c>async</c> ViewModel methods
/// that must touch bindings only after the dispatcher has processed
/// a queued update (e.g. "load file then refresh observable state").
/// </summary>
public static Task InvokeAsync(Action action)
{
if (action is null) return Task.CompletedTask;
if (IsOnUiThread) { action(); return Task.CompletedTask; }
return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask();
}
}

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

@ -8,6 +8,8 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Services; namespace PostIt.Services;
@ -23,16 +25,16 @@ namespace PostIt.Services;
/// <see cref="BearerTokenHandler"/> only refreshes once even if many /// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight. /// concurrent requests are in flight.
/// </summary> /// </summary>
public class YavscApiClient : IAsyncDisposable public class YavscApiClient : IYavscApiClient, IAsyncDisposable
{ {
// 60s of slack before the access_token's nominal expiry. Covers // 60s of slack before the access_token's nominal expiry. Covers
// network latency + JWT validation on the server side. // network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60); private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
private readonly Settings _settings; public Settings Settings {  get; }
private readonly OidcClient _oidc; private readonly OidcClient _oidc;
private readonly TokenStore _store; private readonly TokenStore _store;
private readonly HttpClient _http; public HttpClient Http { get; }
private readonly BearerTokenHandler _bearer; private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
@ -40,24 +42,19 @@ public class YavscApiClient : IAsyncDisposable
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{ {
_settings = settings; Settings = settings;
_store = store; _store = store;
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions()); _oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
_http = new HttpClient(_bearer, disposeHandler: true) Http = new HttpClient(_bearer, disposeHandler: true);
{
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
BaseAddress = new Uri(settings.ApiUrl)
};
_tokens = store.Load(); _tokens = store.Load();
} }
/// <summary> /// <summary>
/// True if a non-expired access token (or a refreshable bundle) is /// True if a non-expired access token (or a refreshable bundle) is
/// already in memory. UI uses this to skip the LoginPage on warm /// already in memory. UI uses this to skip the login flow on warm
/// starts. /// starts.
/// </summary> /// </summary>
public bool HasValidSession public bool HasValidSession
@ -74,9 +71,9 @@ public class YavscApiClient : IAsyncDisposable
/// <summary> /// <summary>
/// The current access token, or null if no session is active. /// The current access token, or null if no session is active.
/// Surfaced so the LoginPageViewModel can mirror it onto its own /// Surfaced so consumers (e.g. <c>HomePage</c>) can mirror it onto
/// observable property (and so the OIDC id_token / claims can be /// their own observable properties and so the OIDC id_token / claims
/// shown in the UI). /// can be shown in the UI.
/// </summary> /// </summary>
public string? CurrentAccessToken => _tokens?.AccessToken; public string? CurrentAccessToken => _tokens?.AccessToken;
@ -87,23 +84,21 @@ public class YavscApiClient : IAsyncDisposable
/// <param name="progress">Optional sink for the discrete phases of /// <param name="progress">Optional sink for the discrete phases of
/// the flow; the UI uses this to render a debug-friendly status /// the flow; the UI uses this to render a debug-friendly status
/// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode /// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode
/// → Success / Error). The same caller can also rely on /// → Success / Error).</param>
/// <see cref="LoginPageViewModel.StatusMessage"/> for the human
/// text (URLs, error detail).</param>
public async Task LoginInteractiveAsync( public async Task LoginInteractiveAsync(
IProgress<OidcLoginPhase>? progress = null, IProgress<OIDCLoginPhase>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
progress?.Report(OidcLoginPhase.Discovering); progress?.Report(OIDCLoginPhase.Discovering);
var browser = Platform.CreateBrowser?.Invoke(); var browser = Platform.CreateBrowser?.Invoke();
if (browser is null) if (browser is null)
{ {
progress?.Report(OidcLoginPhase.Error); progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException("No browser is available on this platform."); throw new InvalidOperationException("No browser is available on this platform.");
} }
var client = new OidcClient(_settings.GetOidcClientOptions(browser)); var client = new OidcClient(Settings.GetOidcClientOptions(browser));
// OidcClient.LoginAsync builds the authorize URL, calls // OidcClient.LoginAsync builds the authorize URL, calls
// IBrowser.InvokeAsync (which on desktop hands the user off // IBrowser.InvokeAsync (which on desktop hands the user off
@ -114,20 +109,20 @@ public class YavscApiClient : IAsyncDisposable
// the moment we ask the browser to open (covers the entire // the moment we ask the browser to open (covers the entire
// user-driven window including the AwaitingCallback wait), and // user-driven window including the AwaitingCallback wait), and
// the moment we trade the code for tokens. // the moment we trade the code for tokens.
progress?.Report(OidcLoginPhase.OpeningBrowser); progress?.Report(OIDCLoginPhase.OpeningBrowser);
var result = await client.LoginAsync(new LoginRequest(), ct).ConfigureAwait(false); var result = await client.LoginAsync(new LoginRequest(), ct).ConfigureAwait(false);
if (result.IsError) if (result.IsError)
{ {
progress?.Report(OidcLoginPhase.Error); progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException($"OIDC login failed: {result.Error}"); throw new InvalidOperationException($"OIDC login failed: {result.Error}");
} }
progress?.Report(OidcLoginPhase.ExchangingCode); progress?.Report(OIDCLoginPhase.ExchangingCode);
if (string.IsNullOrEmpty(result.RefreshToken)) if (string.IsNullOrEmpty(result.RefreshToken))
{ {
progress?.Report(OidcLoginPhase.Error); progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException( throw new InvalidOperationException(
"Missing refresh_token — vérifie le scope 'offline_access'."); "Missing refresh_token — vérifie le scope 'offline_access'.");
} }
@ -139,7 +134,7 @@ public class YavscApiClient : IAsyncDisposable
IdToken: result.IdentityToken); IdToken: result.IdentityToken);
_store.Save(_tokens); _store.Save(_tokens);
progress?.Report(OidcLoginPhase.Success); progress?.Report(OIDCLoginPhase.Success);
} }
/// <summary> /// <summary>
@ -152,7 +147,7 @@ public class YavscApiClient : IAsyncDisposable
/// phase and returns false so the UI can keep going. /// phase and returns false so the UI can keep going.
/// </summary> /// </summary>
public async Task<bool> TrySilentLoginAsync( public async Task<bool> TrySilentLoginAsync(
IProgress<OidcLoginPhase>? progress = null, IProgress<OIDCLoginPhase>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (!HasValidSession) return false; if (!HasValidSession) return false;
@ -161,7 +156,7 @@ public class YavscApiClient : IAsyncDisposable
// Access token still has plenty of life — nothing to do. // Access token still has plenty of life — nothing to do.
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
{ {
progress?.Report(OidcLoginPhase.Success); progress?.Report(OIDCLoginPhase.Success);
return true; return true;
} }
@ -172,19 +167,19 @@ public class YavscApiClient : IAsyncDisposable
// the user back to the login page. // the user back to the login page.
try try
{ {
progress?.Report(OidcLoginPhase.ExchangingCode); progress?.Report(OIDCLoginPhase.ExchangingCode);
await ForceRefreshAsync(ct).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
progress?.Report(OidcLoginPhase.Success); progress?.Report(OIDCLoginPhase.Success);
return true; return true;
} }
catch (RefreshFailedException) catch (RefreshFailedException)
{ {
progress?.Report(OidcLoginPhase.Idle); progress?.Report(OIDCLoginPhase.Idle);
return false; return false;
} }
catch catch
{ {
progress?.Report(OidcLoginPhase.Idle); progress?.Report(OIDCLoginPhase.Idle);
return false; return false;
} }
} }
@ -197,7 +192,7 @@ public class YavscApiClient : IAsyncDisposable
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream, var dto = await JsonSerializer.DeserializeAsync<T>(stream,
@ -205,6 +200,16 @@ public class YavscApiClient : IAsyncDisposable
return dto!; return dto!;
} }
/// <summary>
/// Call a JSON endpoint with no request body while still allowing a
/// positional cancellation token argument.
/// </summary>
public Task<T> CallAsync<T>(
HttpMethod method,
string path,
CancellationToken ct)
=> CallAsync<T>(method, path, body: null, ct);
/// <summary>Call an endpoint that returns no useful body (DELETE, etc.).</summary> /// <summary>Call an endpoint that returns no useful body (DELETE, etc.).</summary>
public async Task CallAsync( public async Task CallAsync(
HttpMethod method, HttpMethod method,
@ -213,9 +218,19 @@ public class YavscApiClient : IAsyncDisposable
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
} }
/// <summary>
/// Call an endpoint with no request body while still allowing a
/// positional cancellation token argument.
/// </summary>
public Task CallAsync(
HttpMethod method,
string path,
CancellationToken ct)
=> CallAsync(method, path, body: null, ct);
private async Task<HttpResponseMessage> SendAsync( private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct) HttpMethod method, string path, object? body, CancellationToken ct)
{ {
@ -227,7 +242,7 @@ public class YavscApiClient : IAsyncDisposable
using var req = new HttpRequestMessage(method, path); using var req = new HttpRequestMessage(method, path);
if (body is not null) if (body is not null)
req.Content = JsonContent.Create(body); req.Content = JsonContent.Create(body);
var response = await _http.SendAsync(req, ct).ConfigureAwait(false); var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
@ -239,12 +254,54 @@ public class YavscApiClient : IAsyncDisposable
using var retry = new HttpRequestMessage(method, path); using var retry = new HttpRequestMessage(method, path);
if (body is not null) if (body is not null)
retry.Content = JsonContent.Create(body); retry.Content = JsonContent.Create(body);
response = await _http.SendAsync(retry, ct).ConfigureAwait(false); response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
} }
return response; return response;
} }
/// <summary>
/// Replaces the bare <c>response.EnsureSuccessStatusCode()</c>
/// call site with one that surfaces the response body in the
/// thrown exception. The default behaviour truncates the
/// diagnostic to "Response status code does not indicate
/// success: 400 (Bad Request)." — useless when the server is
/// an ASP.NET Core action returning a <c>ProblemDetails</c>
/// that names the field that failed ModelState validation.
/// The VM's <c>catch (Exception ex)</c> in
/// <c>MainPageViewModel.ExecuteAsync</c> shows
/// <c>ex.Message</c> on the status bar, so embedding the body
/// here is enough to make the next "click Save" self-explanatory
/// (e.g. <i>"Error: 400 — The Title field is required."</i>).
/// </summary>
private static async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
// Read the body before throwing; once the response is
// disposed, the stream is gone. We bound the read to a few
// KB so a hostile server can't make us buffer megabytes
// just to format an error message.
string body = string.Empty;
try
{
var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
body = raw.Length > 1024 ? raw[..1024] + "…" : raw;
}
}
catch
{
// Body unreadable: fall back to the default message.
}
var msg = body.Length > 0
? $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}"
: $"{(int)response.StatusCode} {response.ReasonPhrase}";
throw new HttpRequestException(msg, inner: null, statusCode: response.StatusCode);
}
/// <summary> /// <summary>
/// Lock the refresh path so concurrent callers don't each rotate /// Lock the refresh path so concurrent callers don't each rotate
/// the refresh token (which Auth0 invalidates on first use). /// the refresh token (which Auth0 invalidates on first use).
@ -306,7 +363,7 @@ public class YavscApiClient : IAsyncDisposable
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
_http.Dispose(); Http.Dispose();
_refreshGate.Dispose(); _refreshGate.Dispose();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }

View file

@ -1,13 +1,122 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using System; using System;
using System.Text.Json.Serialization;
public partial class AuthenticationSettings : ObservableObject public partial class AuthenticationSettings : ObservableObject
{ {
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit";
[ObservableProperty] [ObservableProperty]
public partial string Authority { get; set; } public partial string Authority { get; set; }
[ObservableProperty] [ObservableProperty]
public partial string ClientId { get; set; } public partial string ClientId { get; set; }
}
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
/// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the
/// <c>SettingsPage</c> TextBox binding — a <c>string[]</c> does not
/// round-trip through XAML binding to <c>TextBox.Text</c>, so we
/// expose the array as a string here and re-parse on assignment.
/// <para>
/// <c>[JsonIgnore]</c> on purpose: <see cref="Scopes"/> is the
/// persisted shape (matches the on-disk format in
/// <c>postit-settings.json</c> and the runtime contract in
/// <see cref="PostIt.ViewModels.Settings.GetOidcClientOptions"/>).
/// Writing this property back to disk would duplicate the
/// information and confuse the deserializer.
/// </para>
/// </summary>
[JsonIgnore]
[ObservableProperty]
public partial string ScopeListText { get; set; } = string.Empty;
/// <summary>
/// Refresh <see cref="ScopeListText"/> from <see cref="Scopes"/> so
/// the TextBox shows the current persisted state after a Load().
/// Called from <c>Settings.ApplyJson</c> on each disk / embedded
/// hydration; the source generator's <c>OnScopesChanged</c> partial
/// below keeps the two in sync in the other direction (edits made
/// in the TextBox).
/// </summary>
public void RefreshScopeListText()
{
ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes);
}
partial void OnScopeListTextChanged(string value)
{
if (Scopes is null)
{
Scopes = Array.Empty<string>();
}
// Split on any whitespace, drop empties. Matches what
// string.Join(' ', Scopes) produces when Scopes is null-free,
// so a round-trip (Display → Edit → Display) is lossless
// for sane inputs.
var parts = value?.Split(
new[] { ' ', '\t', '\n', '\r' },
StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
// Skip the write if the parsed array is equal to the current
// one — avoids a PropertyChanged loop between OnScopesChanged
// and OnScopeListTextChanged when RefreshScopeListText runs.
if (Scopes is not null && Scopes.Length == parts.Length)
{
var same = true;
for (var i = 0; i < parts.Length; i++)
{
if (!string.Equals(Scopes[i], parts[i], StringComparison.Ordinal))
{
same = false;
break;
}
}
if (same) return;
}
Scopes = parts;
}
partial void OnScopesChanged(string[] value)
{
// Keep ScopeListText in sync when Scopes is reassigned from
// outside (JSON hydration, MergeScopes, programmatic
// updates). Compute the new value and only fire if it
// differs from what's already shown, otherwise the TextBox
// would briefly flicker / re-set the caret on every load.
var newText = value is null ? string.Empty : string.Join(' ', value);
if (!string.Equals(ScopeListText, newText, StringComparison.Ordinal))
{
ScopeListText = newText;
}
}
}

View file

@ -1,207 +0,0 @@
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using IdentityModel.OidcClient;
using PostIt.Services;
using System;
using System.IO;
using System.Text.Json;
[assembly: InternalsVisibleTo("PostIt.Tests")]
namespace PostIt;
public partial class Settings : ObservableObject
{
const string SettingsFileName = "postit-settings.json";
IStorageFolder? folder = null;
/// <summary>
/// Legacy loopback redirect URI. The post-2026.6 production flow
/// uses the custom URI scheme (<see cref="DefaultDesktopRedirectUri"/>
/// on desktop, <see cref="AndroidRedirectUri"/> on Android) so the
/// OS hands the callback to the running instance without a TCP
/// listener. The loopback constant stays here so test fixtures
/// (which spin up an in-process OidcStubAuthority) keep working,
/// but it is no longer used as a default anywhere in production.
/// If you are still pointing your production <c>postit-settings.json</c>
/// at this URI, switch to <c>postit://callback</c> and remove the
/// matching entry from the Yavsc.Org server's allowed redirect URIs.
/// </summary>
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
[ObservableProperty]
public partial bool DarkMode { get; set; } = false;
[ObservableProperty]
public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
[ObservableProperty]
public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
/// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE
/// (no client secret). The browser implementation should be supplied
/// per-platform by the caller.
/// </summary>
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
{
if (!Loaded) Load();
var options = new OidcClientOptions
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
RedirectUri = RedirectUri,
Scope = string.Join(' ', this.Scopes),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
// PKCE is enabled by default when no client_secret is provided.
};
if (browser is not null)
options.Browser = browser;
return options;
}
internal void Load()
{
if (Loaded) return;
// Trust an already-populated Authority: tests pre-fill Settings
// with the OIDC stub's random loopback port, and programmatic
// callers (CLI flags, integration tests) wire their own. If we
// fall through to the disk / embedded read here we'd silently
// overwrite their value with the bundled default
// (yavsc.pschneider.fr), break the stubbed discovery URL, and
// turn a passing login into an invalid_grant.
if (!string.IsNullOrWhiteSpace(Authentication?.Authority))
{
Loaded = true;
return;
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
Directory.CreateDirectory(configDir);
string configPath = Path.Combine(configDir, SettingsFileName);
FileInfo configFileInfo = new FileInfo(configPath);
if (!configFileInfo.Exists)
{
Console.Error.WriteLine($"🩎 Settings file not found at {configFileInfo.FullName}");
// No user-level config: fall back to the embedded default.
// We only get here when Authentication.Authority is empty
// (the early-return above) so the redundant guard is gone.
if (!TryLoadEmbeddedFallback())
{
Console.Error.WriteLine("🩎 No embedded default settings; running with empty configuration.");
}
return;
}
Console.WriteLine($"🔎 Loading settings from {configFileInfo.FullName}");
try
{
// Synchronous read on purpose: Settings.Load() is called from
// synchronous startup paths (App.axaml.cs, ViewModel ctors,
// tests) and bridging to async here with .Wait() / .GetAwaiter()
// .GetResult() deadlocks the Avalonia UI thread because the
// continuation can't resume on the same thread. The settings
// file is a few KiB at most; async I/O gains nothing here.
using var stream = configFileInfo.OpenRead();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
ApplyJson(json, $"user file {configFileInfo.FullName}");
Loaded = true;
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error loading settings: {ex.Message}");
}
}
private bool TryLoadEmbeddedFallback()
{
const string ResourceName = "PostIt.postit-settings.json";
var assembly = typeof(Settings).Assembly;
using var stream = assembly.GetManifestResourceStream(ResourceName);
if (stream is null)
{
Console.Error.WriteLine($"🩎 Embedded resource {ResourceName} not found.");
return false;
}
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
{
Console.Error.WriteLine("🩎 Embedded settings resource is empty.");
return false;
}
Console.WriteLine($"🔎 Loading embedded default settings ({ResourceName}).");
ApplyJson(json, $"embedded resource {ResourceName}");
return true;
}
private void ApplyJson(string json, string source)
{
if (string.IsNullOrWhiteSpace(json))
{
Console.Error.WriteLine($"🩎 Settings payload is empty (source: {source}).");
return;
}
try
{
var settings = JsonSerializer.Deserialize<Settings>(json);
if (settings is null)
{
Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source}).");
return;
}
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.ApiUrl = settings.ApiUrl;
this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri;
this.Scopes = settings.Scopes;
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}");
}
}
}

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -21,17 +20,33 @@ public class ViewLocator : IDataTemplate
_services = services; _services = services;
} }
public Control Build(object data) 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 return data switch
{ {
MainPageViewModel => _services.GetRequiredService<MainPage>(), MainPageViewModel => _services.GetRequiredService<MainPage>(),
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } 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}" }
}; };
} }
public bool Match(object data) => data is ViewModelBase; public bool Match(object? data) => data is ViewModelBase;
} }

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

@ -1,11 +1,14 @@
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt; using PostIt;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase public class HomePageViewModel : ViewModelBase
{ {
public YavscApiClient Api { get; } public YavscApiClient Api { get; }
public Settings Settings { get; } public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
private string _welcomeText = "Welcome to PostIt!"; private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText public string WelcomeText
@ -17,12 +20,25 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings) public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
{ {
Api = api; Api = api;
Settings = settings; Settings = settings;
} SessionStatus = sessionStatus;
// Constructeur sans arg pour le designer Avalonia }
public HomePageViewModel() : this(null!, null!) { } public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
/// render without a running App. Production paths always reach
/// the parameterised constructor (DI or direct injection), and
/// the postit://callback crash is fixed at the Settings layer
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{
}
} }

View file

@ -1,327 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
namespace PostIt.ViewModels;
public partial class LoginPageViewModel : ViewModelBase
{
private const string SettingsFileName = "postit-settings.json";
[Obsolete("Password grant is not used; IdentityModel.OidcClient performs PKCE.")]
public string Password { get; set; } = string.Empty;
[Obsolete("User-entered email is not used; the IdP login UI collects it.")]
public string UserEmail { get; set; } = string.Empty;
[Obsolete("No local credential persistence in the current build.")]
public bool RememberMe { get; set; }
/// <summary>
/// URL of the Yavsc.Org account-registration page.
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
/// Empty when the authority is not configured.
/// </summary>
public string RegisterUrl =>
BuildExternalUrl("/Account/Register");
/// <summary>
/// URL of the Yavsc.Org password-reset page (open to anonymous users).
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
/// Empty when the authority is not configured.
/// </summary>
public string ForgotPasswordUrl =>
BuildExternalUrl("/Account/ForgotPassword");
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
/// <summary>
/// Canonical <see cref="Settings.Authentication"/> authority with any trailing
/// slash removed. Used as the base for both the OIDC discovery URL and the
/// human-facing Account URLs (Register / Forgot password). Empty when the
/// authority is not configured.
/// </summary>
public string ExternalUrl => BuildExternalUrl(string.Empty);
/// <summary>
/// OIDC discovery URL the client actually calls during login:
/// <c>ExternalUrl + "/.well-known/openid-configuration"</c>. Surfaced in
/// <see cref="StatusMessage"/> on failure so the operator can copy it
/// verbatim and verify reachability from a browser.
/// </summary>
public string DiscoveryUrl =>
string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration";
/// <summary>
/// True when the settings file is missing or <c>Authentication.Authority</c>
/// is empty. The LoginPage surfaces a banner in that case and disables
/// the Register / Forgot password buttons.
/// </summary>
public bool ConfigMissing =>
string.IsNullOrWhiteSpace(Settings.Authentication?.Authority);
/// <summary>
/// Localised banner shown when <see cref="ConfigMissing"/> is true.
/// The path follows the XDG spec on Linux (where PostIt.Desktop runs):
/// the file is expected at <c>~/.config/PostIt/postit-settings.json</c>.
/// </summary>
public string ConfigMissingMessage =>
$"Configuration PostIt manquante — voir ~/.config/PostIt/postit-settings.json";
private string BuildExternalUrl(string path)
{
var authority = Settings.Authentication?.Authority?.TrimEnd('/');
return string.IsNullOrEmpty(authority)
? string.Empty
: authority + path;
}
/// <summary>
/// The access token of the most recent successful login, or null.
/// Kept on the VM so views can show "logged in as …" feedback; the
/// authoritative copy lives in the <see cref="TokenStore"/>.
/// </summary>
private string? _accessToken;
public string? AccessToken
{
get => _accessToken;
private set => this.SetProperty(ref _accessToken, value);
}
public override bool CanNavigateNext { get => false; protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); }
public Settings Settings { get; }
/// <summary>
/// Discrete phase of the OIDC flow the LoginPage is currently
/// showing. Surfaced in the UI as a one-line status (Discovering /
/// OpeningBrowser / AwaitingCallback / ExchangingCode / Success /
/// Error). Operators use this to debug the custom-scheme
/// callback hand-off: when AwaitingCallback never resolves,
/// the OS never re-launched PostIt with the postit:// URL.
/// </summary>
private OidcLoginPhase _phase = OidcLoginPhase.Idle;
public OidcLoginPhase Phase
{
get => _phase;
private set
{
if (this.SetProperty(ref _phase, value))
OnPropertyChanged(nameof(PhaseLabel));
}
}
/// <summary>
/// Human-readable label for <see cref="Phase"/>. French to match
/// the rest of the UI. Computed once per phase change.
/// </summary>
public string PhaseLabel => _phase switch
{
OidcLoginPhase.Idle => "En attente",
OidcLoginPhase.Discovering => "Découverte OIDC…",
OidcLoginPhase.OpeningBrowser => "Ouverture du navigateur…",
OidcLoginPhase.AwaitingCallback => "En attente du callback postit://…",
OidcLoginPhase.ExchangingCode => "Échange du code contre les jetons…",
OidcLoginPhase.Success => "Connecté",
OidcLoginPhase.Error => "Erreur",
_ => _phase.ToString(),
};
private string _statusMessage = "Ready";
public string StatusMessage
{
get => _statusMessage;
private set => this.SetProperty(ref _statusMessage, value);
}
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
private set => this.SetProperty(ref _isBusy, value);
}
private bool _LoginSuccess;
public bool LoginSuccess { get => _isBusy;
private set => this.SetProperty(ref _LoginSuccess, value); }
/// <summary>
/// Optional override used by tests. When set, this factory is called
/// instead of <see cref="Platform.CreateBrowser"/> to obtain the
/// <see cref="IBrowser"/> instance.
/// </summary>
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
/// <summary>
/// Optional override used by tests. When set, this delegate replaces
/// the call to <see cref="Settings.Load"/> at the start of
/// <see cref="LoginAsync"/>, so tests can inject a Settings object
/// without it being overwritten by the user/embedded default.
/// </summary>
public Func<Task>? SettingsLoadOverride { get; set; }
/// <summary>
/// Optional override used by tests. When set, the VM hands this
/// pre-built <see cref="YavscApiClient"/> to itself instead of
/// constructing a fresh one.
/// </summary>
public YavscApiClient? ApiClientOverride { get; set; }
public Action LoginSucceeded { get; internal set; }
private YavscApiClient? _api;
public LoginPageViewModel() : this(new Settings(), apiClient: null, browserFactoryOverride: null)
{
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
// populated as soon as the page renders (XAML bindings fire
// before the user clicks Login). Settings.Load is synchronous
// on purpose; calling .GetAwaiter().GetResult() on it would
// deadlock the UI thread on the await inside the file read.
try { Settings.Load(); }
catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ }
}
/// <summary>
/// Test-friendly constructor: caller supplies pre-loaded
/// <paramref name="settings"/>, an optional
/// <paramref name="browserFactoryOverride"/> that bypasses the
/// static <see cref="Platform"/> indirection, and an optional
/// pre-built <paramref name="apiClient"/> for end-to-end
/// scenarios where the test owns the wiring.
/// </summary>
public LoginPageViewModel(
Settings settings,
Func<IBrowser?>? browserFactoryOverride = null,
YavscApiClient? apiClient = null)
{
Settings = settings;
BrowserFactoryOverride = browserFactoryOverride;
ApiClientOverride = apiClient;
StatusMessage = "Ready";
}
[RelayCommand]
public async Task LoginAsync()
{
try
{
IsBusy = true;
LoginSuccess = false;
if (SettingsLoadOverride is not null)
await SettingsLoadOverride().ConfigureAwait(false);
else
Settings.Load();
// Guard: refuse to call OidcClient when the authority is
// empty. IdentityModel would otherwise build a bogus
// authorize URL like "http://127.0.0.1:1/" from an empty
// Authority, which the browser refuses with a confusing
// "Cette adresse est interdite"-style message. Tell the
// operator exactly what to fix instead.
if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority))
{
IsBusy = false;
StatusMessage =
$"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
return;
}
// The platform project picks the right redirect URI and
// browser implementation; we don't reference any UI
// toolkit from here.
Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri)
? Platform.DefaultRedirectUri
: Settings.RedirectUri;
// Surface the discovery URL the client is about to call,
// so a failure (DNS, TLS, 404) can be diagnosed by
// pasting the URL straight into a browser. OidcClient
// computes the discovery URL as
// `Authority + /.well-known/openid-configuration`; we
// normalise the trailing slash here so the printed URL is
// exactly what IdentityModel will fetch.
if (!string.IsNullOrEmpty(DiscoveryUrl))
StatusMessage = $"Discovering {DiscoveryUrl}";
// Build (or reuse) the API client. The browser override
// takes precedence: tests want to inject a fake browser
// and the production path uses Platform.CreateBrowser.
_api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore());
// Platform.CreateBrowser may still want to be customised
// per-call (e.g. between desktop and android), so route
// the interactive login through a callback that reuses
// BrowserFactoryOverride when present.
//
// The progress sink drives Phase / PhaseLabel; StatusMessage
// keeps the text detail (URLs, error messages). Same
// underlying flow, two views.
var progress = new Progress<OidcLoginPhase>(p => Phase = p);
await LoginInteractiveCoreAsync(_api, progress).ConfigureAwait(false);
IsBusy = false;
AccessToken = _api.CurrentAccessToken;
StatusMessage = "Interactive token acquired.";
LoginSuccess = true;
LoginSucceeded?.Invoke();
}
catch (Exception ex)
{
IsBusy = false;
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
StatusMessage = $"Error: {ex.Message}{suffix}";
}
}
/// <summary>
/// Single entry point for the OIDC login: YavscApiClient owns the
/// browser choice, the OidcClient instance, the token persistence
/// and the refresh path. The VM is just a thin coordinator.
/// </summary>
private async Task LoginInteractiveCoreAsync(
YavscApiClient api,
IProgress<OidcLoginPhase>? progress = null)
{
var original = Platform.CreateBrowser;
try
{
if (BrowserFactoryOverride is not null)
Platform.CreateBrowser = BrowserFactoryOverride;
await api.LoginInteractiveAsync(progress).ConfigureAwait(false);
}
finally
{
Platform.CreateBrowser = original;
}
}
/// <summary>
/// XDG-compliant path to the user settings file. Surfaced in the
/// "Configuration manquante" message so the operator knows exactly
/// which file to edit without having to dig through docs.
/// </summary>
private static string SettingsFileHint()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appData, "PostIt", "postit-settings.json");
}
/// <summary>
/// Build the on-disk <see cref="TokenStore"/> used by
/// <see cref="YavscApiClient"/>. The token bundle lives in
/// <c>~/.config/PostIt/tokens.json</c> on Linux; the same path
/// layout is used on every platform for predictability.
/// </summary>
private static TokenStore BuildTokenStore()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(appData, "PostIt", "tokens.json");
return new TokenStore(path);
}
}

View file

@ -0,0 +1,454 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.Views;
namespace PostIt.ViewModels;
public partial class MainPageViewModel : ViewModelBase
{
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
[ObservableProperty]
public partial string WindowTitle { get; set; }
/// <summary>Editor buffer for the post title. Bound TwoWay to
/// the title <c>TextBox</c> in <c>MainPage.axaml</c>. The Save
/// command reads from this buffer (not from
/// <see cref="SelectedPost"/>) so that typing into a freshly
/// mounted editor (no post selected yet) is captured. With the
/// 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>BlogPostDto</c> with an empty
/// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty]
public partial string DraftTitle { get; set; }
/// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary>
[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 bool DraftIsPublished { get; set; }
public Settings SettingsModel { get; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial string SearchText { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPostDto> Posts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
[ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial Settings Settings { get; private set; }
/// <summary>
/// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
/// <c>App.axaml.cs</c> so the same client (and its token store)
/// is shared with the login flow.
/// </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()
{
Init(null);
SettingsModel = new Settings();
BlogClient = null;
}
private void Init(Settings? settings)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
// Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh
// instance so the fixture can build a self-contained VM.
// The previous "?? new Settings()" silently worked in prod
// too, which is what allowed a second Settings instance to
// race the singleton and crash the postit://callback binding
// sink; that crash is fixed in Settings.OnPropertyChanged
// (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous.
Settings = settings ?? new Settings();
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
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, IServiceProvider? services = null)
{
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
Init(settings);
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPostDto? value)
{
// Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the
// selection is cleared (e.g. after a successful create
// rebinds to the server-issued record, or Delete
// nulls it out), the buffer is reset so the editor
// 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();
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
// Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
[RelayCommand]
internal async Task LoadPosts()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal void Search() => ApplyFilter();
[RelayCommand]
internal async Task Save()
{
// The button is already disabled when the title is empty
// (see CanSave), but the test path (and any programmatic
// ICommand.Execute) bypasses CanExecute, so we still
// guard here. Better to no-op with a status message
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
return;
}
await ExecuteAsync(async () =>
{
// 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)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
var created = await BlogClient.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task Delete()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
/// <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 async Task TogglePublish()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
/// <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()
{
var posts = await BlogClient.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
}
private void ApplyFilter()
{
if (Posts is null) return;
var query = SearchText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? Posts.OrderByDescending(p => p.DateModified)
: Posts.Where(p => p.Title?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.Article?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified);
FilteredPosts.Clear();
foreach (var post in filtered)
{
FilteredPosts.Add(post);
}
}
private async Task ExecuteAsync(Func<Task> action)
{
try
{
IsBusy = true;
StatusMessage = "Working...";
await action();
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void UpdateCommandStates()
{
LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
[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

@ -1,236 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using PostIt.Services;
namespace PostIt.ViewModels;
public partial class MainPageViewModel : ViewModelBase
{
[ObservableProperty]
public partial string Title { get; set; }
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public SettingsPageViewModel SettingsModel { get; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial string SearchText { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> Posts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
[ObservableProperty]
public partial BlogPost? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
ThemeVariant themeVariant = ThemeVariant.Default;
[ObservableProperty]
public partial Settings Settings { get; private set; }
/// <summary>
/// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
/// <c>App.axaml.cs</c> so the same client (and its token store)
/// is shared with the login flow.
/// </summary>
public BlogApiClient BlogClient { get; }
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>
/// 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)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPost>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
Settings = settings ?? new Settings();
Title = "PostIt";
CurrentViewModel = this;
SettingsModel = new SettingsPageViewModel();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPost? value) => UpdateCommandStates();
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
[RelayCommand]
internal async Task LoadPosts()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal void Search() => ApplyFilter();
[RelayCommand]
internal async Task Save()
{
if (SelectedPost is null)
{
StatusMessage = "A post must be selected before saving.";
return;
}
await ExecuteAsync(async () =>
{
if (SelectedPost.Id == 0)
{
SelectedPost.DateCreated = DateTime.UtcNow;
SelectedPost.DateModified = DateTime.UtcNow;
var created = await BlogClient.CreatePostAsync(SelectedPost);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
SelectedPost.DateModified = DateTime.UtcNow;
await BlogClient.UpdatePostAsync(SelectedPost.Id, SelectedPost);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task Delete()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
[RelayCommand]
internal void New()
{
SelectedPost = new BlogPost
{
Title = string.Empty,
Article = string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
StatusMessage = "New blog post ready.";
}
[RelayCommand]
internal void OpenSettings()
{
CurrentViewModel = SettingsModel;
}
private async Task RefreshPostsAsync()
{
var posts = await BlogClient.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
}
private void ApplyFilter()
{
if (Posts is null) return;
var query = SearchText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? Posts.OrderByDescending(p => p.DateModified)
: Posts.Where(p => p.Title?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.Article?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified);
FilteredPosts.Clear();
foreach (var post in filtered)
{
FilteredPosts.Add(post);
}
}
private async Task ExecuteAsync(Func<Task> action)
{
try
{
IsBusy = true;
StatusMessage = "Working...";
await action();
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void UpdateCommandStates()
{
LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
NewCommand.NotifyCanExecuteChanged();
}
private bool CanSave() => SelectedPost is not null && !IsBusy;
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
}

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

@ -1,5 +1,8 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -9,7 +12,11 @@ namespace PostIt.ViewModels;
/// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s /// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s
/// session state ("Connecté" / "Déconnecté") and exposes a /// session state ("Connecté" / "Déconnecté") and exposes a
/// <c>Logout</c> command that purges the token store and asks the /// <c>Logout</c> command that purges the token store and asks the
/// navigation owner to route the user back to <c>HomePage</c>. /// navigation owner to route the user back to <c>HomePage</c>, plus
/// a <c>Login</c> command that drives the OIDC interactive flow
/// and raises a <see cref="LoginSucceeded"/> event on success so
/// <c>MainWindow</c> can push <c>MainPage</c> on top of
/// <c>HomePage</c>.
/// ///
/// Construction is deferred until the API client exists; the /// Construction is deferred until the API client exists; the
/// App.axaml.cs wiring sets <see cref="Api"/> after building both, /// App.axaml.cs wiring sets <see cref="Api"/> after building both,
@ -21,12 +28,29 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary> /// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary>
public event System.Action? LogoutCompleted; public event System.Action? LogoutCompleted;
/// <summary>Raised after <see cref="LoginAsync"/> acquired a valid session;
/// <c>App.axaml.cs</c> listens and pushes <c>MainPage</c> on top of
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
[ObservableProperty] [ObservableProperty]
public partial bool IsLoggedIn { get; private set; } public partial bool IsLoggedIn { get; private set; }
/// <summary>Inverse of <see cref="IsLoggedIn"/>, for XAML bindings
/// (the banner shows the Login button when the user is logged out).
/// Updated from <see cref="Refresh"/>.</summary>
[ObservableProperty]
public partial bool IsLoggedOut { get; private set; } = true;
[ObservableProperty] [ObservableProperty]
public partial string SessionLabel { get; private set; } = "Déconnecté"; public partial string SessionLabel { get; private set; } = "Déconnecté";
/// <summary>True while a Login flow is in flight; the Login button
/// binds <c>IsEnabled</c> to <c>!IsBusy</c> via
/// <see cref="LoginCommand"/>'s <c>CanExecute</c>.</summary>
[ObservableProperty]
public partial bool IsBusy { get; private set; }
/// <summary>The API client backing the banner. Set once at startup; /// <summary>The API client backing the banner. Set once at startup;
/// the banner polls <c>HasValidSession</c> on demand rather than /// the banner polls <c>HasValidSession</c> on demand rather than
/// subscribing to a stream — the session state only changes at /// subscribing to a stream — the session state only changes at
@ -50,9 +74,58 @@ public partial class SessionStatusViewModel : ViewModelBase
{ {
var has = Api?.HasValidSession ?? false; var has = Api?.HasValidSession ?? false;
IsLoggedIn = has; IsLoggedIn = has;
IsLoggedOut = !has;
SessionLabel = has ? "Connecté" : "Déconnecté"; SessionLabel = has ? "Connecté" : "Déconnecté";
} }
/// <summary>
/// Override the banner label with an error message. Used when
/// an interactive login attempt fails so the operator sees
/// something on the persistent UI without us needing a
/// dedicated error page. The next <see cref="Refresh"/> call
/// reverts to "Connecté" / "Déconnecté".
/// </summary>
public void SetError(string message)
{
IsLoggedIn = false;
IsLoggedOut = true;
SessionLabel = message;
}
/// <summary>
/// Drive the OIDC interactive login. On success, refreshes
/// the banner state and raises <see cref="LoginSucceeded"/> so
/// the navigation owner can push <c>MainPage</c>. On failure,
/// surfaces the error in the banner via <see cref="SetError"/>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanLogin))]
public async Task LoginAsync()
{
if (Api is null) return;
IsBusy = true;
try
{
await Api.LoginInteractiveAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
SetError($"Login failed: {ex.Message}");
return;
}
finally
{
IsBusy = false;
}
Refresh();
if (Api.HasValidSession)
LoginSucceeded?.Invoke();
}
private bool CanLogin() => !IsBusy;
partial void OnIsBusyChanged(bool value) => LoginCommand.NotifyCanExecuteChanged();
[RelayCommand] [RelayCommand]
public async System.Threading.Tasks.Task LogoutAsync() public async System.Threading.Tasks.Task LogoutAsync()
{ {
@ -61,4 +134,12 @@ public partial class SessionStatusViewModel : ViewModelBase
Refresh(); Refresh();
LogoutCompleted?.Invoke(); LogoutCompleted?.Invoke();
} }
[RelayCommand]
internal async Task OpenSettings()
{
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
}
} }

View file

@ -0,0 +1,463 @@
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")]
namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
/// at application boot by <see cref="App.OnFrameworkInitializationCompleted"/>
/// through <see cref="BindToServiceProvider"/>. The hybrid pattern:
/// <list type="bullet">
/// <item><description>The static <c>Current</c> reference gives
/// ViewModels a non-DI way to reach the same instance (and lets
/// the framework bindings push notifications through one stable
/// <see cref="ObservableObject"/>).</description></item>
/// <item><description>Tests that want to exercise a clean
/// instance still call <c>new Settings()</c>; <c>Current</c>
/// stays null in those contexts because <see cref="BindToServiceProvider"/>
/// is never invoked.</description></item>
/// <item><description>Reads (<see cref="GetCurrent"/>) are
/// thread-safe and never allocate; mutations always go through
/// the DI-resolved singleton so two threads cannot each register
/// a different "current" Settings.</description></item>
/// </list>
/// </summary>
private static Settings? s_current;
/// <summary>
/// Wire the canonical Settings instance to a DI container. Called
/// exactly once from <c>App.axaml.cs</c> after the singleton has
/// been registered. Subsequent calls are no-ops: the DI container
/// owns the instance lifetime and we don't want a stray
/// <c>BindToServiceProvider</c> in a test fixture to silently
/// rebind the production instance.
/// </summary>
public static void BindToServiceProvider(IServiceProvider services)
{
if (services is null) throw new ArgumentNullException(nameof(services));
Interlocked.CompareExchange(ref s_current,
services.GetService<Settings>() ?? throw new InvalidOperationException(
"Settings is not registered in the DI container."),
null);
}
/// <summary>
/// Returns the canonical Settings instance previously bound through
/// <see cref="BindToServiceProvider"/>, or <c>null</c> when called
/// outside a running Avalonia application (tests, CLI tools).
/// </summary>
public static Settings? GetCurrent() => Volatile.Read(ref s_current);
/// <summary>
/// Resolve the canonical Settings instance or throw. Use this in
/// production code paths that must not silently fall back to a
/// freshly-constructed <see cref="Settings"/> (which used to be
/// the root cause of the postit://callback crash: two Settings
/// instances racing on PropertyChanged from different threads).
/// </summary>
public static Settings RequireCurrent() =>
GetCurrent() ?? throw new InvalidOperationException(
"Settings.Current is not bound. Call App.OnFrameworkInitializationCompleted first.");
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
[ObservableProperty]
public partial bool DarkMode { get; set; } = false;
[ObservableProperty]
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
/// <summary>
/// Catch top-level mutations: the four ObservableProperty
/// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user
/// edit.
/// </summary>
private void MarkDirty() => IsDirty = true;
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
/// <c>PropertyChanged</c> listener so sub-property edits
/// (Authority, ClientId, RedirectUri, Scopes) are picked up
/// by the dirty tracker. We don't filter on PropertyName: any
/// nested setter is treated as a user edit, which matches the
/// user's mental model ("I typed in a field, the page is now
/// dirty").
/// </summary>
partial void OnAuthenticationChanged(AuthenticationSettings value)
{
if (value is not null)
{
value.PropertyChanged += (_, _) => MarkDirty();
}
MarkDirty();
}
public bool Loaded { get; private set; } = false;
/// <summary>
/// True when the in-memory state has drifted from the last
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
/// this flag, so it only enables when the user has actually
/// touched something since the last load / save. Cleared by
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
/// every successful setter on the four top-level mutable
/// properties and on the sub-properties of
/// <see cref="Authentication"/>.
/// </summary>
[ObservableProperty]
public partial bool IsDirty { get; private set; } = false;
/// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
/// generates setters that call <c>SetProperty(...)</c> which fires
/// <c>PropertyChanged</c>. Avalonia bindings consume that event on
/// the UI thread, and a stray background-thread update is exactly
/// what crashed <c>DataValidationErrors.SetErrors</c> on
/// <c>postit://callback</c> re-launches. The lock makes mutations
/// atomic; <see cref="OnPropertyChanged(PropertyChangedEventArgs)"/>
/// then marshals the notification onto the UI thread so bindings
/// observe the change on the right thread.
/// </summary>
private readonly object _mutationGate = new();
/// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE
/// (no client secret). The browser implementation should be supplied
/// per-platform by the caller.
/// </summary>
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
{
if (!Loaded) Load();
// Snapshot under the gate so the caller observes a consistent
// view of all six properties; without this, a concurrent
// Load() could swap Authentication mid-method and we would
// build options from a torn read.
lock (_mutationGate)
{
var options = new OidcClientOptions
{
Authority = Authentication.Authority,
ClientId = Authentication.ClientId,
RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = Authentication.Authority,
// PKCE is enabled by default when no client_secret is provided.
};
if (IsDevelopmentEnvironment())
{
// Dev only: allow local/self-signed TLS for discovery/token
// endpoints when the machine does not trust a custom root.
options.BackchannelHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
}
if (browser is not null)
options.Browser = browser;
return options;
}
}
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
/// about the contract: a missing scope here surfaces as a 401
/// on the very first API call after login, with no obvious link
/// to the settings. The "feature" scopes the user must opt into
/// (e.g. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
private static readonly string[] BuiltInScopes = new[]
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token
};
/// <summary>
/// Merge user-configured scopes with the built-in ones. User scopes
/// come first (preserves author intent), then the built-ins, with
/// duplicates removed case-sensitively. <c>null</c> or empty input
/// is fine — we still emit the built-ins.
/// </summary>
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
if (userScopes is not null)
{
foreach (var s in userScopes)
{
if (string.IsNullOrWhiteSpace(s)) continue;
if (seen.Add(s)) yield return s;
}
}
foreach (var s in BuiltInScopes)
{
if (seen.Add(s)) yield return s;
}
}
private static bool IsDevelopmentEnvironment()
{
return string.Equals(
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
"Development",
StringComparison.OrdinalIgnoreCase);
}
internal void Load()
{
if (Loaded) return;
// Trust an already-populated Authority: tests pre-fill Settings
// with the OIDC stub's random loopback port, and programmatic
// callers (CLI flags, integration tests) wire their own. If we
// fall through to the disk / embedded read here we'd silently
// overwrite their value with the bundled default
// (yavsc.pschneider.fr), break the stubbed discovery URL, and
// turn a passing login into an invalid_grant.
lock (_mutationGate)
{
if (Loaded) return; // double-check after taking the gate
if (!string.IsNullOrWhiteSpace(Authentication?.Authority))
{
Loaded = true;
return;
}
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
Directory.CreateDirectory(configDir);
string configPath = Path.Combine(configDir, SettingsFileName);
FileInfo configFileInfo = new FileInfo(configPath);
if (!configFileInfo.Exists)
{
Console.Error.WriteLine($"🩎 Settings file not found at {configFileInfo.FullName}");
// No user-level config: fall back to the embedded default.
// We only get here when Authentication.Authority is empty
// (the early-return above) so the redundant guard is gone.
if (!TryLoadEmbeddedFallback())
{
Console.Error.WriteLine("🩎 No embedded default settings; running with empty configuration.");
}
return;
}
Console.WriteLine($"🔎 Loading settings from {configFileInfo.FullName}");
try
{
// Synchronous read on purpose: Settings.Load() is called from
// synchronous startup paths (App.axaml.cs, ViewModel ctors,
// tests) and bridging to async here with .Wait() / .GetAwaiter()
// .GetResult() deadlocks the Avalonia UI thread because the
// continuation can't resume on the same thread. The settings
// file is a few KiB at most; async I/O gains nothing here.
using var stream = configFileInfo.OpenRead();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
ApplyJson(json, $"user file {configFileInfo.FullName}");
Loaded = true;
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error loading settings: {ex.Message}");
}
}
private bool TryLoadEmbeddedFallback()
{
const string ResourceName = "PostIt.postit-settings.json";
var assembly = typeof(Settings).Assembly;
using var stream = assembly.GetManifestResourceStream(ResourceName);
if (stream is null)
{
Console.Error.WriteLine($"🩎 Embedded resource {ResourceName} not found.");
return false;
}
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
{
Console.Error.WriteLine("🩎 Embedded settings resource is empty.");
return false;
}
Console.WriteLine($"🔎 Loading embedded default settings ({ResourceName}).");
ApplyJson(json, $"embedded resource {ResourceName}");
return true;
}
private void ApplyJson(string json, string source)
{
if (string.IsNullOrWhiteSpace(json))
{
Console.Error.WriteLine($"🩎 Settings payload is empty (source: {source}).");
return;
}
try
{
var settings = JsonSerializer.Deserialize<Settings>(json);
if (settings is null)
{
Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source}).");
return;
}
// Apply under the gate so concurrent Load() callers cannot
// see half the new values / half the old ones. The actual
// PropertyChanged fan-out is handled by [ObservableProperty]'s
// setters which we route through SetProperty → OnPropertyChanged
// → our overridden dispatcher-safe marshaller below.
lock (_mutationGate)
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ?
AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority;
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
// A disk load (or an embedded-resource fallback) is the
// baseline, not a user edit. Clear the dirty flag last
// so the OnAuthenticationChanged / sub-property fan-out
// triggered by the assignments above doesn't leave it
// stuck at true.
IsDirty = false;
// Refresh the space-separated ScopeListText view after
// hydration so the SettingsPage TextBox reflects the
// loaded scopes (and not the default empty string the
// ObservableProperty was constructed with). OnScopesChanged
// already tries to do this, but it skips when the new
// array parses to the same text — calling explicitly
// forces a re-sync and normalises any whitespace the
// JSON might have introduced.
this.Authentication?.RefreshScopeListText();
// Re-notify the command in case the button was bound
// before Load finished and the CanExecute cache is
// stale.
SaveCommand.NotifyCanExecuteChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}");
}
}
/// <summary>
/// Persist the current in-memory state to
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
/// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
/// (Windows). Symmetrical to <see cref="Load"/>: same path,
/// same directory creation, same <c>0600</c> file mode (POSIX)
/// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
/// on success.
///
/// <para>Synchronous on purpose: matches <see cref="Load"/>'s
/// contract (the file is a few KiB at most, and the Avalonia
/// UI thread cannot await here without risking the same
/// deadlock <see cref="Load"/>'s docstring describes).
/// </para>
/// </summary>
[RelayCommand(CanExecute = nameof(CanSave))]
public void Save()
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
Directory.CreateDirectory(configDir);
var configPath = Path.Combine(configDir, SettingsFileName);
lock (_mutationGate)
{
try
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true,
});
File.WriteAllText(configPath, json);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
File.SetUnixFileMode(configPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
IsDirty = false;
Console.WriteLine($"💾 Settings saved to {configPath}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
throw;
}
}
}
private bool CanSave() => IsDirty;
/// <summary>
/// Re-notify the <c>SaveCommand</c> (generated by
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
/// / <see cref="ApplyJson"/>).
/// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -1,18 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public partial class SettingsPageViewModel : ViewModelBase
{
[ObservableProperty]
public partial bool DarkMode { get; set; }
[ObservableProperty]
public partial string Authority { get; set; }
[ObservableProperty]
public partial string ClientId { get; set; }
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}

View file

@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Controls;
using PostIt.Models;
namespace PostIt.ViewModels;
/// <summary>
/// Backing state for <see cref="PostIt.Views.SignaturePage"/>.
///
/// The page exists to produce a <see cref="SignaturePadData"/>
/// (length-prefixed normalised int[]) from a human signature drawn
/// with the mouse (Desktop) or finger (touch / Android). The page
/// is a recipient of an external trigger — a SignalR push from
/// Yavsc.Org telling PostIt "a devis has been sent, sign here" —
/// so it intentionally has no first-class entry point in
/// <see cref="MainPage"/>. The only "open" affordance today is a
/// dev-only shortcut on the blog editor, marked for removal once
/// the SignalR handler lands.
///
/// Output path is the platform-friendly per-user data directory
/// (XDG_DATA_HOME / AppData / NSDocumentDirectory on iOS). Files
/// are JSON, one per capture, named
/// <c>signature-{yyyyMMdd-HHmmssfff}.json</c>. This is a stop-gap
/// until the Yavsc.Org endpoint exists; the contract there will
/// be <c>POST /api/signature/{devisId}</c> with this same payload.
/// </summary>
public partial class SignaturePageViewModel : ViewModelBase
{
/// <summary>
/// Default capture surface, in DIPs. 3:1 ratio matches a
/// signature line at the bottom of an A4 contract.
/// </summary>
public const double DefaultWidth = 600;
public const double DefaultHeight = 200;
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial int StrokeCount { get; set; }
[ObservableProperty]
public partial int PointCount { get; set; }
[ObservableProperty]
public partial string? LastCapturedPath { get; set; }
public double Width { get; }
public double Height { get; }
private SignaturePadControl? _control;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public SignaturePageViewModel()
: this(DefaultWidth, DefaultHeight)
{
}
public SignaturePageViewModel(double width, double height)
{
if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width));
if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height));
Width = width;
Height = height;
}
/// <summary>
/// Bind a freshly-constructed (or re-templated) control to this
/// VM. Called from the view's code-behind once the control has
/// been added to the visual tree and its template applied (so
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
/// </summary>
public void Attach(SignaturePadControl control)
{
if (control is null) throw new ArgumentNullException(nameof(control));
Detach();
_control = control;
_control.RedrawRequested += OnRedraw;
_control.StrokeCompleted += OnStrokeCompleted;
RefreshCounts();
}
public void Detach()
{
if (_control is null) return;
_control.RedrawRequested -= OnRedraw;
_control.StrokeCompleted -= OnStrokeCompleted;
_control = null;
}
private void OnStrokeCompleted(object? sender, SignaturePadData data)
{
StatusMessage = $"Trait terminé. {data.StrokeCount} trait(s).";
RefreshCounts();
}
private void OnRedraw(object? sender, EventArgs e) => RefreshCounts();
private void RefreshCounts()
{
if (_control is null) return;
var snap = _control.Snapshot();
StrokeCount = snap.StrokeCount;
PointCount = snap.PointCount;
}
[RelayCommand]
public void Clear()
{
_control?.Clear();
StatusMessage = "Effacé.";
RefreshCounts();
}
[RelayCommand]
public async Task CaptureAsync()
{
if (_control is null)
{
StatusMessage = "Contrôle non attaché.";
return;
}
var data = _control.Snapshot();
if (data.IsEmpty)
{
StatusMessage = "Rien à capturer.";
return;
}
try
{
var path = WriteCapture(data);
LastCapturedPath = path;
StatusMessage = $"Capture enregistrée: {path}";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
await Task.CompletedTask;
}
private static string WriteCapture(SignaturePadData data)
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PostIt", "signatures");
Directory.CreateDirectory(dir);
var fileName = $"signature-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.json";
var path = Path.Combine(dir, fileName);
var payload = new
{
format = "yavsc.signature/v1",
coordinateMax = SignaturePadData.CoordinateMax,
capturedAtUtc = DateTime.UtcNow,
strokes = data.Strokes,
strokeCount = data.StrokeCount,
};
File.WriteAllText(
path,
JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }),
Encoding.UTF8);
return path;
}
}

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

@ -1,7 +1,12 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.HomePage" x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel"
Header="Home"> Header="Home">
<Design.DataContext>
<vm:HomePageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Spacing="12"> Spacing="12">
@ -9,8 +14,9 @@
FontSize="22" FontSize="22"
FontWeight="SemiBold" FontWeight="SemiBold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<Button Content="Login" <Button Content="Open Blog Interface"
Click="OnLoginClick" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -1,8 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -12,19 +8,4 @@ public partial class HomePage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
private void OnLoginClick(object? sender, RoutedEventArgs e)
{
var vm = (HomePageViewModel)DataContext!;
var loginVm = new LoginPageViewModel(vm.Settings, apiClient: vm.Api);
loginVm.LoginSucceeded += () =>
{
var client = new BlogApiClient(vm.Api);
Navigation?.PushAsync(new MainPage
{
DataContext = new MainPageViewModel(client, vm.Settings)
});
};
Navigation?.PushAsync(new LoginPage { DataContext = loginVm });
}
} }

View file

@ -1,86 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.LoginPage"
x:DataType="vm:LoginPageViewModel"
Header="Login">
<Design.DataContext>
<vm:LoginPageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="20">
<Border IsVisible="{Binding ConfigMissing}"
Background="#FFF3CD"
BorderBrush="#E0A800"
BorderThickness="1"
CornerRadius="4"
Padding="10">
<TextBlock Text="{Binding ConfigMissingMessage}"
TextWrapping="Wrap"
Foreground="#7A5800"/>
</Border>
<TextBlock Text="Sign In"
FontSize="24"
HorizontalAlignment="Center"/>
<StackPanel Spacing="4">
<TextBlock Text="Email"/>
<TextBox Name="EmailBox"
PlaceholderText="Enter your email"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Password"/>
<TextBox Name="PasswordBox"
PlaceholderText="Enter your password"
PasswordChar="•"/>
</StackPanel>
<Button Content="Login"
Command="{Binding LoginAsync}"/>
<Button Content="Cancel"
Click="OnCancelClick"/>
<Button Content="Register a new account"
IsEnabled="{Binding HasRegisterUrl}"
Click="OnRegisterClick"/>
<Button Content="Forgot password?"
IsEnabled="{Binding HasForgotPasswordUrl}"
Click="OnForgotPasswordClick"/>
<TextBox Name="StatusText"
Text="{Binding StatusMessage}"
TextWrapping="Wrap"
IsReadOnly="True"
BorderThickness="0"
Background="Transparent"/>
<!--
Phase indicator: a single-line label bound to the OIDC flow
phase (Discovering / OpeningBrowser / AwaitingCallback / …).
Operators use this to debug the postit:// callback hand-off:
if AwaitingCallback never advances to ExchangingCode, the OS
never re-launched PostIt with the callback URL. Kept as a
discrete control (not folded into StatusMessage) so the phase
always renders even when StatusMessage is empty or stale.
-->
<Border Background="#EEF2F7"
BorderBrush="#B0BEC5"
BorderThickness="1"
CornerRadius="4"
Padding="6,4">
<TextBlock Text="{Binding PhaseLabel}"
FontWeight="SemiBold"
Foreground="#37474F"/>
</Border>
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
</StackPanel>
</ContentPage>

View file

@ -1,55 +0,0 @@
using System;
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Interactivity;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class LoginPage : ContentPage
{
public LoginPage()
{
InitializeComponent();
// HomePage pushes LoginPage via PushModalAsync(new LoginPage())
// without supplying a DataContext. Attach a freshly-built
// LoginPageViewModel whenever the caller hasn't wired one up,
// so XAML bindings and LoginAsyncCommand resolve.
if (DataContext is null)
DataContext = new LoginPageViewModel();
}
private async void OnCancelClick(object? sender, RoutedEventArgs e)
{
// Cancel button dismisses all open modals
if (Navigation is not null)
await Navigation.PopAllModalsAsync();
}
private void OnRegisterClick(object? sender, RoutedEventArgs e)
{
OpenExternalUrl((DataContext as LoginPageViewModel)?.RegisterUrl);
}
private void OnForgotPasswordClick(object? sender, RoutedEventArgs e)
{
OpenExternalUrl((DataContext as LoginPageViewModel)?.ForgotPasswordUrl);
}
private static void OpenExternalUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return;
// Desktop launcher: shell-execute the URL so the OS picks the right handler.
// Platform projects (PostIt.Android, PostIt.Browser) override this behavior
// when they plug into the LoginPage lifecycle.
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open external URL {url}: {ex.Message}");
}
}
}

View file

@ -1,60 +1,113 @@
<NavigationPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:PostIt.Models" xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="PostIt.Views.MainPage" x:Class="PostIt.Views.MainPage"
x:DataType="vm:MainPageViewModel" x:DataType="vm:MainPageViewModel"
HorizontalAlignment="Center" HorizontalAlignment="Stretch"
VerticalAlignment="Center" > VerticalAlignment="Stretch">
<Design.DataContext> <Design.DataContext>
<vm:MainPageViewModel /> <vm:MainPageViewModel />
</Design.DataContext> </Design.DataContext>
<StackPanel Margin="12" Spacing="12" HorizontalAlignment="Center" > <Grid Margin="12" RowSpacing="12"
<TextBlock Text="PostIt Blog API Interface" FontSize="20" FontWeight="Bold" /> HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Spacing="8" > <StackPanel Grid.Row="0" Spacing="12"
<Button Command="{Binding LoadPosts}" Content="Load posts" /> HorizontalAlignment="Stretch"
<Button Command="{Binding Search}" Content="Filter" /> VerticalAlignment="Top">
<Button Command="{Binding New}" Content="New post" />
<Button Command="{Binding Save}" Content="Save" /> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding Delete}" Content="Delete" /> <Button Command="{Binding LoadPosts}" Content="Load posts" />
<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
push from Yavsc.Org ("devis received, sign here").
Remove this button and its Click handler in
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel> </StackPanel>
<Border BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPost">
<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" />
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Border BorderBrush="Gray" BorderThickness="1" Padding="8"> <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<StackPanel Spacing="10"> <ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
<TextBlock Text="Post detail" FontWeight="SemiBold" /> HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40">
<TextBox Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" /> <ListBox.ItemTemplate>
<TextBox Text="{Binding SelectedPost.AuthorId, Mode=TwoWay}" PlaceholderText="Author id" /> <DataTemplate x:DataType="models:BlogPostDto">
<AvaloniaEdit:TextEditor <StackPanel Spacing="4">
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}" <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
ShowLineNumbers="True" <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
FontFamily="Cascadia Code, Consolas, Menlo, Monospace" <TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
Height="320" </StackPanel>
VerticalScrollBarVisibility="Auto" </DataTemplate>
HorizontalScrollBarVisibility="Auto" /> </ListBox.ItemTemplate>
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" /> </ListBox>
</StackPanel> </Border>
</Border>
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8">
</StackPanel> <Grid RowSpacing="10"
</NavigationPage> HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<AvaloniaEdit:TextEditor Grid.Row="2"
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
MinHeight="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid>
</Border>
</Grid>
</ContentPage>

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