Merge pull request #66 from pazof/feat/estimate

Feat/estimate
This commit is contained in:
Paul Schneider 2026-07-06 00:58:04 +01:00 committed by GitHub
commit aca3ceffe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 7502 additions and 698 deletions

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

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

13
.vscode/settings.json vendored
View file

@ -26,5 +26,16 @@
"cSpell.language": "fr,en", "cSpell.language": "fr,en",
"makefile.configureOnOpen": false, "makefile.configureOnOpen": false,
"search.useGlobalIgnoreFiles": true, "search.useGlobalIgnoreFiles": true,
"search.useParentIgnoreFiles": true "search.useParentIgnoreFiles": true,
"chat.mcp.serverSampling": {
"yavsc/.vscode/mcp.json: openclaw": {
"allowedModels": [
"copilot/auto",
"copilotcli/claude-haiku-4.5",
"copilotcli/gpt-4.1",
"copilotcli/gpt-5-mini",
"copilotcli/mai-code-1-flash-picker"
]
}
}
} }

View file

@ -13,9 +13,9 @@
--> -->
<ItemGroup> <ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.7.0" /> <PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.0.5-preview-net9" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.0.5-preview-net9" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
@ -24,10 +24,10 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -26,7 +26,7 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
COPY . . COPY . .
# 3. Restauration des dépendances avec vos workloads actifs # 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 # 4. Restauration des dépendances pour tous les projets
RUN dotnet restore RUN dotnet restore

View file

@ -10,8 +10,8 @@ include .env
all: all:
dotnet build --nologo dotnet build --nologo
clean: clean:
dotnet clean dotnet clean -c $(CONFIG)
src/Yavsc/bin/output/wwwroot: src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
@ -31,7 +31,7 @@ src/Yavsc.Server/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.Server.dll:
src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll: src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll:
dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj
$(DESTDIR): $(DESTDIR):
mkdir $(DESTDIR) mkdir $(DESTDIR)
install: $(DESTDIR) install: $(DESTDIR)

View file

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

View file

@ -14,7 +14,7 @@ public class LoginPageViewModelTests
// short-circuits the system browser. The authority signs its // short-circuits the system browser. The authority signs its
// access_token with RS256; the fake browser captures the redirect // access_token with RS256; the fake browser captures the redirect
// URI so the authority can complete the token exchange. // 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 browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings var settings = new PostIt.Settings
@ -96,7 +96,7 @@ public class LoginPageViewModelTests
// double slash before /.well-known/openid-configuration. The // double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient // stub advertises itself without the trailing slash; OidcClient
// must bridge. // must bridge.
using var authority = await OidcStubAuthority.StartAsync(); using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings var settings = new PostIt.Settings
@ -254,4 +254,4 @@ public class LoginPageViewModelTests
"https://yavsc.example.com/.well-known/openid-configuration", "https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage); vm.StatusMessage);
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -105,8 +105,8 @@ public partial class LoginPageViewModel : ViewModelBase
/// callback hand-off: when AwaitingCallback never resolves, /// callback hand-off: when AwaitingCallback never resolves,
/// the OS never re-launched PostIt with the postit:// URL. /// the OS never re-launched PostIt with the postit:// URL.
/// </summary> /// </summary>
private OidcLoginPhase _phase = OidcLoginPhase.Idle; private OIDCLoginPhase _phase = OIDCLoginPhase.Idle;
public OidcLoginPhase Phase public OIDCLoginPhase Phase
{ {
get => _phase; get => _phase;
private set private set
@ -122,13 +122,13 @@ public partial class LoginPageViewModel : ViewModelBase
/// </summary> /// </summary>
public string PhaseLabel => _phase switch public string PhaseLabel => _phase switch
{ {
OidcLoginPhase.Idle => "En attente", OIDCLoginPhase.Idle => "En attente",
OidcLoginPhase.Discovering => "Découverte OIDC…", OIDCLoginPhase.Discovering => "Découverte OIDC…",
OidcLoginPhase.OpeningBrowser => "Ouverture du navigateur…", OIDCLoginPhase.OpeningBrowser => "Ouverture du navigateur…",
OidcLoginPhase.AwaitingCallback => "En attente du callback postit://…", OIDCLoginPhase.AwaitingCallback => "En attente du callback postit://…",
OidcLoginPhase.ExchangingCode => "Échange du code contre les jetons…", OIDCLoginPhase.ExchangingCode => "Échange du code contre les jetons…",
OidcLoginPhase.Success => "Connecté", OIDCLoginPhase.Success => "Connecté",
OidcLoginPhase.Error => "Erreur", OIDCLoginPhase.Error => "Erreur",
_ => _phase.ToString(), _ => _phase.ToString(),
}; };
@ -275,7 +275,7 @@ public partial class LoginPageViewModel : ViewModelBase
// The progress sink drives Phase / PhaseLabel; StatusMessage // The progress sink drives Phase / PhaseLabel; StatusMessage
// keeps the text detail (URLs, error messages). Same // keeps the text detail (URLs, error messages). Same
// underlying flow, two views. // underlying flow, two views.
var progress = new Progress<OidcLoginPhase>(p => Phase = p); var progress = new Progress<OIDCLoginPhase>(p => Phase = p);
await LoginInteractiveCoreAsync(_api, progress); await LoginInteractiveCoreAsync(_api, progress);
IsBusy = false; IsBusy = false;
@ -299,7 +299,7 @@ public partial class LoginPageViewModel : ViewModelBase
/// </summary> /// </summary>
private async Task LoginInteractiveCoreAsync( private async Task LoginInteractiveCoreAsync(
YavscApiClient api, YavscApiClient api,
IProgress<OidcLoginPhase>? progress = null) IProgress<OIDCLoginPhase>? progress = null)
{ {
var original = Platform.CreateBrowser; var original = Platform.CreateBrowser;
try try

View file

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

View file

@ -35,6 +35,17 @@
<Button Command="{Binding New}" Content="New post" /> <Button Command="{Binding New}" Content="New post" />
<Button Command="{Binding Save}" Content="Save" /> <Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" /> <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>
</StackPanel> </StackPanel>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,24 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers; using Microsoft.Extensions.Localization;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Workflow; using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Services;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
public class CommandFormsController : Controller public class CommandFormsController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
private readonly IStringLocalizer<CommandFormsController> _localizer;
public CommandFormsController(ApplicationDbContext context) public CommandFormsController(ApplicationDbContext context,
IStringLocalizer<CommandFormsController> localizer)
{ {
_context = context; _context = context;
_localizer = localizer;
} }
// GET: CommandForms // GET: CommandForms
@ -47,11 +51,14 @@ namespace Yavsc.Controllers
SetViewBag(); SetViewBag();
return View(); return View();
} }
private void SetViewBag(CommandForm commandForm = null) private void SetViewBag(CommandForm commandForm = null)
{ {
ViewBag.ActivityCode = new SelectList(_context.Activities, "Code", "Name", commandForm?.ActivityCode); 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 // POST: CommandForms/Create
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ namespace Yavsc.Controllers
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
readonly IStringLocalizer<ProjectController> _localizer; readonly IStringLocalizer<ProjectController> _localizer;
readonly IStringLocalizer<BugController> _bugLocalizer; readonly IStringLocalizer<BugController> _bugLocalizer;
public ProjectController(ApplicationDbContext context, public ProjectController(ApplicationDbContext context,
IStringLocalizer<ProjectController> localizer, IStringLocalizer<ProjectController> localizer,
IStringLocalizer<BugController> bugLocalizer IStringLocalizer<BugController> bugLocalizer
@ -32,7 +32,7 @@ namespace Yavsc.Controllers
// GET: Project // GET: Project
public async Task<IActionResult> Index() 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()); return View(await applicationDbContext.ToListAsync());
} }

View file

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

View file

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

View file

@ -41,6 +41,7 @@ using Yavsc.Settings;
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using IdentityServer8.Models; using IdentityServer8.Models;
using IdentityServer8.EntityFramework.Mappers; using IdentityServer8.EntityFramework.Mappers;
using Yavsc.Server.Hubs;
namespace Yavsc.Extensions; namespace Yavsc.Extensions;

View file

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

View file

@ -12,12 +12,16 @@ namespace Yavsc.Helpers {
this ApplicationDbContext _dbContext, List<UserActivity> activity) this ApplicationDbContext _dbContext, List<UserActivity> activity)
{ {
var activities = activity.ToArray(); 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() { x=> new SelectListItem() {
Value = x.Code, Text = x.Name, Selected = activities.Any(a=>a.DoesCode == x.Code) Value = x.Code, Text = x.Name, Selected = activities.Any(a=>a.DoesCode == x.Code)
} ).ToList(); } ).ToList();
return items; return items;
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class moderatedActivities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MusicalPreference_GeneralSettings_GeneralSettingsUserId",
table: "MusicalPreference");
migrationBuilder.RenameColumn(
name: "GeneralSettingsUserId",
table: "MusicalPreference",
newName: "MusicLoverSettingsUserId");
migrationBuilder.RenameIndex(
name: "IX_MusicalPreference_GeneralSettingsUserId",
table: "MusicalPreference",
newName: "IX_MusicalPreference_MusicLoverSettingsUserId");
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: "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_GeneralSettings_MusicLoverSettingsUserId",
table: "MusicalPreference",
column: "MusicLoverSettingsUserId",
principalTable: "GeneralSettings",
principalColumn: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MusicalPreference_GeneralSettings_MusicLoverSettingsUserId",
table: "MusicalPreference");
migrationBuilder.DropTable(
name: "Signatures");
migrationBuilder.DropColumn(
name: "Moderated",
table: "Activities");
migrationBuilder.RenameColumn(
name: "MusicLoverSettingsUserId",
table: "MusicalPreference",
newName: "GeneralSettingsUserId");
migrationBuilder.RenameIndex(
name: "IX_MusicalPreference_MusicLoverSettingsUserId",
table: "MusicalPreference",
newName: "IX_MusicalPreference_GeneralSettingsUserId");
migrationBuilder.AlterColumn<string>(
name: "Description",
table: "Bug",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(10240)",
oldMaxLength: 10240,
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_MusicalPreference_GeneralSettings_GeneralSettingsUserId",
table: "MusicalPreference",
column: "GeneralSettingsUserId",
principalTable: "GeneralSettings",
principalColumn: "UserId");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -1413,6 +1413,50 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN"); 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 => modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{ {
b.Property<long>("FileId") b.Property<long>("FileId")
@ -2157,7 +2201,8 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("text"); .HasMaxLength(10240)
.HasColumnType("character varying(10240)");
b.Property<long?>("FeatureId") b.Property<long?>("FeatureId")
.HasColumnType("bigint"); .HasColumnType("bigint");
@ -2534,7 +2579,7 @@ namespace Yavsc.Migrations
b.Property<string>("DjSettingsUserId") b.Property<string>("DjSettingsUserId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("GeneralSettingsUserId") b.Property<string>("MusicLoverSettingsUserId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("Rate") b.Property<int>("Rate")
@ -2547,7 +2592,7 @@ namespace Yavsc.Migrations
b.HasIndex("DjSettingsUserId"); b.HasIndex("DjSettingsUserId");
b.HasIndex("GeneralSettingsUserId"); b.HasIndex("MusicLoverSettingsUserId");
b.HasIndex("TendencyId"); b.HasIndex("TendencyId");
@ -2585,16 +2630,6 @@ namespace Yavsc.Migrations
b.ToTable("DjSettings"); 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 => modelBuilder.Entity("Yavsc.Models.Musical.Profiles.Instrumentation", b =>
{ {
b.Property<long>("InstrumentId") b.Property<long>("InstrumentId")
@ -2610,6 +2645,16 @@ namespace Yavsc.Migrations
b.ToTable("Instrumentation"); b.ToTable("Instrumentation");
}); });
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("UserId");
b.ToTable("GeneralSettings");
});
modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b => modelBuilder.Entity("Yavsc.Models.Payment.PayPalPayment", b =>
{ {
b.Property<string>("CreationToken") b.Property<string>("CreationToken")
@ -2894,6 +2939,9 @@ namespace Yavsc.Migrations
b.Property<bool>("Hidden") b.Property<bool>("Hidden")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("Moderated")
.HasColumnType("boolean");
b.Property<string>("ModeratorGroupName") b.Property<string>("ModeratorGroupName")
.HasColumnType("text"); .HasColumnType("text");
@ -3737,6 +3785,25 @@ namespace Yavsc.Migrations
b.Navigation("Query"); 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 => modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{ {
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File") b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
@ -4162,9 +4229,9 @@ namespace Yavsc.Migrations
.WithMany("SoundColor") .WithMany("SoundColor")
.HasForeignKey("DjSettingsUserId"); .HasForeignKey("DjSettingsUserId");
b.HasOne("Yavsc.Models.Musical.Profiles.GeneralSettings", null) b.HasOne("Yavsc.Models.Musical.Profiles.MusicLoverSettings", null)
.WithMany("SoundColor") .WithMany("SoundColor")
.HasForeignKey("GeneralSettingsUserId"); .HasForeignKey("MusicLoverSettingsUserId");
b.HasOne("Yavsc.Models.Musical.MusicalTendency", "MusicalTendency") b.HasOne("Yavsc.Models.Musical.MusicalTendency", "MusicalTendency")
.WithMany() .WithMany()
@ -4519,6 +4586,8 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b => modelBuilder.Entity("Yavsc.Models.Billing.Estimate", b =>
{ {
b.Navigation("Bill"); b.Navigation("Bill");
b.Navigation("Signatures");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b => modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
@ -4580,7 +4649,7 @@ namespace Yavsc.Migrations
b.Navigation("SoundColor"); b.Navigation("SoundColor");
}); });
modelBuilder.Entity("Yavsc.Models.Musical.Profiles.GeneralSettings", b => modelBuilder.Entity("Yavsc.Models.Musical.Profiles.MusicLoverSettings", b =>
{ {
b.Navigation("SoundColor"); b.Navigation("SoundColor");
}); });

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,8 @@
 @{
ViewBag.Title = Localizer["Account"];
}
<div class="container"> <div class="container">
<div class="lead"> <div class="lead">
<h1>Access Denied</h1> <h1>Access Denied</h1>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,9 @@
@model IdentityServer8.EntityFramework.Entities.ApiScope @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> <h4>ApiScope</h4>
<hr />
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<form asp-action="Create"> <form asp-action="Create">
@ -64,6 +53,3 @@
<div> <div>
<a asp-action="Index">Back to List</a> <a asp-action="Index">Back to List</a>
</div> </div>
</body>
</html>

View file

@ -1,18 +1,8 @@
@model Yavsc.Models.YavscApiScope @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> <h3>Are you sure you want to delete this?</h3>
<div> <div>
<h4>YavscApiScope</h4> <h4>YavscApiScope</h4>
@ -61,12 +51,9 @@
@Html.DisplayFor(model => model.ShowInDiscoveryDocument) @Html.DisplayFor(model => model.ShowInDiscoveryDocument)
</dd> </dd>
</dl> </dl>
<form asp-action="Delete"> <form asp-action="Delete">
<input type="hidden" asp-for="Id" /> <input type="hidden" asp-for="Id" />
<input type="submit" value="Delete" class="btn btn-danger" /> | <input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-action="Index">Back to List</a> <a asp-action="Index">Back to List</a>
</form> </form>
</div>
</body>
</html>

View file

@ -1,17 +1,8 @@
@model Yavsc.Models.YavscApiScope @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> <div>
<h4>YavscApiScope</h4> <h4>YavscApiScope</h4>
@ -65,5 +56,3 @@
<a asp-action="Edit" asp-route-id="@Model?.Id">Edit</a> | <a asp-action="Edit" asp-route-id="@Model?.Id">Edit</a> |
<a asp-action="Index">Back to List</a> <a asp-action="Index">Back to List</a>
</div> </div>
</body>
</html>

View file

@ -1,17 +1,8 @@
@model Yavsc.Models.YavscApiScope @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> <h4>YavscApiScope</h4>
<hr /> <hr />
@ -65,6 +56,3 @@
<div> <div>
<a asp-action="Index">Back to List</a> <a asp-action="Index">Back to List</a>
</div> </div>
</body>
</html>

View file

@ -1,17 +1,10 @@
@model IEnumerable<Yavsc.Models.YavscApiScope> @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> <p>
<a asp-action="Create">Create New</a> <a asp-action="Create">Create New</a>
</p> </p>
@ -75,5 +68,3 @@
} }
</tbody> </tbody>
</table> </table>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
@{
ViewBag.Title = Localizer["FrontOffice"];
}

View file

@ -0,0 +1,4 @@
@{
ViewBag.Title = Localizer["FrontOffice"];
}

View file

@ -0,0 +1,4 @@
@{
ViewBag.Title = Localizer["FrontOffice"];
}

View file

@ -0,0 +1,4 @@
@{
ViewBag.Title = Localizer["FrontOffice"];
}

View file

@ -13,7 +13,7 @@
<dt>EstimateToSignAsPro <dt>EstimateToSignAsPro
</dt> </dt>
<dd>@Html.DisplayFor(m=>m.EstimateToSignAsProCount) <dd>@Html.DisplayFor(m=>m.EstimateToHonorAsProCount)
</dd> </dd>
<dt>EstimateToSignAsCli <dt>EstimateToSignAsCli
@ -21,11 +21,6 @@
<dd>@Html.DisplayFor(m=>m.EstimateToSignAsCliCount) <dd>@Html.DisplayFor(m=>m.EstimateToSignAsCliCount)
</dd> </dd>
<dt>BillToSignAsPro
</dt>
<dd>@Html.DisplayFor(m=>m.BillToSignAsProCount)
</dd>
<dt>BillToSignAsCli <dt>BillToSignAsCli
</dt> </dt>
<dd>@Html.DisplayFor(m=>m.BillToSignAsCliCount) <dd>@Html.DisplayFor(m=>m.BillToSignAsCliCount)

View file

@ -1,4 +1,7 @@
@model Yavsc.Models.Haircut.BrusherProfile @model Yavsc.Models.Haircut.BrusherProfile
@{
ViewBag.Title = Localizer["FrontOffice"];
}
<fieldset> <fieldset>
<legend>Disponibilités</legend> <legend>Disponibilités</legend>

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