Compare commits
30 commits
ec54108cf0
...
f2ae01729a
| Author | SHA1 | Date | |
|---|---|---|---|
| f2ae01729a | |||
| dce17888ac | |||
| f11913ec08 | |||
| a5acfcfc05 | |||
| 89aa2bc37d | |||
| 4a70abc0f9 | |||
| 835cb47b18 | |||
|
aca3ceffe2 |
|||
|
19e3b30830 |
|||
|
3932d8823f |
|||
| 6ac264fa2c | |||
| c08ff81776 | |||
| 333b066e66 | |||
|
|
a2c7cf9f9c |
||
| ef0f5ddcac | |||
| 7b3a236bcc | |||
| ee6cb34c23 | |||
| c74ac71b5d | |||
| bce6280750 | |||
| c8894c4220 | |||
| 34b4203cd1 | |||
| 17838bc78e | |||
| a0342ea988 | |||
| 406e2ff03a | |||
| 742da7c3f0 | |||
| 90dfe9c13f | |||
|
|
f4eb14d083 | ||
| 1d26cbdf3d | |||
|
|
b939c403f6 | ||
| b049551448 |
828 changed files with 7814 additions and 217113 deletions
|
|
@ -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/
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -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
2
.vscode/launch.json
vendored
|
|
@ -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
11
.vscode/mcp.json
vendored
Normal 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
14
.vscode/settings.json
vendored
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -11,7 +11,7 @@ all:
|
|||
dotnet build --nologo
|
||||
|
||||
clean:
|
||||
dotnet clean
|
||||
dotnet clean -c $(CONFIG)
|
||||
|
||||
src/Yavsc/bin/output/wwwroot:
|
||||
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
188
src/PostIt.Tests/SignaturePadControlTests.cs
Normal file
188
src/PostIt.Tests/SignaturePadControlTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
163
src/PostIt.Tests/SignaturePageViewModelTests.cs
Normal file
163
src/PostIt.Tests/SignaturePageViewModelTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
204
src/PostIt/PostIt/Controls/SignaturePadControl.cs
Normal file
204
src/PostIt/PostIt/Controls/SignaturePadControl.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
103
src/PostIt/PostIt/Models/SignaturePadData.cs
Normal file
103
src/PostIt/PostIt/Models/SignaturePadData.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
185
src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
Normal file
185
src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
src/PostIt/PostIt/Views/SignaturePage.axaml
Normal file
77
src/PostIt/PostIt/Views/SignaturePage.axaml
Normal 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>
|
||||
91
src/PostIt/PostIt/Views/SignaturePage.axaml.cs
Normal file
91
src/PostIt/PostIt/Views/SignaturePage.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,6 @@ namespace Yavsc.Abstract.Chat
|
|||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ namespace Yavsc.Abstract.Workflow
|
|||
{
|
||||
public interface IDecidableQuery: ITrackedEntity, IQuery
|
||||
{
|
||||
bool Decided { get; set; }
|
||||
bool Accepted { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@ namespace Yavsc
|
|||
public enum QueryStatus: int
|
||||
{
|
||||
Inserted,
|
||||
OwnerValidated,
|
||||
Visited,
|
||||
Rejected,
|
||||
Accepted,
|
||||
InProgress,
|
||||
|
||||
// final states
|
||||
Failed,
|
||||
Success
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ namespace Yavsc.Controllers
|
|||
Location = c.Location,
|
||||
EventDate = c.EventDate,
|
||||
Id = c.Id,
|
||||
Previsional = c.Previsional,
|
||||
Previsional = c.Provisional,
|
||||
Reason = c.Reason,
|
||||
ActivityCode = c.ActivityCode,
|
||||
BillingCode = BillingCodes.Rdv
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
134
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal file
134
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,8 @@ 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +82,7 @@ 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)
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -59,11 +59,11 @@ namespace Yavsc.Helpers
|
|||
{
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@ 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();
|
||||
|
|
|
|||
4672
src/Yavsc.Org/Migrations/20260706013420_activityModerated.Designer.cs
generated
Normal file
4672
src/Yavsc.Org/Migrations/20260706013420_activityModerated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
294
src/Yavsc.Org/Migrations/20260706013420_activityModerated.cs
Normal file
294
src/Yavsc.Org/Migrations/20260706013420_activityModerated.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
65
src/Yavsc.Org/Resources/Views/CommandForms/Index.resx
Normal file
65
src/Yavsc.Org/Resources/Views/CommandForms/Index.resx
Normal 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>
|
||||
65
src/Yavsc.Org/Resources/Views/Manage/SetActivity.resx
Normal file
65
src/Yavsc.Org/Resources/Views/Manage/SetActivity.resx
Normal 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>
|
||||
|
|
@ -101,7 +101,7 @@ namespace Yavsc.Services
|
|||
return _isCop[userName];
|
||||
}
|
||||
|
||||
public void OnDisctonnected(string connectionId)
|
||||
public void OnDisconnected(string connectionId)
|
||||
{
|
||||
string uname;
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -98,19 +98,19 @@ namespace Yavsc.Services
|
|||
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);
|
||||
}
|
||||
|
||||
/* 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");
|
||||
|
||||
} */
|
||||
return await NotifyEvent<IEvent>(userIds, yavscEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
|
||||
<div class="container">
|
||||
<div class="lead">
|
||||
<h1>Access Denied</h1>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model ForgotPasswordViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
<form asp-controller="Account" asp-action="ForgotPassword">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
@model string
|
||||
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
<h1>Check your mail box!</h1>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model LoggedOutViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
@model LoggedOutViewModel
|
||||
|
||||
@{
|
||||
// set this so the layout rendering sees an anonymous user
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model LoginViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
<div class="login-page">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model LogoutViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
@model LogoutViewModel
|
||||
|
||||
<div class="logout-page">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model RegisterModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
<partial name="_ValidationSummary" />
|
||||
<form method="post">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model SignInModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Account"];
|
||||
}
|
||||
|
||||
<div class="login-page">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model HaircutAdminViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Administration"];
|
||||
}
|
||||
|
||||
<a asp-controller="HairTaints" class="btn btn-primary">
|
||||
Gestion des couleurs
|
||||
|
|
|
|||
|
|
@ -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 <@(user.Email)> </span>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -67,6 +57,3 @@
|
|||
<input type="submit" value="Delete" class="btn btn-danger" /> |
|
||||
<a asp-action="Index">Back to List</a>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model Client
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
<h2>@Localizer["Delete"]</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model Client
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
<h2>@Localizer["Edit"]</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientCorsOrigin>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Allowed CORS Origins";
|
||||
ViewData["addAction"] = "AddCorsOrigin";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientGrantType>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Allowed Grant Types";
|
||||
ViewData["addAction"] = "AddGrantType";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientIdPRestriction>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Identity Provider Restrictions";
|
||||
ViewData["addAction"] = "AddIdPRestriction";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Post-logout Redirect URIs";
|
||||
ViewData["addAction"] = "AddPostLogoutRedirectUri";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientRedirectUri>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Redirect URIs";
|
||||
ViewData["addAction"] = "AddRedirectUri";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientScope>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Allowed Scopes";
|
||||
ViewData["addAction"] = "AddScope";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
@using IdentityServer8.Models;
|
||||
@model IEnumerable<IdentityServer8.EntityFramework.Entities.Client>
|
||||
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
<h2>@Localizer["Index"]</h2>
|
||||
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model Client
|
||||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
<h2>@Localizer["Regenerate client secret"]</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
@{
|
||||
ViewBag.Title = Localizer["Client"];
|
||||
}
|
||||
|
||||
@{
|
||||
var secret = ViewBag.NewClientSecret as string;
|
||||
var expiresAt = ViewBag.NewClientSecretExpiresAt as string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
@{
|
||||
ViewBag.Title = Localizer["Device"];
|
||||
}
|
||||
|
||||
<div class="page-device-success">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model string
|
||||
@{
|
||||
ViewBag.Title = Localizer["Device"];
|
||||
}
|
||||
|
||||
<div class="page-device-code">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model DeviceAuthorizationViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Device"];
|
||||
}
|
||||
|
||||
<div class="page-device-confirmation">
|
||||
<div class="lead">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
@model DiagnosticsViewModel
|
||||
|
||||
@{
|
||||
ViewBag.Title = Localizer["Diagnostics"];
|
||||
}
|
||||
|
||||
<div class="diagnostics-page">
|
||||
<div class="lead">
|
||||
<h1>Authentication Cookie</h1>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@model Instrumentation
|
||||
@{
|
||||
ViewBag.Title = Localizer["Do"];
|
||||
}
|
||||
|
||||
<h2>@Model.Tool.Name</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
@{
|
||||
ViewBag.Title = Localizer["FrontOffice"];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue