Compare commits

...

30 commits

Author SHA1 Message Date
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
828 changed files with 7814 additions and 217113 deletions

View file

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

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
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`

2
.vscode/launch.json vendored
View file

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

11
.vscode/mcp.json vendored Normal file
View file

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

14
.vscode/settings.json vendored
View file

@ -26,5 +26,17 @@
"cSpell.language": "fr,en",
"makefile.configureOnOpen": false,
"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"
]
}
}
}

View file

@ -2,20 +2,11 @@
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</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>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.7.0" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.0.5-preview-net9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.0.5-preview-net9" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
<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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
@ -24,7 +15,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" 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="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />

View file

@ -48,7 +48,7 @@ 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
RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json --allow-insecure-connections
# (4) Restore
RUN dotnet restore

View file

@ -26,7 +26,7 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
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
RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json
# 4. Restauration des dépendances pour tous les projets
RUN dotnet restore

View file

@ -10,8 +10,8 @@ include .env
all:
dotnet build --nologo
clean:
dotnet clean
clean:
dotnet clean -c $(CONFIG)
src/Yavsc/bin/output/wwwroot:
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:
dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj
$(DESTDIR):
$(DESTDIR):
mkdir $(DESTDIR)
install: $(DESTDIR)

View file

@ -152,6 +152,13 @@ d'abord `appsettings-org.json` du serveur ; sinon, laisse-le en place.
(utilisateur, mot de passe, hôte, base). Privilégier
`dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*`
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
transactionnels.
- `Authentication.PayPal.*` et `Authentication.Google.*` — clés d'API

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.

View file

@ -10,7 +10,7 @@ namespace PostIt.Tests;
/// URL emitted by OidcClient, extracts its <c>state</c>, and returns a
/// 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.
/// </summary>
public sealed class FakeAuthorizingBrowser

View file

@ -14,7 +14,7 @@ public class LoginPageViewModelTests
// 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();
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
@ -96,7 +96,7 @@ public class LoginPageViewModelTests
// double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient
// must bridge.
using var authority = await OidcStubAuthority.StartAsync();
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
@ -254,4 +254,4 @@ public class LoginPageViewModelTests
"https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage);
}
}
}

View file

@ -20,7 +20,7 @@ namespace PostIt.Tests;
/// the browser intercepts the authorize redirect, the server completes
/// the token exchange.
/// </summary>
public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
{
private readonly HttpListener _listener;
private readonly RSA _rsa;
@ -30,7 +30,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
public string Issuer { 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;
_rsa = rsa;
@ -39,7 +39,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
LoopbackRedirectUri = loopback;
}
public static async Task<OidcStubAuthority> StartAsync()
public static async Task<OIDCStubAuthority> StartAsync()
{
// Pick a free loopback port.
var port = GetFreePort();
@ -53,7 +53,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
var rsa = RSA.Create(2048);
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));
return authority;
}

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

@ -20,7 +20,7 @@ namespace PostIt.Tests;
/// End-to-end coverage of <see cref="YavscApiClient"/>: silent
/// refresh on a near-expiry access token, 401-driven refresh + retry,
/// 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.
/// </summary>
public class YavscApiClientTests
@ -45,7 +45,7 @@ public class YavscApiClientTests
// in-memory access token as expired and re-run a call. The
// refresh path must rotate the refresh token transparently
// 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();
await apiServer.StartAsync();
@ -63,7 +63,7 @@ public class YavscApiClientTests
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts");
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotNull(posts);
Assert.NotEmpty(posts);
@ -85,7 +85,7 @@ public class YavscApiClientTests
{
// API server returns 401 on the first request, 200 on the next.
// 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);
await apiServer.StartAsync();
@ -97,7 +97,7 @@ public class YavscApiClientTests
settings, authority, tokensPath);
var posts = await client.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts");
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotEmpty(posts);
Assert.Equal(2, apiServer.RequestCount);
@ -125,14 +125,15 @@ public class YavscApiClientTests
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts"));
await Assert.ThrowsAsync<InvalidOperationException>(
() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
}
[Fact]
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();
await apiServer.StartAsync();
@ -154,7 +155,7 @@ public class YavscApiClientTests
// --- helpers --------------------------------------------------------
private static PostIt.Settings BuildSettings(OidcStubAuthority authority, string apiBaseUrl) => new()
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{
Authentication = new AuthenticationSettings
{
@ -167,7 +168,7 @@ public class YavscApiClientTests
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
PostIt.Settings settings, OidcStubAuthority authority, string tokensPath)
PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath)
{
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
@ -181,7 +182,7 @@ public class YavscApiClientTests
/// <summary>
/// YavscApiClient.LoginInteractiveAsync delegates to
/// 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.
/// </summary>
private static async Task LoginWithBrowserAsync(
@ -214,7 +215,7 @@ public class YavscApiClientTests
File.WriteAllText(tokensPath, JsonSerializer.Serialize(record));
}
// --- OidcLoginPhase progress tests ---------------------------------
// --- OIDCLoginPhase progress tests ---------------------------------
/// <summary>
/// Collecting Progress<T> is documented to capture reports
@ -225,7 +226,7 @@ public class YavscApiClientTests
[Fact]
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();
await apiServer.StartAsync();
@ -234,18 +235,18 @@ public class YavscApiClientTests
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var reported = new System.Collections.Generic.List<OidcLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported);
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
var progress = new SyncProgress<OIDCLoginPhase>(reported);
try
{
await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress);
// SyncProgress captures reports synchronously — no flush needed.
Assert.Contains(OidcLoginPhase.Discovering, reported);
Assert.Contains(OidcLoginPhase.OpeningBrowser, reported);
Assert.Contains(OidcLoginPhase.ExchangingCode, reported);
Assert.Equal(OidcLoginPhase.Success, Last(reported));
Assert.Contains(OIDCLoginPhase.Discovering, reported);
Assert.Contains(OIDCLoginPhase.OpeningBrowser, reported);
Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
Assert.Equal(OIDCLoginPhase.Success, Last(reported));
}
finally
{
@ -256,24 +257,24 @@ public class YavscApiClientTests
[Fact]
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();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var client = new YavscApiClient(settings, new TokenStore(TokensPath()));
var reported = new System.Collections.Generic.List<OidcLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported);
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
var progress = new SyncProgress<OIDCLoginPhase>(reported);
var original = Platform.CreateBrowser;
try
{
Platform.CreateBrowser = () => null; // simulate no browser wired up
await Assert.ThrowsAsync<InvalidOperationException>(
() => client.LoginInteractiveAsync(progress));
() => client.LoginInteractiveAsync(progress, TestContext.Current.CancellationToken));
// SyncProgress captures reports synchronously — no flush needed.
Assert.Equal(OidcLoginPhase.Error, Last(reported));
Assert.Equal(OIDCLoginPhase.Error, Last(reported));
}
finally
{
@ -284,7 +285,7 @@ public class YavscApiClientTests
[Fact]
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();
await apiServer.StartAsync();
@ -293,7 +294,7 @@ public class YavscApiClientTests
// Tokens file deliberately doesn't exist.
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(client.HasValidSession);
}
@ -301,7 +302,7 @@ public class YavscApiClientTests
[Fact]
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();
await apiServer.StartAsync();
@ -314,7 +315,7 @@ public class YavscApiClientTests
{
await LoginWithBrowserAsync(client, browser.CreateBrowser());
// 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(client.HasValidSession);
}
@ -327,7 +328,7 @@ public class YavscApiClientTests
[Fact]
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();
await apiServer.StartAsync();
@ -351,13 +352,13 @@ public class YavscApiClientTests
// matches the disk: access expired, refresh still good.
var client = new YavscApiClient(settings, store);
var reported = new System.Collections.Generic.List<OidcLoginPhase>();
var progress = new SyncProgress<OidcLoginPhase>(reported);
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
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.Contains(OidcLoginPhase.ExchangingCode, reported);
Assert.Equal(OidcLoginPhase.Success, Last(reported));
Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
Assert.Equal(OIDCLoginPhase.Success, Last(reported));
}
finally
{
@ -430,7 +431,7 @@ public class YavscApiClientTests
/// overload stays for tests that don't care about phase events.
/// </summary>
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;
try

View file

@ -63,6 +63,7 @@ public partial class App : Application
services.AddTransient<LoginPage>();
services.AddTransient<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
// ViewModels
services.AddSingleton(settings);
@ -72,6 +73,7 @@ public partial class App : Application
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<LoginPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.

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

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

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

View file

@ -91,15 +91,15 @@ public class YavscApiClient : IAsyncDisposable
/// <see cref="LoginPageViewModel.StatusMessage"/> for the human
/// text (URLs, error detail).</param>
public async Task LoginInteractiveAsync(
IProgress<OidcLoginPhase>? progress = null,
IProgress<OIDCLoginPhase>? progress = null,
CancellationToken ct = default)
{
progress?.Report(OidcLoginPhase.Discovering);
progress?.Report(OIDCLoginPhase.Discovering);
var browser = Platform.CreateBrowser?.Invoke();
if (browser is null)
{
progress?.Report(OidcLoginPhase.Error);
progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException("No browser is available on this platform.");
}
@ -114,20 +114,20 @@ public class YavscApiClient : IAsyncDisposable
// the moment we ask the browser to open (covers the entire
// user-driven window including the AwaitingCallback wait), and
// 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);
if (result.IsError)
{
progress?.Report(OidcLoginPhase.Error);
progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException($"OIDC login failed: {result.Error}");
}
progress?.Report(OidcLoginPhase.ExchangingCode);
progress?.Report(OIDCLoginPhase.ExchangingCode);
if (string.IsNullOrEmpty(result.RefreshToken))
{
progress?.Report(OidcLoginPhase.Error);
progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException(
"Missing refresh_token — vérifie le scope 'offline_access'.");
}
@ -139,7 +139,7 @@ public class YavscApiClient : IAsyncDisposable
IdToken: result.IdentityToken);
_store.Save(_tokens);
progress?.Report(OidcLoginPhase.Success);
progress?.Report(OIDCLoginPhase.Success);
}
/// <summary>
@ -152,7 +152,7 @@ public class YavscApiClient : IAsyncDisposable
/// phase and returns false so the UI can keep going.
/// </summary>
public async Task<bool> TrySilentLoginAsync(
IProgress<OidcLoginPhase>? progress = null,
IProgress<OIDCLoginPhase>? progress = null,
CancellationToken ct = default)
{
if (!HasValidSession) return false;
@ -161,7 +161,7 @@ public class YavscApiClient : IAsyncDisposable
// Access token still has plenty of life — nothing to do.
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
{
progress?.Report(OidcLoginPhase.Success);
progress?.Report(OIDCLoginPhase.Success);
return true;
}
@ -172,19 +172,19 @@ public class YavscApiClient : IAsyncDisposable
// the user back to the login page.
try
{
progress?.Report(OidcLoginPhase.ExchangingCode);
progress?.Report(OIDCLoginPhase.ExchangingCode);
await ForceRefreshAsync(ct).ConfigureAwait(false);
progress?.Report(OidcLoginPhase.Success);
progress?.Report(OIDCLoginPhase.Success);
return true;
}
catch (RefreshFailedException)
{
progress?.Report(OidcLoginPhase.Idle);
progress?.Report(OIDCLoginPhase.Idle);
return false;
}
catch
{
progress?.Report(OidcLoginPhase.Idle);
progress?.Report(OIDCLoginPhase.Idle);
return false;
}
}
@ -205,6 +205,16 @@ public class YavscApiClient : IAsyncDisposable
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>
public async Task CallAsync(
HttpMethod method,
@ -216,6 +226,16 @@ public class YavscApiClient : IAsyncDisposable
response.EnsureSuccessStatusCode();
}
/// <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(
HttpMethod method, string path, object? body, CancellationToken ct)
{

View file

@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
}

View file

@ -105,8 +105,8 @@ public partial class LoginPageViewModel : ViewModelBase
/// 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
private OIDCLoginPhase _phase = OIDCLoginPhase.Idle;
public OIDCLoginPhase Phase
{
get => _phase;
private set
@ -122,13 +122,13 @@ public partial class LoginPageViewModel : ViewModelBase
/// </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",
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(),
};
@ -275,7 +275,7 @@ public partial class LoginPageViewModel : ViewModelBase
// 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);
var progress = new Progress<OIDCLoginPhase>(p => Phase = p);
await LoginInteractiveCoreAsync(_api, progress);
IsBusy = false;
@ -299,7 +299,7 @@ public partial class LoginPageViewModel : ViewModelBase
/// </summary>
private async Task LoginInteractiveCoreAsync(
YavscApiClient api,
IProgress<OidcLoginPhase>? progress = null)
IProgress<OIDCLoginPhase>? progress = null)
{
var original = Platform.CreateBrowser;
try

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

@ -35,6 +35,17 @@
<Button Command="{Binding New}" Content="New post" />
<Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" />
<!--
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"
Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>

View file

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

View file

@ -0,0 +1,77 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://github.com/avaloniaui/avalonia"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Views.SignaturePage"
x:DataType="vm:SignaturePageViewModel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:SignaturePageViewModel />
</Design.DataContext>
<Grid Margin="12" RowSpacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Capture de signature"
FontSize="20"
FontWeight="Bold" />
<!--
Capture surface. The SignaturePadControl is render-
agnostic, so we host it inside a fixed-size Border
that is the PART_CaptureArea, and overlay a Canvas
for the visual feedback. The view's code-behind
repaints the canvas on every RedrawRequested.
-->
<Border Grid.Row="1"
x:Name="PadFrame"
Width="{Binding Width}"
Height="{Binding Height}"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="Pad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="8">
<Button Content="Effacer" Command="{Binding Clear}" />
<Button Content="Capturer" Command="{Binding CaptureAsync}" />
</StackPanel>
<TextBlock Grid.Row="3"
Text="{Binding StatusMessage}"
Foreground="Gray"
TextWrapping="Wrap" />
<TextBlock Grid.Row="4"
FontSize="11"
Foreground="DarkSlateGray"
TextWrapping="Wrap">
<Run Text="Strokes: " />
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
<Run Text=" · Points: " />
<Run Text="{Binding PointCount, Mode=OneWay}" />
</TextBlock>
</Grid>
</ContentPage>

View file

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class SignaturePage : ContentPage
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
private SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage()
{
InitializeComponent();
// Wire the capture area: the Pad itself is the control, the
// surrounding Border (PadFrame) is the hit-test region. We
// set CaptureArea once the control's template has been
// applied — for an inline control with no template, that
// happens on first measure, which is guaranteed before
// the user can interact, so attaching here is safe.
_control = Pad;
_control.CaptureArea = PadFrame;
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
}
private void RebindViewModel(SignaturePageViewModel? vm)
{
if (_vm is not null)
{
_vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
}
_vm = vm;
if (_vm is null || _control is null) return;
_vm.Attach(_control);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
}
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
private void Repaint()
{
if (_control is null || InkLayer is null) return;
InkLayer.Children.Clear();
var w = PadFrame.Bounds.Width;
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var strokes = _control.Strokes;
int i = 0;
while (i < strokes.Count)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}

View file

@ -14,11 +14,11 @@ namespace Yavsc.Abstract.Chat
public const string JustCreatedBy = "just created by ";
public const string LabYouNotOp = "you're no op.";
public const string LabNoSuchUser = "No such user";
public const string LabNoSuchChan = "No such chan";
public const string LabNoSuchUser = "No such user";
public const string LabNoSuchChan = "No such chan";
public const string HopWontKickOp = "Half operator cannot kick any operator";
public const string LabAuthChatUser = "Authenticated chat user";
public const string NoKickOnCop = "No, you won´t, you´ĺl never do kick a cop, it is the bad.";
public const string LabnoJoinNoSend = "LabnoJoinNoSend";
public const string LabNoJoinNoSend = "LabnoJoinNoSend";
}
}
}

View file

@ -17,8 +17,8 @@ namespace Yavsc.Services
/// <summary>
/// Renvoye la facture associée à une clé de facturation,
/// à partir du couple suivant :
///
/// * un code de facturation
///
/// * un code de facturation
/// (identifiant associé à un type de demande du client)
/// * un entier long identifiant la demande du client
/// (à une demande, on associe au maximum une seule facture)
@ -26,10 +26,10 @@ namespace Yavsc.Services
/// <param name="billingCode">Identifiant du type de facturation</param>
/// <param name="queryId">Identifiant de la demande du client</param>
/// <returns>La facture</returns>
Task<IDecidableQuery> GetBillAsync(string billingCode, long queryId);
Task<IQuery> GetBillAsync(string billingCode, long queryId);
/// <summary>
/// Perfomer settings for the specified performer in the activity
/// Perfomer settings for the specified performer in the activity
/// </summary>
/// <param name="activityCode">activityCode</param>
/// <param name="userId">performer uid</param>

View file

@ -1,82 +0,0 @@
// ------------------------------------------------------------------------------
// <autogenerated>
// This code was generated by a tool.
// Mono Runtime Version: 4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </autogenerated>
// ------------------------------------------------------------------------------
namespace Yavsc {
using System;
using System.Reflection;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public partial class ChatHubLabels {
private static System.Resources.ResourceManager resourceMan;
private static System.Globalization.CultureInfo resourceCulture;
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
public static System.Resources.ResourceManager ResourceManager {
get {
if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager(("Yavsc.Abstract.Resources." + "Yavsc.ChatHub"), typeof(ChatHubLabels).GetTypeInfo().Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
public static System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
public static string Authenticated_chat_user {
get {
return ResourceManager.GetString("Authenticated chat user", resourceCulture);
}
}
public static string LabnoJoinNoSend {
get {
return ResourceManager.GetString("LabnoJoinNoSend", resourceCulture);
}
}
public static string InvalidRoomName {
get {
return ResourceManager.GetString("InvalidRoomName", resourceCulture);
}
}
public static string InvalidUserName {
get {
return ResourceManager.GetString("InvalidUserName", resourceCulture);
}
}
public static string InvalidMessage {
get {
return ResourceManager.GetString("InvalidMessage", resourceCulture);
}
}
public static string InvalidReason {
get {
return ResourceManager.GetString("InvalidReason", resourceCulture);
}
}
}
}

View file

@ -4,8 +4,6 @@ namespace Yavsc.Abstract.Workflow
{
public interface IDecidableQuery: ITrackedEntity, IQuery
{
bool Decided { get; set; }
bool Accepted { get; set; }
}
}

View file

@ -9,12 +9,9 @@ namespace Yavsc
public enum QueryStatus: int
{
Inserted,
OwnerValidated,
Visited,
Rejected,
Accepted,
InProgress,
// final states
Failed,
Success

View file

@ -5,6 +5,8 @@ using Newtonsoft.Json;
using System.Security.Claims;
using Yavsc.Helpers;
using Yavsc.ViewModels;
using Yavsc.Models.Billing;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
{
@ -181,5 +183,203 @@ namespace Yavsc.ApiControllers
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
/// <summary>
/// Capture a signature for an estimate, in the JSON
/// wire format produced by PostIt (see
/// <c>PostIt.Models.SignaturePadData</c>). The legacy
/// <c>POST prosign</c> / <c>POST clisign</c> endpoints
/// take a PNG <see cref="IFormFile"/>; this one takes a
/// JSON body so the capture happens entirely in-app on
/// the client side, without a rasterisation step.
///
/// <para>The route is intentionally a sibling of the
/// legacy endpoints, not a replacement: the legacy
/// PNG-based flow stays in place to keep the TeX
/// invoice templates (<c>Bill_tex.cshtml</c>,
/// <c>Estimate_tex.cshtml</c>) working until the
/// migration commit regenerates PNGs from the JSON
/// payload. The two flows share the
/// <see cref="Signature"/> table for storage but not
/// the URL surface.</para>
/// </summary>
[HttpPost("estimate/{id:long}/sign")]
[ValidateAntiForgeryToken]
[Consumes("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Sign(
[FromRoute] long id,
[FromBody] SignatureSubmission body,
CancellationToken token)
{
if (body is null) return BadRequest(new { Error = "missing body" });
if (body.Strokes is null) return BadRequest(new { Error = "missing strokes" });
if (string.IsNullOrEmpty(body.SignerUserId))
return BadRequest(new { Error = "missing signerUserId" });
var estimate = await dbContext.Estimates
.Include(e => e.Client)
.FirstOrDefaultAsync(e => e.Id == id, token);
if (estimate is null) return NotFound(new { Error = "estimate not found" });
// The signer is identified by userId in the body, not
// by the bearer token, because the OAuth scope we
// carry is for the API client (PostIt), not the end
// user. We trust the body's userId to match either
// Owner or Client, and reject everything else.
var userId = body.SignerUserId;
if (userId != estimate.OwnerId && userId != estimate.ClientId)
return Forbid();
// Map userId → type. The Pro/Client split is the
// same one the legacy prosign/clisign endpoints use;
// keeping the rule here means the Signature table
// and the legacy ProviderValidationDate/ClientValidationDate
// columns can co-exist without contradicting each other.
var type = userId == estimate.OwnerId
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body.CoordinateMax,
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
Strokes = body.Strokes,
};
// Disk write first: a disk failure shouldn't leave
// a Signature row pointing at a file that doesn't
// exist. The file helper throws on filesystem
// problems and propagates here.
FileReceivedInfo fi;
try
{
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
}
// Find-or-add: the (EstimateId, Type) pair is
// unique, so a second POST for the same side of the
// estimate replaces the previous signature. EF
// translates this into a single UPDATE when the
// row exists and an INSERT otherwise; the unique
// index in ApplicationDbContext is the
// database-level guarantee that the contract
// holds if two requests race.
var signature = await dbContext.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
if (signature is null)
{
signature = new Signature
{
EstimateId = id,
SignerId = userId,
Type = type,
};
dbContext.Signatures.Add(signature);
}
else
{
// Roll the signer's quota back by the size of
// the file we're about to orphan: the old
// FilePath is no longer referenced once we
// overwrite FilePath below.
try
{
var orphan = new FileInfo(signature.FilePath);
if (orphan.Exists)
{
var signerForOrphan = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signerForOrphan is not null)
signerForOrphan.DiskUsage =
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
}
}
catch { /* best effort — the file is being replaced anyway */ }
}
signature.SignerId = userId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
// Bump the signer's quota. The Signature row's
// SignerId is the IdentityUser.Id (a string), so we
// look up by Id and not by username.
var signer = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signer is not null)
{
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
}
try
{
await dbContext.SaveChangesAsync(token);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
// Best-effort rollback: remove the file we wrote
// so disk and db don't disagree.
try { System.IO.File.Delete(signature.FilePath); }
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
var location = Url.Action(nameof(Sign), new { id })
?? $"/api/bill/estimate/{id}/sign";
return Created(location, new
{
id = signature.Id,
estimateId = signature.EstimateId,
type = signature.Type.ToString(),
capturedAtUtc = signature.CapturedAtUtc,
coordinateMax = signature.CoordinateMax,
});
}
}
}
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// field disambiguates which side of the estimate signed
/// because the bearer token belongs to the PostIt OAuth
/// client, not the end user.
/// </summary>
public class SignatureSubmission
{
/// <summary>
/// ApplicationUser.Id of the signer. Must equal
/// <c>Estimate.OwnerId</c> for a Pro signature or
/// <c>Estimate.ClientId</c> for a Client signature.
/// </summary>
public string SignerUserId { get; set; }
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may override
/// this with <c>DateTime.UtcNow</c> if the client is
/// caught lying about clock skew, but the default is to
/// trust the client.
/// </summary>
public DateTime? CapturedAtUtc { get; set; }
}

View file

@ -41,7 +41,7 @@ namespace Yavsc.Controllers
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.Now;
var result = _context.RdvQueries.Include(c => c.Location).
Include(c => c.Client).Where(c => c.PerformerId == uid && c.Id < maxId && c.EventDate > now
&& c.ValidationDate == null).
@ -49,12 +49,12 @@ namespace Yavsc.Controllers
{
Client = new ClientProviderInfo {
UserName = c.Client.UserName,
UserId = c.ClientId,
UserId = c.ClientId,
Avatar = c.Client.Avatar },
Location = c.Location,
EventDate = c.EventDate,
Id = c.Id,
Previsional = c.Previsional,
Previsional = c.Provisional,
Reason = c.Reason,
ActivityCode = c.ActivityCode,
BillingCode = BillingCodes.Rdv

View file

@ -34,8 +34,8 @@ namespace Yavsc.ApiControllers
if (queryId == 0) return BadRequest("queryId");
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
if (billing == null) return BadRequest();
billing.Decided = true;
billing.Accepted = false;
billing.Status = QueryStatus.Rejected;
dbContext.SaveChanges();
return Ok();
}
@ -47,8 +47,7 @@ namespace Yavsc.ApiControllers
if (queryId == 0) return BadRequest("queryId");
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
if (billing == null) return BadRequest();
billing.Accepted = true;
billing.Decided = true;
billing.Status = QueryStatus.Accepted;
dbContext.SaveChanges();
return Ok();
}

View file

@ -41,7 +41,7 @@ namespace Yavsc.ApiControllers
// user, as a client
public IActionResult Index()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.Now;
@ -151,7 +151,7 @@ namespace Yavsc.ApiControllers
{
HairCutQuery query = await _context.HairCutQueries.Include(q => q.Client).
Include(q => q.Client.PostalAddress).Include(q => q.Prestation).Include(q=>q.Regularisation)
Include(q => q.Client.PostalAddress).Include(q => q.Prestation).Include(q=>q.Regularization)
.SingleAsync(q => q.Id == id);
if (query.PaymentId!=null)
return new BadRequestObjectResult(new { error = "An existing payment process already exists" });

View file

@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Attributes.Validation;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Messaging;
using Yavsc.Services;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
using Yavsc.Server.Hubs;
namespace Yavsc.Blogs.Controllers
{
@ -46,7 +46,7 @@ namespace Yavsc.Blogs.Controllers
}
logger.LogInformation("validated: api/stream/Put: "+filename);
var userName = User.GetUserName();
string url = string.Format(
"{0}/{1}/{2}",
Config.UserFilesOptions.RequestPath.ToUriComponent(),
@ -54,7 +54,7 @@ namespace Yavsc.Blogs.Controllers
filename
);
string destDir = HttpContext.User.EnsureDestinationDirectory(filePath);
logger.LogInformation($"Saving flow to {destDir}");
var userId = User.GetUserId();
@ -65,7 +65,7 @@ namespace Yavsc.Blogs.Controllers
sender = userName,
url = url,
}, $"{userName} is starting a stream!");
await liveProcessor.AcceptStream(HttpContext, user, destDir, shortFileName);
return Ok();
}

View file

@ -0,0 +1,38 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace Yavsc.Org.Tests.Controllers;
public class CommandFormsControllerTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public CommandFormsControllerTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
private HttpClient CreateAdminClient()
{
var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
});
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
return http;
}
[Fact]
public async Task Create_GET_returns_200_for_admin()
{
var http = CreateAdminClient();
var response = await http.GetAsync("/CommandForms/Create", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("Create", body);
}
}

View file

@ -0,0 +1,134 @@
using System;
using System.IO;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Server.Helpers;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Org.Tests;
/// <summary>
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
/// helper. Scope is intentionally narrow: the file-naming format,
/// the strokes counter, and the on-disk write path. The controller
/// (authz, db persistence, signalR notification) is out of scope
/// for this commit and will get a dedicated integration test once
/// the Yavsc.Api test project is set up.
/// </summary>
public class EstimateSignatureFileHelperTests : IDisposable
{
private readonly string _tempRoot;
public EstimateSignatureFileHelperTests()
{
// UserFilesDirName is a process-wide static; we redirect
// it to a per-test temp dir so concurrent tests don't
// collide and the host filesystem is not littered.
_tempRoot = Path.Combine(
Path.GetTempPath(),
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
AbstractFileSystemHelpers.UserFilesDirName = _tempRoot;
}
public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort — the OS will clean Temp eventually */ }
}
[Fact]
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
{
var name = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Pro, 42, 638_000_000_000_000_000L);
Assert.Equal("sign-pro-42-638000000000000000.json", name);
var cli = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Client, 7, 1L);
Assert.Equal("sign-client-7-1.json", cli);
}
[Theory]
[InlineData(new int[] { }, 0)]
[InlineData(new[] { 1, 100, 200 }, 1)]
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
{
// We don't read the count back from the helper (it's a
// private method), but the JSON envelope must reflect
// it; this verifies the public behaviour end-to-end.
_ = expectedStrokeCount;
// Arrange
var user = MakeUser("alice");
var payload = new SignaturePadPayload
{
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
Strokes = strokes,
};
// Act
var fi = Run(user, 123L, SignatureType.Pro, payload);
// Assert: file exists, sits under the user's root, and
// parses as a yavsc.signature/v1 envelope.
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
var root = doc.RootElement;
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
Assert.Equal("Pro", root.GetProperty("type").GetString());
Assert.Equal("alice", root.GetProperty("signerName").GetString());
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
{
var user = MakeUser("bob");
await Assert.ThrowsAsync<ArgumentNullException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 1L, SignatureType.Pro, payload: null!));
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
{
var user = MakeUser("bob");
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 0L, SignatureType.Pro, payload));
}
// --- helpers ----------------------------------------------------
private static FileReceivedInfo Run(
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
{
// The helper is async; tests that don't care about the
// result can call it sync via .GetAwaiter().GetResult()
// because we know it never throws in the happy path.
return EstimateSignatureFileHelper
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, CancellationToken.None)
.GetAwaiter().GetResult();
}
private static ClaimsPrincipal MakeUser(string username)
{
return new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, username) },
authenticationType: "test"));
}
}

View file

@ -36,11 +36,11 @@ namespace Yavsc
{
WorkflowHelpers.ConfigureBillingService();
var firstRegistrar = new Func<ApplicationDbContext, long, IDecidableQuery>((db, id) =>
db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id));
var firstRegistrar = new Func<ApplicationDbContext, long, IQuery>((db, id) =>
db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id));
const string testCode = "Brush";
Assert.Throws<InvalidOperationException>(() =>
WorkflowHelpers.RegisterBilling<HairCutQuery>(testCode, firstRegistrar));
}

View file

@ -93,10 +93,10 @@ namespace Yavsc.Controllers
: "";
var user = await GetCurrentUserAsync();
long pc = _dbContext.BlogSpot.Count(x => x.AuthorId == user.Id);
var model = new IndexViewModel
{
@ -123,14 +123,14 @@ namespace Yavsc.Controllers
AllowMonthlyEmail = user.AllowMonthlyEmail,
Address = user.PostalAddress?.Address
};
model.HaveProfessionalSettings = _dbContext.Performers.Any(x => x.PerformerId == user.Id);
var usrActs = _dbContext.UserActivities.Include(a=>a.Does).Where(a=> a.UserId == user.Id).ToArray();
// TODO remember me who this magical a.Settings is built
var usrActToSet = usrActs.Where( a => ( a.Settings == null && a.Does.SettingsClassName != null )).ToArray();
model.HaveActivityToConfigure = usrActToSet .Count()>0;
model.Activity = _dbContext.UserActivities.Include(a=>a.Does).Where(u=>u.UserId == user.Id).ToList();
return View(model);
}
@ -152,7 +152,7 @@ namespace Yavsc.Controllers
var user = await GetCurrentUserAsync();
user.AllowMonthlyEmail = model.Allow;
await this._dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction(nameof(Index), new { Message = ManageMessageId.SetMonthlyEmailSuccess });
}
@ -302,8 +302,8 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var calendars = await _calendarManager.GetCalendarsAsync(pageToken);
return View(new SetGoogleCalendarViewModel {
ReturnUrl = returnUrl,
return View(new SetGoogleCalendarViewModel {
ReturnUrl = returnUrl,
Calendars = calendars
});
}
@ -343,9 +343,9 @@ namespace Yavsc.Controllers
)) return BadRequest(new { message = "data already present" });
user.BankInfo.Add(model);
_dbContext.Update(user);
await _dbContext.SaveChangesAsync();
}
return RedirectToAction(nameof(Index), new { Message = ManageMessageId.SetBankInfoSuccess });
@ -495,7 +495,7 @@ namespace Yavsc.Controllers
return View("Error");
}
var userLogins = await _userManager.GetLoginsAsync(user);
ViewBag.ShowRemoveButton = user.PasswordHash != null || userLogins.Count > 1;
return View(new ManageLoginsViewModel

View file

@ -1,20 +1,24 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers;
using Microsoft.Extensions.Localization;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Services;
namespace Yavsc.Controllers
{
public class CommandFormsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IStringLocalizer<CommandFormsController> _localizer;
public CommandFormsController(ApplicationDbContext context)
public CommandFormsController(ApplicationDbContext context,
IStringLocalizer<CommandFormsController> localizer)
{
_context = context;
_localizer = localizer;
}
// GET: CommandForms
@ -47,11 +51,14 @@ namespace Yavsc.Controllers
SetViewBag();
return View();
}
private void SetViewBag(CommandForm commandForm = null)
{
ViewBag.ActivityCode = new SelectList(_context.Activities, "Code", "Name", commandForm?.ActivityCode);
ViewBag.ActionName = _context.CommandForm.Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.Title, Selected = commandForm.Id == c.Id });
ViewBag.ActionName = BillingService.Billing.Keys
.Select((string b) => new SelectListItem { Value = b, Text = _localizer[b] }).ToList();
}
// POST: CommandForms/Create
[HttpPost]
[ValidateAntiForgeryToken]

View file

@ -36,16 +36,16 @@ namespace Yavsc.Controllers
public ActionResult Index()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.Now;
var now = DateTime.UtcNow;
var model = new FrontOfficeIndexViewModel
{
EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now
&& c.ValidationDate == null && !_context.Estimates.Any(e => (e.CommandId == c.Id && e.ProviderValidationDate != null))).Count(),
EstimateToSignAsProCount = _context.RdvQueries.Where(c => (c.PerformerId == uid && c.EventDate > now
&& c.ValidationDate == null && _context.Estimates.Any(e => (e.CommandId == c.Id && e.ProviderValidationDate != null)))).Count(),
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.ClientValidationDate == null).Count(),
BillToSignAsProCount = 0,
EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted
&& c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(),
EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted
&& c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(),
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(),
BillToSignAsCliCount = 0,
NewPayementsCount = 0
};
@ -65,14 +65,14 @@ namespace Yavsc.Controllers
}
[AllowAnonymous]
public async Task <ActionResult> HairCut(string id)
public async Task <ActionResult> ListPerformersAsync(string activityCode)
{
if (id == null)
if (activityCode == null)
{
throw new NotImplementedException("No Activity code");
}
ViewBag.Activity = await _context.Activities.FirstOrDefaultAsync(a => a.Code == id);
var result = await _context.ListPerformersAsync(_billing, id);
ViewBag.Activity = await _context.Activities.FirstOrDefaultAsync(a => a.Code == activityCode);
var result = await _context.ListPerformersAsync(_billing, activityCode);
return View(result);
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Controllers
// GET: GeneralSettings
public async Task<IActionResult> Index()
{
return View(await _context.GeneralSettings.ToListAsync());
return View(await _context.MusicLoverSettings.ToListAsync());
}
// GET: GeneralSettings/Details/5
@ -28,7 +28,7 @@ namespace Yavsc.Controllers
return NotFound();
}
MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@ -50,7 +50,7 @@ namespace Yavsc.Controllers
{
if (ModelState.IsValid)
{
_context.GeneralSettings.Add(generalSettings);
_context.MusicLoverSettings.Add(generalSettings);
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
@ -65,7 +65,7 @@ namespace Yavsc.Controllers
return NotFound();
}
MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@ -96,7 +96,7 @@ namespace Yavsc.Controllers
return NotFound();
}
MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@ -110,8 +110,8 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(string id)
{
MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
_context.GeneralSettings.Remove(generalSettings);
MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
_context.MusicLoverSettings.Remove(generalSettings);
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}

View file

@ -6,7 +6,6 @@ namespace Yavsc.Controllers.Generic
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Models;
using Yavsc.Helpers;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@ -37,7 +36,7 @@ namespace Yavsc.Controllers.Generic
{
_context = context;
}
public async Task<IActionResult> Index()
{
return View(await GetSettingsAsync(User.GetUserId()));

View file

@ -49,7 +49,7 @@ namespace Yavsc.Controllers
this.haircutLocalizer = haircutLocalizer;
}
private async Task<HairCutQuery> GetQuery(long id)
{
var query = await _context.HairCutQueries
@ -58,7 +58,7 @@ namespace Yavsc.Controllers
.Include(x => x.Prestation)
.Include(x => x.PerformerProfile.Performer)
.Include(x => x.PerformerProfile.Performer.DeviceDeclaration)
.Include(x => x.Regularisation)
.Include(x => x.Regularization)
.SingleAsync(m => m.Id == id);
query.SelectedProfile = await _context.BrusherProfile.SingleAsync(b => b.UserId == query.PerformerId);
return query;
@ -82,11 +82,11 @@ namespace Yavsc.Controllers
}
var paymentInfo = await _context.ConfirmPayment(User.GetUserId(), PayerID, token);
ViewBag.paymentinfo = paymentInfo;
command.Regularisation = paymentInfo.DbContent;
command.Regularization = paymentInfo.DbContent;
command.PaymentId = token;
bool paymentOk = false;
if (paymentInfo.DetailsFromPayPal != null)
if (paymentInfo.DetailsFromPayPal.Ack == AckCodeType.SUCCESS)
if (paymentInfo.DetailsFromPayPal.Ack == AckCodeType.SUCCESS)
{
// FIXME Assert (command.ValidationDate == null)
if (command.ValidationDate == null) {
@ -174,7 +174,7 @@ namespace Yavsc.Controllers
.Include(x => x.PerformerProfile)
.Include(x => x.Prestation)
.Include(x => x.PerformerProfile.Performer)
.Include(x => x.Regularisation)
.Include(x => x.Regularization)
.SingleOrDefaultAsync(m => m.Id == id);
if (command == null)
{
@ -224,7 +224,7 @@ namespace Yavsc.Controllers
.FirstOrDefault(
x => x.PerformerId == model.PerformerId
);
if (taintIds != null)
{

View file

@ -18,7 +18,7 @@ namespace Yavsc.Controllers
private readonly ApplicationDbContext _context;
readonly IStringLocalizer<ProjectController> _localizer;
readonly IStringLocalizer<BugController> _bugLocalizer;
public ProjectController(ApplicationDbContext context,
IStringLocalizer<ProjectController> localizer,
IStringLocalizer<BugController> bugLocalizer
@ -32,7 +32,7 @@ namespace Yavsc.Controllers
// GET: Project
public async Task<IActionResult> Index()
{
var applicationDbContext = _context.Project.Include(p => p.Client).Include(p => p.Context).Include(p => p.PerformerProfile).Include(p => p.Regularisation).Include(p => p.Repository);
var applicationDbContext = _context.Project.Include(p => p.Client).Include(p => p.Context).Include(p => p.PerformerProfile).Include(p => p.Regularization).Include(p => p.Repository);
return View(await applicationDbContext.ToListAsync());
}

View file

@ -4,14 +4,14 @@
<!-- Yavsc.Org-specific versions -->
<ItemGroup>
<PackageVersion Include="AsciiDocSharp" Version="0.2.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.2.0" />
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="8.0.5-preview-net9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="8.0.5-preview-net9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="8.0.5-preview-net9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="8.0.5-preview-net9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="8.1.0-alpha.171" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />

View file

@ -1,8 +1,5 @@

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
@ -23,7 +20,7 @@ namespace Yavsc.Extensions
var typeInfo = type.GetTypeInfo();
var values = Enum.GetValues(type).Cast<Enum>();
var items = new List<SelectListItem>();
foreach (var value in values)
{
items.Add(new SelectListItem {

View file

@ -19,6 +19,9 @@ using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
@ -41,6 +44,7 @@ using Yavsc.Settings;
using Yavsc.ViewModels.Auth;
using IdentityServer8.Models;
using IdentityServer8.EntityFramework.Mappers;
using Yavsc.Server.Hubs;
namespace Yavsc.Extensions;
@ -49,6 +53,30 @@ public static class HostingExtensions
{
private const string InMemoryProviderName = "InMemory";
private static void IgnoreKnownFalsePositiveMigrationWarnings(DbContextOptionsBuilder options)
{
options.ConfigureWarnings(w =>
w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
private static async Task ApplyMigrationsAsync<TContext>(IServiceProvider services, ILogger logger)
where TContext : DbContext
{
var contextName = typeof(TContext).Name;
var db = services.GetRequiredService<TContext>();
logger.LogInformation(
"Applying database migrations for {DbContext} using provider {Provider}...",
contextName,
db.Database.ProviderName ?? "(null)");
await db.Database.MigrateAsync();
logger.LogInformation(
"Database migrations applied successfully for {DbContext}.",
contextName);
}
public static WebApplication ConfigureWebAppServices(this WebApplicationBuilder builder)
{
builder.Services.AddSwaggerGen();
@ -154,6 +182,13 @@ public static class HostingExtensions
{
options.UseNpgsql(connectionString,
options => options.MigrationsAssembly(typeof(Program).Assembly));
// EF Core 10 can raise PendingModelChangesWarning at runtime
// even when the snapshot and generated migrations are already
// aligned on this codebase. Treat that known false positive as
// non-fatal in every environment so production startup matches
// the behavior already observed in development.
IgnoreKnownFalsePositiveMigrationWarnings(options);
}
});
@ -316,6 +351,7 @@ public static class HostingExtensions
{
b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
IgnoreKnownFalsePositiveMigrationWarnings(b);
}
// NOTE: don't b.UseSeeding(...) here — EF Core's UseSeeding
@ -339,6 +375,7 @@ public static class HostingExtensions
{
b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
IgnoreKnownFalsePositiveMigrationWarnings(b);
}
};
@ -889,11 +926,13 @@ public static class HostingExtensions
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
await app.MigrateDatabaseAsync();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.MigrateDatabase();
logger.LogInformation("Running in production mode. Ensure the database is migrated.");
await app.MigrateDatabaseAsync();
}
app.Use(async (context, next) =>
@ -940,26 +979,37 @@ public static class HostingExtensions
return app;
}
private static void MigrateDatabase(this IApplicationBuilder app)
private static async Task MigrateDatabaseAsync(this IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
var logger = serviceScope.ServiceProvider
.GetRequiredService<ILoggerFactory>()
.CreateLogger("Yavsc.Org.Migrations");
try
{
foreach (Type contextType in new Type[]
using (var scope = app.ApplicationServices.CreateScope())
{
typeof(ApplicationDbContext)
})
{
((DbContext)serviceScope.ServiceProvider
.GetRequiredService(contextType))
.Database.Migrate();
await ApplyMigrationsAsync<ApplicationDbContext>(scope.ServiceProvider, logger);
}
EnsureCriticalSchema(serviceScope.ServiceProvider, logger);
}
catch (InvalidOperationException ex)
catch (Exception ex)
{
app.Properties["DegradedDBContext"] = ex.Message;
logger.LogError(
ex,
"Database migration failed for {DbContext}. App started in degraded mode.",
nameof(ApplicationDbContext));
// EF Core 10 may raise PendingModelChangesWarning as an exception.
// Dump a concise model diff to make the mismatch actionable.
if (ex is InvalidOperationException ioe
&& ioe.Message.Contains("PendingModelChangesWarning", StringComparison.Ordinal))
{
LogPendingModelChanges(serviceScope.ServiceProvider, logger);
}
}
}
@ -972,6 +1022,88 @@ public static class HostingExtensions
SeedConfigurationDatabase(app);
}
private static void LogPendingModelChanges(IServiceProvider services, ILogger logger)
{
try
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var migrationsAssembly = db.GetService<IMigrationsAssembly>();
var snapshotModel = migrationsAssembly.ModelSnapshot?.Model;
if (snapshotModel is null)
{
logger.LogWarning("Pending-model diagnostic: no ModelSnapshot found for ApplicationDbContext.");
return;
}
logger.LogWarning(
"Pending-model diagnostic: provider={Provider}",
db.Database.ProviderName ?? "(null)");
var runtimeDeclDate = db.Model
.FindEntityType("Yavsc.Models.Identity.DeviceDeclaration")?
.FindProperty("DeclarationDate")?
.GetDefaultValueSql();
var snapshotDeclDate = snapshotModel
.FindEntityType("Yavsc.Models.Identity.DeviceDeclaration")?
.FindProperty("DeclarationDate")?
.GetDefaultValueSql();
logger.LogWarning(
"Pending-model diagnostic: DeviceDeclaration.DeclarationDate default SQL runtime='{RuntimeDefaultSql}', snapshot='{SnapshotDefaultSql}'.",
runtimeDeclDate ?? "(null)",
snapshotDeclDate ?? "(null)");
bool runtimeHasMusicLoverSettings = db.Model.FindEntityType("Yavsc.Models.Musical.Profiles.MusicLoverSettings") is not null;
bool snapshotHasMusicLoverSettings = snapshotModel.FindEntityType("Yavsc.Models.Musical.Profiles.MusicLoverSettings") is not null;
logger.LogWarning(
"Pending-model diagnostic: MusicLoverSettings runtime={RuntimeHas} snapshot={SnapshotHas}.",
runtimeHasMusicLoverSettings,
snapshotHasMusicLoverSettings);
bool runtimeHasSignature = db.Model.FindEntityType("Yavsc.Models.Billing.Signature") is not null;
bool snapshotHasSignature = snapshotModel.FindEntityType("Yavsc.Models.Billing.Signature") is not null;
logger.LogWarning(
"Pending-model diagnostic: Signature runtime={RuntimeHas} snapshot={SnapshotHas}.",
runtimeHasSignature,
snapshotHasSignature);
bool runtimeHasModerated = db.Model
.FindEntityType("Yavsc.Models.Workflow.Activity")?
.FindProperty("Moderated") is not null;
bool snapshotHasModerated = snapshotModel
.FindEntityType("Yavsc.Models.Workflow.Activity")?
.FindProperty("Moderated") is not null;
logger.LogWarning(
"Pending-model diagnostic: Activity.Moderated runtime={RuntimeHas} snapshot={SnapshotHas}.",
runtimeHasModerated,
snapshotHasModerated);
}
catch (Exception diagEx)
{
logger.LogError(diagEx, "Pending-model diagnostic failed.");
}
}
private static void EnsureCriticalSchema(IServiceProvider services, ILogger logger)
{
try
{
var db = services.GetRequiredService<ApplicationDbContext>();
// Hotfix guard: keep startup resilient if a migration was skipped,
// while still allowing EF migrations to be the source of truth.
db.Database.ExecuteSqlRaw(@"
ALTER TABLE ""Activities""
ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
}
catch (Exception ex)
{
logger.LogError(ex, "Critical schema check failed for Activities.Moderated.");
}
}
private static void SeedConfigurationDatabase(IApplicationBuilder app)
{
try
@ -980,13 +1112,26 @@ public static class HostingExtensions
.GetRequiredService<IServiceScopeFactory>()
.CreateScope();
var logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("Yavsc.Org.Seeding");
var configurationDb = scope.ServiceProvider
.GetRequiredService<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
var configuration = scope.ServiceProvider
.GetRequiredService<IConfiguration>();
logger.LogInformation(
"Running seed for {DbContext} using provider {Provider}...",
nameof(IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext),
configurationDb.Database.ProviderName ?? "(null)");
EnsureDefaultConfiguration(configuration)(configurationDb, true);
logger.LogInformation(
"Seed completed for {DbContext}.",
nameof(IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext));
}
catch (Exception ex)
{
@ -996,7 +1141,10 @@ public static class HostingExtensions
var logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("Yavsc.Org.Seeding");
logger.LogError(ex, "ConfigurationDb seeding failed.");
logger.LogError(
ex,
"Seed failed for {DbContext}.",
nameof(IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext));
}
}

View file

@ -17,11 +17,11 @@ namespace Yavsc.Helpers
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo { 
Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
Previsional = query.Previsional,
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
@ -44,7 +44,7 @@ namespace Yavsc.Helpers
var yaev = query.CreateEvent("NewHairCutQuery",
string.Format(SR["HairCutQueryValidation"],query.Client.UserName),
$"{query.Client.Id}");
return yaev;
}
@ -58,12 +58,12 @@ namespace Yavsc.Helpers
var yaev = new HairCutQueryEvent("newCommand")
{
Sender = query.ClientId,
Client = new ClientProviderInfo { 
Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
Previsional = query.Previsional,
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,

View file

@ -12,12 +12,16 @@ namespace Yavsc.Helpers {
this ApplicationDbContext _dbContext, List<UserActivity> activity)
{
var activities = activity.ToArray();
var activityCodes = activities.Select(a=>a.DoesCode).ToArray();
List<SelectListItem> items = _dbContext.Activities.Select(
var systemActivities = _dbContext.Activities.Where(a=>!a.Moderated
&& activityCodes.Contains(a.Code)).ToArray();
List<SelectListItem> items = systemActivities.Select(
x=> new SelectListItem() {
Value = x.Code, Text = x.Name, Selected = activities.Any(a=>a.DoesCode == x.Code)
} ).ToList();
return items;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,294 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class activityModerated : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MusicalPreference_GeneralSettings_GeneralSettingsUserId",
table: "MusicalPreference");
migrationBuilder.DropTable(
name: "GeneralSettings");
migrationBuilder.DropColumn(
name: "Accepted",
table: "RdvQueries");
migrationBuilder.DropColumn(
name: "Decided",
table: "RdvQueries");
migrationBuilder.DropColumn(
name: "Accepted",
table: "Project");
migrationBuilder.DropColumn(
name: "Decided",
table: "Project");
migrationBuilder.DropColumn(
name: "Accepted",
table: "HairMultiCutQueries");
migrationBuilder.DropColumn(
name: "Decided",
table: "HairMultiCutQueries");
migrationBuilder.DropColumn(
name: "Accepted",
table: "HairCutQueries");
migrationBuilder.DropColumn(
name: "Decided",
table: "HairCutQueries");
migrationBuilder.RenameColumn(
name: "Previsional",
table: "RdvQueries",
newName: "Provisional");
migrationBuilder.RenameColumn(
name: "Previsional",
table: "Project",
newName: "Provisional");
migrationBuilder.RenameColumn(
name: "GeneralSettingsUserId",
table: "MusicalPreference",
newName: "MusicLoverSettingsUserId");
migrationBuilder.RenameIndex(
name: "IX_MusicalPreference_GeneralSettingsUserId",
table: "MusicalPreference",
newName: "IX_MusicalPreference_MusicLoverSettingsUserId");
migrationBuilder.RenameColumn(
name: "Previsional",
table: "HairMultiCutQueries",
newName: "Provisional");
migrationBuilder.RenameColumn(
name: "Previsional",
table: "HairCutQueries",
newName: "Provisional");
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "Bug",
type: "character varying(10240)",
maxLength: 10240,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "Moderated",
table: "Activities",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "MusicLoverSettings",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MusicLoverSettings", x => x.UserId);
});
migrationBuilder.CreateTable(
name: "Signatures",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EstimateId = table.Column<long>(type: "bigint", nullable: false),
SignerId = table.Column<string>(type: "text", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
CoordinateMax = table.Column<int>(type: "integer", nullable: false, defaultValue: 10000),
Strokes = table.Column<int[]>(type: "integer[]", nullable: false),
CapturedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
FilePath = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Signatures", x => x.Id);
table.ForeignKey(
name: "FK_Signatures_AspNetUsers_SignerId",
column: x => x.SignerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Signatures_Estimates_EstimateId",
column: x => x.EstimateId,
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Signatures_EstimateId_Type",
table: "Signatures",
columns: new[] { "EstimateId", "Type" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Signatures_SignerId",
table: "Signatures",
column: "SignerId");
migrationBuilder.AddForeignKey(
name: "FK_MusicalPreference_MusicLoverSettings_MusicLoverSettingsUser~",
table: "MusicalPreference",
column: "MusicLoverSettingsUserId",
principalTable: "MusicLoverSettings",
principalColumn: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MusicalPreference_MusicLoverSettings_MusicLoverSettingsUser~",
table: "MusicalPreference");
migrationBuilder.DropTable(
name: "MusicLoverSettings");
migrationBuilder.DropTable(
name: "Signatures");
migrationBuilder.DropColumn(
name: "Moderated",
table: "Activities");
migrationBuilder.RenameColumn(
name: "Provisional",
table: "RdvQueries",
newName: "Previsional");
migrationBuilder.RenameColumn(
name: "Provisional",
table: "Project",
newName: "Previsional");
migrationBuilder.RenameColumn(
name: "MusicLoverSettingsUserId",
table: "MusicalPreference",
newName: "GeneralSettingsUserId");
migrationBuilder.RenameIndex(
name: "IX_MusicalPreference_MusicLoverSettingsUserId",
table: "MusicalPreference",
newName: "IX_MusicalPreference_GeneralSettingsUserId");
migrationBuilder.RenameColumn(
name: "Provisional",
table: "HairMultiCutQueries",
newName: "Previsional");
migrationBuilder.RenameColumn(
name: "Provisional",
table: "HairCutQueries",
newName: "Previsional");
migrationBuilder.AddColumn<bool>(
name: "Accepted",
table: "RdvQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Decided",
table: "RdvQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Accepted",
table: "Project",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Decided",
table: "Project",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Accepted",
table: "HairMultiCutQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Decided",
table: "HairMultiCutQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Accepted",
table: "HairCutQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Decided",
table: "HairCutQueries",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "Bug",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(10240)",
oldMaxLength: 10240,
oldNullable: true);
migrationBuilder.CreateTable(
name: "GeneralSettings",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GeneralSettings", x => x.UserId);
});
migrationBuilder.AddForeignKey(
name: "FK_MusicalPreference_GeneralSettings_GeneralSettingsUserId",
table: "MusicalPreference",
column: "GeneralSettingsUserId",
principalTable: "GeneralSettings",
principalColumn: "UserId");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -1413,6 +1413,50 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CapturedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("CoordinateMax")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(10000);
b.Property<long>("EstimateId")
.HasColumnType("bigint");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SignerId")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<int[]>("Strokes")
.IsRequired()
.HasColumnType("integer[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SignerId");
b.HasIndex("EstimateId", "Type")
.IsUnique();
b.ToTable("Signatures");
});
modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{
b.Property<long>("FileId")
@ -1874,9 +1918,6 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
@ -1897,9 +1938,6 @@ namespace Yavsc.Migrations
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Decided")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasColumnType("text");
@ -1919,7 +1957,7 @@ namespace Yavsc.Migrations
b.Property<long>("PrestationId")
.HasColumnType("bigint");
b.Property<decimal?>("Previsional")
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<string>("SelectedProfileUserId")
@ -1964,9 +2002,6 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
@ -1984,9 +2019,6 @@ namespace Yavsc.Migrations
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Decided")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasColumnType("text");
@ -2003,7 +2035,7 @@ namespace Yavsc.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Previsional")
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<int>("Status")
@ -2157,7 +2189,8 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
.HasMaxLength(10240)
.HasColumnType("character varying(10240)");
b.Property<long?>("FeatureId")
.HasColumnType("bigint");
@ -2534,7 +2567,7 @@ namespace Yavsc.Migrations
b.Property<string>("DjSettingsUserId")
.HasColumnType("text");
b.Property<string>("GeneralSettingsUserId")
b.Property<string>("MusicLoverSettingsUserId")
.HasColumnType("text");
b.Property<int>("Rate")
@ -2547,7 +2580,7 @@ namespace Yavsc.Migrations
b.HasIndex("DjSettingsUserId");
b.HasIndex("GeneralSettingsUserId");
b.HasIndex("MusicLoverSettingsUserId");
b.HasIndex("TendencyId");
@ -2585,16 +2618,6 @@ namespace Yavsc.Migrations
b.ToTable("DjSettings");
});
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.GeneralSettings", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("UserId");
b.ToTable("GeneralSettings");
});
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.Instrumentation", b =>
{
b.Property<long>("InstrumentId")
@ -2610,6 +2633,16 @@ namespace Yavsc.Migrations
b.ToTable("Instrumentation");
});
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("UserId");
b.ToTable("MusicLoverSettings");
});
modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b =>
{
b.Property<string>("CreationToken")
@ -2894,6 +2927,9 @@ namespace Yavsc.Migrations
b.Property<bool>("Hidden")
.HasColumnType("boolean");
b.Property<bool>("Moderated")
.HasColumnType("boolean");
b.Property<string>("ModeratorGroupName")
.HasColumnType("text");
@ -3043,9 +3079,6 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
@ -3063,9 +3096,6 @@ namespace Yavsc.Migrations
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Decided")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasColumnType("text");
@ -3085,7 +3115,7 @@ namespace Yavsc.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Previsional")
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<string>("Reason")
@ -3192,9 +3222,6 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
@ -3212,9 +3239,6 @@ namespace Yavsc.Migrations
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Decided")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasColumnType("text");
@ -3235,7 +3259,7 @@ namespace Yavsc.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Previsional")
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<int>("Status")
@ -3737,6 +3761,25 @@ namespace Yavsc.Migrations
b.Navigation("Query");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
.WithMany("Signatures")
.HasForeignKey("EstimateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Signer")
.WithMany()
.HasForeignKey("SignerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Estimate");
b.Navigation("Signer");
});
modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
@ -3918,7 +3961,7 @@ namespace Yavsc.Migrations
.WithMany()
.HasForeignKey("LocationId");
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularisation")
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
@ -3948,7 +3991,7 @@ namespace Yavsc.Migrations
b.Navigation("Prestation");
b.Navigation("Regularisation");
b.Navigation("Regularization");
b.Navigation("SelectedProfile");
});
@ -3971,7 +4014,7 @@ namespace Yavsc.Migrations
.WithMany()
.HasForeignKey("LocationId");
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularisation")
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
@ -3989,7 +4032,7 @@ namespace Yavsc.Migrations
b.Navigation("PerformerProfile");
b.Navigation("Regularisation");
b.Navigation("Regularization");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestationCollectionItem", b =>
@ -4162,9 +4205,9 @@ namespace Yavsc.Migrations
.WithMany("SoundColor")
.HasForeignKey("DjSettingsUserId");
b.HasOne("Yavsc.Models.Musical.Profiles.GeneralSettings", null)
b.HasOne("Yavsc.Models.Musical.Profiles.MusicLoverSettings", null)
.WithMany("SoundColor")
.HasForeignKey("GeneralSettingsUserId");
.HasForeignKey("MusicLoverSettingsUserId");
b.HasOne("Yavsc.Models.Musical.MusicalTendency", "MusicalTendency")
.WithMany()
@ -4344,7 +4387,7 @@ namespace Yavsc.Migrations
.WithMany()
.HasForeignKey("LocationId");
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularisation")
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
@ -4362,7 +4405,7 @@ namespace Yavsc.Migrations
b.Navigation("PerformerProfile");
b.Navigation("Regularisation");
b.Navigation("Regularization");
});
modelBuilder.Entity("Yavsc.Models.Workflow.UserActivity", b =>
@ -4404,7 +4447,7 @@ namespace Yavsc.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularisation")
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
@ -4420,7 +4463,7 @@ namespace Yavsc.Migrations
b.Navigation("PerformerProfile");
b.Navigation("Regularisation");
b.Navigation("Regularization");
b.Navigation("Repository");
});
@ -4519,6 +4562,8 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b =>
{
b.Navigation("Bill");
b.Navigation("Signatures");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
@ -4580,7 +4625,7 @@ namespace Yavsc.Migrations
b.Navigation("SoundColor");
});
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.GeneralSettings", b =>
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b =>
{
b.Navigation("SoundColor");
});

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!--
route name for the api controller used to tag the 'BlogPost' entity
-->
<data name="CommandForms"><value>Formulaires de commandes</value></data>
</root>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!--
route name for the api controller used to tag the 'BlogPost' entity
-->
<data name="AcceptPublicContact"><value>Accepte les demandes de prise de contact d'utilisateurs authentifié, sans plus de procès</value></data>
</root>

View file

@ -80,7 +80,7 @@ namespace Yavsc.Services
public void OnConnected(string cxId, bool isCop)
{
var username = ChatUserNames[cxId];
if (!IsConnected(username))
if (!IsConnected(username))
ChatRoomPresence[username] = new List<string>();
_isCop[username] = isCop;
}
@ -101,7 +101,7 @@ namespace Yavsc.Services
return _isCop[userName];
}
public void OnDisctonnected(string connectionId)
public void OnDisconnected(string connectionId)
{
string uname;
@ -136,7 +136,7 @@ namespace Yavsc.Services
return false;
}
// FIXME only remove cx, not username,
// as long as he might be connected
// as long as he might be connected
// from another device, to the same room
chanInfo.Users.Remove(cxId);
if (chanInfo.Users.Count == 0)
@ -183,7 +183,7 @@ namespace Yavsc.Services
}
else{
chanInfo.Users.Add(cxId);
}
}
_logger.LogInformation($"existing room joint: {userName}=>{roomName}");
if (!ChatRoomPresence[userName].Contains(roomName))
ChatRoomPresence[userName].Add(roomName);
@ -200,7 +200,7 @@ namespace Yavsc.Services
// room was closed.
var room = _dbContext.ChatRoom.FirstOrDefault(r => r.Name == roomName);
chanInfo = new ChatRoomInfo();
if (room != null)
{
@ -244,7 +244,7 @@ namespace Yavsc.Services
throw new System.NotImplementedException();
}
public bool Dehop(string roomName, string userName)
public bool DeHop(string roomName, string userName)
{
throw new System.NotImplementedException();
}
@ -292,12 +292,12 @@ namespace Yavsc.Services
return false;
}
if (!Channels.TryGetValue(roomName, out chanInfo))
if (!Channels.TryGetValue(roomName, out chanInfo))
{
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchChan).ToString());
return false;
}
var kickerName = GetUserName(cxId);
if (!chanInfo.Ops.Contains(cxId))
if (!chanInfo.Hops.Contains(cxId))
@ -325,19 +325,19 @@ namespace Yavsc.Services
}
// all good, time to kick :-)
foreach (var ucx in ucxs) {
if (chanInfo.Users.Contains(ucx))
foreach (var ucx in ucxs) {
if (chanInfo.Users.Contains(ucx))
chanInfo.Users.Remove(ucx);
else if (chanInfo.Ops.Contains(ucx))
else if (chanInfo.Ops.Contains(ucx))
chanInfo.Ops.Remove(ucx);
else if (chanInfo.Hops.Contains(ucx))
else if (chanInfo.Hops.Contains(ucx))
chanInfo.Hops.Remove(ucx);
}
return true;
}
}
}

View file

@ -1,5 +1,4 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Yavsc.Interface;
@ -8,6 +7,7 @@ using Yavsc.Models;
using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
using Yavsc.Models.Messaging;
using Yavsc.Server.Hubs;
namespace Yavsc.Services
{
@ -92,25 +92,25 @@ namespace Yavsc.Services
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
$"{ev.Sender} (un client) vous demande un rendez-vous",
body + Environment.NewLine);
response.success++;
var cxids = _cxManager.GetConnexionIds(user.UserName);
if (cxids == null)
var cxIds = _cxManager.GetConnexionIds(user.UserName);
if (cxIds == null)
{
_logger.LogDebug($"no cx to {user.UserName} <{user.Email}> ");
}
else
{
_logger.LogDebug($"Sending signal to {string.Join(" ", cxids)} : " + JsonConvert.SerializeObject(ev));
_logger.LogDebug($"Sending signal to {string.Join(" ", cxIds)} : " + JsonConvert.SerializeObject(ev));
foreach (var cxid in cxids)
foreach (var cxId in cxIds)
{
// from usr asp.net Id : var hubClient = hubContext.Clients.User(userId);
var hubClient = hubContext.Clients.Client(cxid);
var hubClient = hubContext.Clients.Client(cxId);
var data = new Dictionary<string, object>
{
["event"] = JsonConvert.SerializeObject(ev)
@ -152,22 +152,9 @@ namespace Yavsc.Services
return await NotifyEvent<HairCutQueryEvent>(userIds, ev);
}
public async Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> userIds, IEvent yaev)
public async Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> userIds, IEvent yavscEvent)
{
return await NotifyEvent<IEvent>(userIds, yaev);
return await NotifyEvent<IEvent>(userIds, yavscEvent);
}
/* SMS with Twilio:
public Task SendSmsAsync(TwilioSettings twilioSettigns, string number, string message)
{
var Twilio = new TwilioRestClient(twilioSettigns.AccountSID, twilioSettigns.Token);
var result = Twilio.SendMessage( twilioSettigns.SMSAccountFrom, number, message);
// Status is one of Queued, Sending, Sent, Failed or null if the number is not valid
Trace.TraceInformation(result.Status);
// Twilio doesn't currently have an async API, so return success.
return Task.FromResult(result.Status != "Failed");
} */
}
}

View file

@ -1,4 +1,8 @@

@{
ViewBag.Title = Localizer["Account"];
}
<div class="container">
<div class="lead">
<h1>Access Denied</h1>

View file

@ -1,4 +1,7 @@
@model ForgotPasswordViewModel
@{
ViewBag.Title = Localizer["Account"];
}
<form asp-controller="Account" asp-action="ForgotPassword">
<div class="form-group">
@ -6,4 +9,4 @@
<input class="form-control" placeholder="LoginOrEmail" asp-for="LoginOrEmail" autofocus>
</div>
<button class="btn btn-primary" name="button" value="send">Send me a recovery link</button>
</form>
</form>

View file

@ -1,5 +1,7 @@
@model string
@{
ViewBag.Title = Localizer["Account"];
}
<h1>Check your mail box!</h1>

View file

@ -1,4 +1,8 @@
@model LoggedOutViewModel
@{
ViewBag.Title = Localizer["Account"];
}
@model LoggedOutViewModel
@{
// set this so the layout rendering sees an anonymous user

View file

@ -1,4 +1,7 @@
@model LoginViewModel
@{
ViewBag.Title = Localizer["Account"];
}
<div class="login-page">
<div class="lead">
@ -84,4 +87,4 @@
</div>
}
</div>
</div>
</div>

View file

@ -1,4 +1,8 @@
@model LogoutViewModel
@{
ViewBag.Title = Localizer["Account"];
}
@model LogoutViewModel
<div class="logout-page">
<div class="lead">

View file

@ -1,8 +1,11 @@
@model RegisterModel
@{
ViewBag.Title = Localizer["Account"];
}
<partial name="_ValidationSummary" />
<form method="post">
@Html.EditorForModel()
<button class="btn btn-primary" name="button"
value="Register">Register</button>
</form>
</form>

View file

@ -1,4 +1,7 @@
@model ResetPasswordViewModel
@{
ViewBag.Title = Localizer["Account"];
}
<form asp-route-id="@Model.Id" asp-route-code="@Model.Code">
<p>Your email : <code>@Model.Email</code></p>
@ -15,4 +18,4 @@
<button class="btn btn-primary" name="button" value="Reset">Reset Password</button>
</form>
</form>

View file

@ -1,4 +1,7 @@
@model SignInModel
@{
ViewBag.Title = Localizer["Account"];
}
<div class="login-page">
<div class="lead">
@ -84,4 +87,4 @@
</div>
}
</div>
</div>
</div>

View file

@ -1,4 +1,7 @@
@model HaircutAdminViewModel
@{
ViewBag.Title = Localizer["Administration"];
}
<a asp-controller="HairTaints" class="btn btn-primary">
Gestion des couleurs

View file

@ -21,7 +21,19 @@
@foreach (var user in Model.Users) {
<tr>
<td>
<img src="~/avatars/@(user.UserName).xs.png" alt="avatar"/>
@if (user.UserId==User.GetUserId()) {
<span class="badge badge-warning">You</span>
}
@if (SiteSettings.Value.Admin.EMail == user.Email) {
<span class="badge badge-success">Admin</span>
}
@if (SiteSettings.Value.Owner.EMail == user.Email) {
<span class="badge badge-success">Owner</span>
}
@if (!String.IsNullOrWhiteSpace(user.Avatar))
{
<img src="@user.Avatar" alt="avatar" class="user-icon"/>
}
<span> @user.UserName &lt;@(user.Email)&gt; </span>
</td>
<td>

View file

@ -1,20 +1,9 @@
@model IdentityServer8.EntityFramework.Entities.ApiScope
@{
Layout = null;
ViewBag.Title = Localizer["ApiScope"];
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Create</title>
</head>
<body>
<h4>ApiScope</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Create">
@ -64,6 +53,3 @@
<div>
<a asp-action="Index">Back to List</a>
</div>
</body>
</html>

View file

@ -1,18 +1,8 @@
@model Yavsc.Models.YavscApiScope
@{
Layout = null;
ViewBag.Title = Localizer["ApiScope"];
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Delete</title>
</head>
<body>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>YavscApiScope</h4>
@ -61,12 +51,9 @@
@Html.DisplayFor(model => model.ShowInDiscoveryDocument)
</dd>
</dl>
<form asp-action="Delete">
<input type="hidden" asp-for="Id" />
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-action="Index">Back to List</a>
</form>
</div>
</body>
</html>

View file

@ -1,17 +1,8 @@
@model Yavsc.Models.YavscApiScope
@{
Layout = null;
ViewBag.Title = Localizer["ApiScope"];
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Details</title>
</head>
<body>
<div>
<h4>YavscApiScope</h4>
@ -65,5 +56,3 @@
<a asp-action="Edit" asp-route-id="@Model?.Id">Edit</a> |
<a asp-action="Index">Back to List</a>
</div>
</body>
</html>

View file

@ -1,17 +1,8 @@
@model Yavsc.Models.YavscApiScope
@{
Layout = null;
ViewBag.Title = Localizer["ApiScope"];
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Edit</title>
</head>
<body>
<h4>YavscApiScope</h4>
<hr />
@ -65,6 +56,3 @@
<div>
<a asp-action="Index">Back to List</a>
</div>
</body>
</html>

View file

@ -1,17 +1,10 @@
@model IEnumerable<Yavsc.Models.YavscApiScope>
@{
Layout = null;
ViewBag.Title = Localizer["ApiScope"];
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Index</title>
</head>
<body>
<p>
<a asp-action="Create">Create New</a>
</p>
@ -75,5 +68,3 @@
}
</tbody>
</table>
</body>
</html>

View file

@ -1,4 +1,7 @@
@model Client
@{
ViewBag.Title = Localizer["Client"];
}
<h2>@Localizer["Create"]</h2>
@ -77,4 +80,3 @@
<div>
<a asp-action="Index">@Localizer["Back to List"]</a>
</div>

View file

@ -1,4 +1,7 @@
@model Client
@{
ViewBag.Title = Localizer["Client"];
}
<h2>@Localizer["Delete"]</h2>
@ -63,4 +66,4 @@
<a asp-action="Index">@Localizer["Back to List"]</a>
</div>
</form>
</div>
</div>

View file

@ -2,7 +2,12 @@
@using Microsoft.AspNetCore.Html
@using System.Text
@{
ViewBag.Title = Localizer["Client"];
}
@functions {
// Lifetimes in IdentityServer are stored as seconds (int). Render them
// in a human-friendly way: "5 min", "2 h", "30 d", "—" for 0/negative.
// Zero typically means "use the global default" — show it as such so

View file

@ -1,4 +1,7 @@
@model Client
@{
ViewBag.Title = Localizer["Client"];
}
<h2>@Localizer["Edit"]</h2>
@ -303,4 +306,4 @@
<div>
<a asp-action="Index">@Localizer["Back to List"]</a>
</div>
</div>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientClaim>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Client Claims";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
@ -61,4 +65,4 @@
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientCorsOrigin>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Allowed CORS Origins";
ViewData["addAction"] = "AddCorsOrigin";
@ -23,4 +27,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientGrantType>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Allowed Grant Types";
ViewData["addAction"] = "AddGrantType";
@ -24,4 +28,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientIdPRestriction>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Identity Provider Restrictions";
ViewData["addAction"] = "AddIdPRestriction";
@ -21,4 +25,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Post-logout Redirect URIs";
ViewData["addAction"] = "AddPostLogoutRedirectUri";
@ -22,4 +26,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientProperty>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Client Properties";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
@ -62,4 +66,4 @@
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientRedirectUri>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Redirect URIs";
ViewData["addAction"] = "AddRedirectUri";
@ -22,4 +26,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientScope>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Allowed Scopes";
ViewData["addAction"] = "AddScope";
@ -22,4 +26,4 @@
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>
</p>

View file

@ -1,4 +1,8 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientSecret>
@{
ViewBag.Title = Localizer["Client"];
}
@{
ViewData["Title"] = "Edit Client Secrets";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
@ -80,4 +84,4 @@
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
<a asp-action="RegenerateSecret" asp-route-id="@clientId" class="ml-3">Regenerate a single secret</a>
</p>
</p>

View file

@ -1,6 +1,10 @@
@using IdentityServer8.Models;
@model IEnumerable<IdentityServer8.EntityFramework.Entities.Client>
@{
ViewBag.Title = Localizer["Client"];
}
<h2>@Localizer["Index"]</h2>
<p>
@ -10,7 +14,7 @@
@foreach (var item in Model)
{
<div class="list-item">
<div class="card">
<h5 class="card-title">Identifier</h5>
<h6 class="card-subtitle mb-2 text-muted">and activation</h6>
@ -36,7 +40,7 @@
</dd>
</dl>
</div>
<div class="card">
<h5 class="card-title">Urls</h5>
@ -56,7 +60,7 @@
</dd>
<dt>@Html.DisplayNameFor(model => model.AbsoluteRefreshTokenLifetime)</dt>
<dd>@Html.DisplayFor(model => item.AbsoluteRefreshTokenLifetime)</dd>
</dl>
</div>
@ -78,7 +82,7 @@
</dt>
<dd>
@Enum.GetName(typeof(AccessTokenType), item.AccessTokenType)
</dd>
</dl>
</div>
@ -86,6 +90,6 @@
<a class="btn btn-primary btn-lg" asp-action="Edit" asp-route-id="@item.Id">Edit</a>
<a class="btn btn-secondary btn-lg" asp-action="Details" asp-route-id="@item.Id">Details</a>
<a class="btn btn-danger btn-lg" asp-action="Delete" asp-route-id="@item.Id">Delete</a>
</div>
}

View file

@ -1,4 +1,7 @@
@model Client
@{
ViewBag.Title = Localizer["Client"];
}
<h2>@Localizer["Regenerate client secret"]</h2>
@ -36,4 +39,4 @@
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-danger">@Localizer["Regenerate now"]</button>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-default">@Localizer["Cancel"]</a>
</form>
</form>

View file

@ -1,3 +1,7 @@
@{
ViewBag.Title = Localizer["Client"];
}
@{
var secret = ViewBag.NewClientSecret as string;
var expiresAt = ViewBag.NewClientSecretExpiresAt as string;

View file

@ -1,10 +1,10 @@
@model IEnumerable<CommandForm>
@{
ViewBag.Title = "Index";
ViewBag.Title = Localizer["CommandForms"];
}
<h2>Index</h2>
<h2>@ViewBag.Title</h2>
<p>
<a asp-action="Create">Create New</a>
@ -22,7 +22,7 @@
</th>
<th></th>
</tr>
@foreach (var item in Model) {
<tr class="@(item.Context.Hidden?"disabled":null)">
<td>

View file

@ -1,7 +1,10 @@
@{
ViewBag.Title = Localizer["Device"];
}
<div class="page-device-success">
<div class="lead">
<h1>Success</h1>
<p>You have successfully authorized the device</p>
</div>
</div>
</div>

View file

@ -1,4 +1,7 @@
@model string
@{
ViewBag.Title = Localizer["Device"];
}
<div class="page-device-code">
<div class="lead">
@ -20,4 +23,4 @@
</form>
</div>
</div>
</div>
</div>

View file

@ -1,4 +1,7 @@
@model DeviceAuthorizationViewModel
@{
ViewBag.Title = Localizer["Device"];
}
<div class="page-device-confirmation">
<div class="lead">
@ -105,4 +108,4 @@
</div>
</div>
</form>
</div>
</div>

View file

@ -1,5 +1,9 @@
@model DiagnosticsViewModel
@{
ViewBag.Title = Localizer["Diagnostics"];
}
<div class="diagnostics-page">
<div class="lead">
<h1>Authentication Cookie</h1>
@ -22,7 +26,7 @@
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header">

View file

@ -1,4 +1,7 @@
@model Instrumentation
@{
ViewBag.Title = Localizer["Do"];
}
<h2>@Model.Tool.Name</h2>
@ -6,4 +9,4 @@
Remove Instrument</a>
<form asp-action="AddInstrument" asp-controller="Instrumentation" asp-action-id="Model.UserId" >
<select name="Name" value="" placeholder="Séléctionnez votre instrument" ></select>
</form>
</form>

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