Compare commits

...

18 commits

Author SHA1 Message Date
fa4577fb35 Merge pull request 'feat/estimate' (#50) from feat/estimate into main
All checks were successful
Dotnet build and test / build (push) Successful in 3m8s
Reviewed-on: #50
2026-09-04 22:06:49 +01:00
45d3f1f4f8
prepare 1.0.8-rc12: avatar loading, authority binding, and command header
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m48s
Forgejo Release / release (push) Successful in 9m39s
2026-09-04 21:56:04 +01:00
2c6ab134f5
render the avatar upload status
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m18s
2026-09-04 20:28:54 +01:00
343538a7ea
setup the avatar 2026-09-04 20:25:06 +01:00
b820348558
Fix set-avatar auth flow and API avatar upload stability
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m5s
2026-09-04 19:03:08 +01:00
7a1a9ad2ee
la version impair est stable
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m6s
2026-09-04 15:50:22 +01:00
81beb0a102
test the avatar failback
All checks were successful
Dotnet build and test / build (pull_request) Successful in 13m35s
2026-09-04 15:21:42 +01:00
8e2fd65b68
Dead code cleanup
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m9s
2026-09-04 15:08:51 +01:00
0d784e919c
ensure the avatar path 2026-09-04 15:08:35 +01:00
e788910826
code spaces 2026-09-04 15:02:11 +01:00
6b4e52e7ce
refactoring the command
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m54s
2026-09-04 14:53:00 +01:00
fc820362b9
refactoring the command form creation 2026-09-04 13:19:31 +01:00
2a36c19610
renaming 2026-09-02 14:52:17 +01:00
914e9486b5
Platform.TryGetCurrentLocationAsync
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m30s
2026-09-02 14:41:05 +01:00
55d305f295
test(oidc): cover discovery regression and consume fixed IdentityServer8 rc007
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m15s
Add an application-level smoke test for /.well-known/openid-configuration
and a direct IResourceStore check so the IdentityServer8 mapper failure is
caught from the Yavsc side.

Bump HigginsSoft.IdentityServer8 packages to 8.1.0-pazofrc007, which
includes the EntityFramework mapper fix removing the failing AutoMapper
static initialization.

This closes the 500 on OIDC discovery and restores remoting/auth flows
that depend on discovery metadata.
2026-08-31 17:35:02 +01:00
b46548a08f
Release 1.0.8-rc11
All checks were successful
Forgejo Release / release (push) Successful in 12m48s
Dotnet build and test / build (pull_request) Successful in 11m46s
2026-08-31 13:37:54 +01:00
5b4bb983de
chore(deps): cleanup Yavsc.Org restore package configuration 2026-08-31 13:29:12 +01:00
9dc0138230
fix: bump EF Core Sqlite to address NU1903 vulnerability 2026-08-31 13:26:03 +01:00
80 changed files with 2363 additions and 1023 deletions

2
.vscode/tasks.json vendored
View file

@ -7,7 +7,7 @@
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,

View file

@ -1,5 +1,42 @@
# Changelog
## [1.0.8-rc12] - unstable
### Added
* [PostIt] Nouveau helper d'image `ImageHelper` pour charger des bitmaps depuis les ressources et depuis le web.
* [PostIt] Affichage de l'avatar XS dans la liste des performers d'activites, avec fallback visuel (initiale utilisateur).
* [PostIt.Tests] Nouveaux tests autour des URLs avatar et de la source d'autorite.
* [contrib] Ajout d'un `README.md` utilitaire pour les symboles/icones.
### Changed
* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`.
* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification.
* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`.
* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
### Fixed
néant
## [1.0.8-rc11] - unstable
### Added
nothing
### Changed
* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11.
* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise.
* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu.
### Fixed
* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`.
* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`.
## [1.0.8-rc10] - unstable
### Added

View file

@ -49,6 +49,19 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Onboarding assiste par agents IA
Pour accelerer la prise en main du depot avec Copilot/Plan/Explore :
- Parcours pas-a-pas : [doc/onboarding-agents.md](./doc/onboarding-agents.md)
- Playbook d'usage des agents : [doc/agent-playbook.md](./doc/agent-playbook.md)
- Matrice intentions -> agent -> preuves : [doc/agent-intent-matrix.md](./doc/agent-intent-matrix.md)
Regle minimale en contribution assistee par agent :
- expliciter l'impact architecture,
- justifier le niveau de tests execute,
- documenter les risques residuels.
## Le CHANGELOG.md
Le `CHANGELOG.md` est un document de changement de version
@ -61,8 +74,8 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt

View file

@ -1,15 +1,31 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
@ -17,14 +33,24 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
</Project>
</Project>

View file

@ -28,6 +28,10 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md)
pour le sommaire complet. La racine de l'architecture est
[Architecture.md](./doc/Architecture.md).
Pour une prise en main guidee avec agents IA:
- parcours onboarding: [doc/onboarding-agents.md](./doc/onboarding-agents.md)
- playbook d'usage: [doc/agent-playbook.md](./doc/agent-playbook.md)
# Construction et déploiement

5
contrib/README.md Normal file
View file

@ -0,0 +1,5 @@
# Read me
## Note aux icones
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩

View file

@ -18,6 +18,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests |
| [agent-playbook.md](agent-playbook.md) | Playbook d'usage de Copilot, Plan, Explore avec scénarios et anti-patterns |
| [agent-intent-matrix.md](agent-intent-matrix.md) | Matrice intentions développeur -> agent -> preuves attendues |
## Roadmap & design exploration

View file

@ -0,0 +1,20 @@
# Matrice intentions -> agent -> preuves
Cette matrice aide a choisir rapidement l'agent adapte et a exiger
une sortie verifiable.
| Intention developpeur | Agent principal | Entrees minimales | Sortie minimale attendue | Verification |
|---|---|---|---|---|
| Comprendre un BC avant changement | Explore | BC cible, profondeur, contrainte de perimetre | Composants, points d'entree, tests relies, risques | Lire les fichiers cites + confirmer tests proposes |
| Decomposer une tache transverse | Plan | Objectif, contraintes, definition of done | Etapes ordonnees, dependances, criteres de verif | Verifier que chaque etape a une preuve observable |
| Implementer une modif locale | Copilot | Fichier cible, comportement attendu, conventions | Patch minimal, justification courte | Build/test du projet impacte |
| Ajouter un test smoke | Copilot (+Explore) | Route/endpoint, projet de test cible | Test + commande cible | Execution test cible |
| Corriger une regression | Plan + Copilot | Symptome, zone suspecte, test attendu | Fix + test NonRegression | Test rouge avant, vert apres |
| Diagnostiquer flux PostIt/OIDC | Explore + Plan | Flux, symptome, plateforme | Carte du flux + hypotheses testables | Verification manuelle + tests existants |
## Regles d'arbitrage
- Si l'intention est "comprendre": commencer par Explore.
- Si l'intention est "orchestrer": commencer par Plan.
- Si l'intention est "produire": utiliser Copilot apres cadrage.
- Si une sortie n'inclut pas de preuve, elle est incomplete.

101
doc/agent-playbook.md Normal file
View file

@ -0,0 +1,101 @@
# Playbook d'usage des agents IA (Yavsc)
Ce playbook normalise l'usage de Copilot, Plan et Explore dans le depot.
Il privilegie des sorties verifiables: fichiers, commandes tests, risques.
## Quand utiliser quel agent
- Plan: quand la tache est ambigue, transverse ou risquee.
- Explore: quand il faut cartographier rapidement des zones du code.
- Copilot: quand les specifications sont claires et localisees.
## Prompt type (base)
Utiliser ce squelette avant toute tache non triviale:
```text
Contexte: <projet/fichier/fonction>
Objectif: <resultat observable>
Contraintes: <style, archi, perimetre>
Verification: <tests exacts a lancer>
Sortie attendue: <fichiers modifies + risques>
```
## 4 scenarios de reference
## 1) Explorer un bounded context
Intention:
- Comprendre ou implementer un changement dans un BC sans regression laterale.
Prompt minimal:
```text
Explore le BC <nom> avec profondeur medium.
Retour: composants touches, points d'entree, tests existants et risques.
```
Preuves attendues:
- Carte des fichiers a modifier.
- Test(s) smoke/mandatory proposes.
## 2) Ajouter un smoke test
Intention:
- Couvrir rapidement un endpoint ou une route publique.
Prompt minimal:
```text
Propose un smoke test pour <route/endpoint> dans le projet de test approprie.
Respecte les conventions de doc/testing.md.
```
Preuves attendues:
- Fichier test cree/modifie.
- Commande precise pour executer le test cible.
## 3) Corriger une regression backend API
Intention:
- Corriger un bug sans casser un flux voisin.
Prompt minimal:
```text
Planifie puis implemente un fix de <symptome> dans <projet>.
Ajoute/ajuste un test NonRegression rouge puis vert.
```
Preuves attendues:
- Explication cause racine.
- Test non-regression associe.
- Commande d'execution et resultat attendu.
## 4) Tracer un flux PostIt/OIDC
Intention:
- Localiser une cassure d'authentification entre client et serveur.
Prompt minimal:
```text
Cartographie le flux OIDC PostIt: entrypoints, callback, stockage token,
refresh. Donne points de rupture probables et tests/verification proposes.
```
Preuves attendues:
- Liste ordonnee des etapes du flux.
- Fichiers critiques.
- Hypotheses testables.
## Anti-patterns a eviter
- Prompt sans objectif verifiable.
- Demande trop large sans perimetre de fichiers.
- Validation basee uniquement sur "ca semble correct".
- Pas de lien entre changement et niveau de test.
## Gate PR minimale (agent-assiste)
Avant validation:
- Impact architecture explicite.
- Rationale de choix agent explicite.
- Test(s) executes et justifies.
- Risques residuels documentes.

73
doc/onboarding-agents.md Normal file
View file

@ -0,0 +1,73 @@
# Onboarding guide: agents IA + architecture + tests
Ce guide est optimise pour accelerer la prise en main des agents IA
(Copilot, Plan, Explore) dans Yavsc, avec une verification rapide
par les tests.
## Resultat attendu
A la fin du parcours, un contributeur doit pouvoir:
- Identifier les projets impactes par une modification.
- Choisir l'agent adapte a l'intention de travail.
- Produire une proposition de changement verifiable par les tests.
## Parcours en 3 modules
## Module A - Comprendre le terrain (30-45 min)
Objectif: acquerir une lecture fiable de l'architecture.
1. Lire [README.md](../README.md) puis [Architecture.md](Architecture.md).
2. Lire [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md).
3. Selon le domaine:
- Backend/API: [architecture/workflow-multi-parties.md](architecture/workflow-multi-parties.md)
- PostIt: [architecture/postit.md](architecture/postit.md) puis [architecture/postit-oidc.md](architecture/postit-oidc.md)
Definition of done:
- Expliquer en 5 phrases quelles couches sont touchees.
- Citer le ou les points d'entree applicatifs a verifier.
## Module B - Boucle tests rapide (20-30 min)
Objectif: verifier rapidement sans lancer toute la suite.
1. Lire [testing.md](testing.md).
2. Lancer les smoke tests d'abord, puis mandatory selon le projet.
3. N'elargir au test complet que si le scope depasse le BC touche.
Definition of done:
- Fournir la commande test executee.
- Expliquer pourquoi ce niveau de test est suffisant.
## Module C - Usage agentique en production (30-40 min)
Objectif: utiliser les agents comme accelerateurs, pas comme boites noires.
1. Plan: decomposer la tache en etapes verifiables.
2. Explore: collecter le contexte code/doc precise.
3. Copilot: implementer localement et verifier.
Regles:
- Toujours donner un contexte explicite (fichier, but, contrainte).
- Demander des preuves observables (fichiers modifies, tests, risques).
- Refuser toute sortie non verifiable.
Definition of done:
- Une tache simple est livree avec:
- Plan
- Changement local
- Preuve par test
## Routine continue (sans echeance fixe)
Rituels recommandes:
- Hebdo: revue des prompts qui ont bien fonctionne.
- Mensuel: mise a jour du present guide et du playbook.
- A chaque incident: ajouter un anti-pattern dans le playbook.
## Check-list de validation
- Le changement indique son impact architecture.
- Le choix de l'agent est justifie.
- La preuve test est incluse.
- Les risques residuels sont explicitement listes.

View file

@ -1,4 +1,5 @@
using Android.App;
using Android;
using Android.Runtime;
using Avalonia;
using Avalonia.Android;
@ -9,6 +10,9 @@ using Avalonia.Controls;
using Avalonia.Styling;
using Yavsc.Api.Client;
[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)]
[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)]
namespace PostIt.Android
{
[Application]

View file

@ -57,6 +57,17 @@ public class MainActivity : AvaloniaMainActivity
}
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
{
if (PostIt.Android.Services.AndroidCurrentLocationProvider
.HandlePermissionResult(requestCode, grantResults))
{
return;
}
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}
internal static class AndroidOidcCallbackSink
{
private static System.Threading.Tasks.TaskCompletionSource<string>? _pending;

View file

@ -14,11 +14,12 @@ internal static class PlatformBootstrap
{
internal static void InitPlatform()
{
Platform.CreateBrowser = () =>
{
var activity = MainActivity.Current;
return activity is null ? null : new AndroidSystemBrowser(activity);
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
}
}

View file

@ -0,0 +1,130 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content.PM;
using Android.Locations;
using AndroidX.Core.App;
using AndroidX.Core.Content;
using PostIt.Services;
namespace PostIt.Android.Services;
internal static class AndroidCurrentLocationProvider
{
public static async Task<CurrentLocationResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken)
{
var activity = MainActivity.Current;
if (activity is null)
{
return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête.");
}
var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false);
if (!permissionGranted)
{
return CurrentLocationResult.PermissionDenied();
}
var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager;
if (locationManager is null)
{
return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible.");
}
var location = locationManager.GetProviders(enabledOnly: true)?
.Select(provider => locationManager.GetLastKnownLocation(provider))
.Where(candidate => candidate is not null)
.OrderByDescending(candidate => candidate!.Time)
.ThenBy(candidate => candidate!.Accuracy)
.FirstOrDefault();
if (location is null)
{
return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez.");
}
return CurrentLocationResult.Success(location.Latitude, location.Longitude);
}
public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults)
=> LocationPermissionBroker.HandleResult(requestCode, grantResults);
private static class LocationPermissionBroker
{
private const int RequestCode = 4042;
private static readonly string[] RequestedPermissions =
{
Manifest.Permission.AccessFineLocation,
Manifest.Permission.AccessCoarseLocation,
};
private static readonly object SyncRoot = new();
private static TaskCompletionSource<bool>? _pendingRequest;
public static Task<bool> EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken)
{
if (HasLocationPermission(activity))
{
return Task.FromResult(true);
}
lock (SyncRoot)
{
if (_pendingRequest is null)
{
_pendingRequest = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode);
}
if (!cancellationToken.CanBeCanceled)
{
return _pendingRequest.Task;
}
return WaitAsync(_pendingRequest.Task, cancellationToken);
}
}
public static bool HandleResult(int requestCode, Permission[]? grantResults)
{
if (requestCode != RequestCode)
{
return false;
}
var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted);
TaskCompletionSource<bool>? pendingRequest;
lock (SyncRoot)
{
pendingRequest = _pendingRequest;
_pendingRequest = null;
}
pendingRequest?.TrySetResult(granted);
return true;
}
private static bool HasLocationPermission(Activity activity)
{
return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted
|| ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted;
}
private static async Task<bool> WaitAsync(Task<bool> task, CancellationToken cancellationToken)
{
using var registration = cancellationToken.Register(() =>
{
lock (SyncRoot)
{
_pendingRequest?.TrySetCanceled(cancellationToken);
_pendingRequest = null;
}
});
return await task.ConfigureAwait(false);
}
}
}

View file

@ -7,6 +7,20 @@ namespace PostIt.Tests;
public class ActivitiesPageViewModelTests
{
[Fact]
public void ActivityApiClient_uses_avatar_authority_when_provided()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(
api,
"https://api.pschneider.fr/api/v1/",
"https://yavsc.pschneider.fr/");
var url = client.BuildAvatarXsUrl("paul");
Assert.Equal("https://yavsc.pschneider.fr/avatars/paul.xs.png", url);
}
[Fact]
public async Task ActivityApiClient_uses_business_absolute_paths()
{
@ -40,6 +54,7 @@ public class ActivitiesPageViewModelTests
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Alice.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.True(vm.Performers[0].IsPerformerActive);
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
@ -50,6 +65,7 @@ public class ActivitiesPageViewModelTests
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Bob", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Bob.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.False(vm.Performers[0].IsPerformerActive);
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
@ -72,9 +88,9 @@ public class ActivitiesPageViewModelTests
{
Paths.Add(path);
if (typeof(T) == typeof(List<ActivityBrowseItemDto>))
if (typeof(T) == typeof(List<ActivityInfo>))
{
var activities = new List<ActivityBrowseItemDto>
var activities = new List<ActivityInfo>
{
new()
{
@ -82,11 +98,11 @@ public class ActivitiesPageViewModelTests
Name = "Brush",
Description = "Coiffure à domicile",
PerformerCount = 1,
Forms = new List<CommandFormSummaryDto>
Forms = new List<CommandFormSummary>
{
new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }
},
Children = new List<ActivityBrowseItemDto>
Children = new List<ActivityInfo>
{
new()
{
@ -95,7 +111,7 @@ public class ActivitiesPageViewModelTests
Description = "Spécialisation premium",
ParentCode = "brush",
PerformerCount = 1,
Forms = new List<CommandFormSummaryDto>
Forms = new List<CommandFormSummary>
{
new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" }
}
@ -106,14 +122,14 @@ public class ActivitiesPageViewModelTests
return Task.FromResult((T)(object)activities);
}
if (typeof(T) == typeof(List<ActivityPerformerDto>))
if (typeof(T) == typeof(List<PerformerActivity>))
{
var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal)
? new List<ActivityPerformerDto>
? new List<PerformerActivity>
{
new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 }
}
: new List<ActivityPerformerDto>
: new List<PerformerActivity>
{
new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 }
};

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
ApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
@ -266,7 +266,7 @@ public class BearerScopeTests
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -1,6 +1,8 @@
using System.Net.Http;
using System.Text.Json;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
@ -15,19 +17,19 @@ public class BillingCommandPageViewModelTests
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "dev", Name = "Développement" },
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" },
client)
{
EventDateText = "2026-09-02 14:30",
Reason = "Point de cadrage",
Address = "1 rue du Test",
LatitudeText = "48.8566",
LongitudeText = "2.3522",
Consent = true,
};
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
@ -46,16 +48,71 @@ public class BillingCommandPageViewModelTests
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "book", Name = "Book" },
var vm =
new CommandFormSummary { Id = 13, ActionName = "Book", Title = "Réservation" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "book", Name = "Book" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
new CommandFormSummaryDto { Id = 13, ActionName = "Book", Title = "Réservation" },
client);
Assert.Null(vm);
}
[Fact]
public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = null;
vm!.Longitude = null;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Null(api.LastPath);
Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
var location = json.RootElement.GetProperty("Location");
Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString());
Assert.False(location.TryGetProperty("Latitude", out _));
Assert.False(location.TryGetProperty("Longitude", out _));
}
[Fact]
public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider()
{
var original = Platform.TryGetCurrentLocationAsync;
try
{
Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522));
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
await vm!.UseCurrentLocationCommand.ExecuteAsync(null);
Assert.Equal(48.8566, vm!.Latitude);
Assert.Equal(2.3522, vm!.Longitude);
}
finally
{
Platform.TryGetCurrentLocationAsync = original;
}
}
[Fact]
@ -70,19 +127,18 @@ public class BillingCommandPageViewModelTests
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "brush", Name = "Brush" },
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" },
client)
{
EventDateText = "2026-09-02 14:30",
Address = "1 rue du Test",
LatitudeText = "48.8566",
LongitudeText = "2.3522",
Consent = true,
AdditionalInfo = "Prévoir shampoing",
};
client) as BrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
vm!.AdditionalInfo = "Prévoir shampoing";
await vm.InitializeAsync();
vm.SelectedPrestation = vm.AvailablePrestations[1];
@ -106,23 +162,22 @@ public class BillingCommandPageViewModelTests
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "mbrush", Name = "MBrush" },
var vm =
new CommandFormSummary { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "mbrush", Name = "MBrush" },
new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" },
new CommandFormSummaryDto { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" },
client)
{
EventDateText = "2026-09-03 10:00",
Address = "2 rue du Test",
LatitudeText = "48.8567",
LongitudeText = "2.3523",
Consent = true,
};
client) as MBrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-03 10:00");
vm!.Address = "2 rue du Test";
vm!.Latitude = 48.8567;
vm!.Longitude = 2.3523;
vm!.Consent = true;
await vm.InitializeAsync();
vm.MultiPrestations[0].IsSelected = true;
vm.MultiPrestations[1].IsSelected = true;
await vm.SubmitCommand.ExecuteAsync(null);
vm!.MultiPrestations[0].IsSelected = true;
vm!.MultiPrestations[1].IsSelected = true;
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
@ -144,13 +199,13 @@ public class BillingCommandPageViewModelTests
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingCommandPageViewModel(
new ActivityBrowseItemDto { Code = "brush", Name = "Brush" },
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" },
client);
await vm.InitializeAsync(new BillingQueryDetailsDto
client) as BrushViewModel;
await vm!.InitializeAsync(new BillingQueryDetailsDto
{
Id = 77,
BillingCode = "Brush",
@ -170,9 +225,9 @@ public class BillingCommandPageViewModelTests
}
});
vm.SelectedPrestation = vm.AvailablePrestations[1];
vm.AdditionalInfo = "Note mise à jour";
await vm.SubmitCommand.ExecuteAsync(null);
vm!.SelectedPrestation = vm!.AvailablePrestations[1];
vm!.AdditionalInfo = "Note mise à jour";
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.LastMethod);
Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath);
@ -216,4 +271,4 @@ public class BillingCommandPageViewModelTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
}

View file

@ -14,9 +14,9 @@ public class BillingQueriesPageViewModelTests
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityBrowseItemDto { Code = "dev", Name = "Développement" },
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client);
await vm.InitializeAsync();
@ -33,9 +33,9 @@ public class BillingQueriesPageViewModelTests
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityBrowseItemDto { Code = "dev", Name = "Développement" },
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client,
isReadOnly: true,
ongoingOnly: true);
@ -131,4 +131,4 @@ public class BillingQueriesPageViewModelTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
}

View file

@ -95,7 +95,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
@ -123,8 +123,8 @@ public class PostAclDialogTests
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
var circleClient = new CircleApiClient(api, settings.ApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);

View file

@ -89,7 +89,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip
settings.ApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";

View file

@ -58,7 +58,7 @@ public class YavscApiClientTests
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -118,7 +118,7 @@ public class YavscApiClientTests
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
},
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
ApiUrl = "https://127.0.0.1:5003/api/v1",
};
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -162,7 +162,7 @@ public class YavscApiClientTests
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
},
BusinessApiUrl = apiBaseUrl
ApiUrl = apiBaseUrl
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
@ -175,7 +175,7 @@ public class YavscApiClientTests
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.

View file

@ -135,9 +135,6 @@ private void ConfigureRootView(MainView rootView)
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true);
}
/// <summary>
@ -148,7 +145,7 @@ private void ConfigureRootView(MainView rootView)
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static async Task PushMainPageAsync()
public static async Task PushBlogsPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();

View file

@ -0,0 +1,50 @@
using System;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class FormHelpers
{
public static BillingCommandPageViewModel?
CreateCommandPageViewModel(
this CommandFormSummary form,
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
string namespacePrefix = typeof(PostIt.ViewModels.Commands.RdvViewModel).Namespace + ".";
string formVMName = form.ActionName + "ViewModel";
string formOnActivityVMName = activity.Code + formVMName + "ViewModel";
var vmType = Type.GetType(namespacePrefix +formOnActivityVMName);
if (vmType == null)
{
vmType = Type.GetType(namespacePrefix + formVMName);
}
if (vmType == null)
{
Console.Error.WriteLine(
$"! Cannot find type '{formOnActivityVMName}' or '{formVMName}'");
return null;
}
if (!typeof(BillingCommandPageViewModel).IsAssignableFrom(vmType))
{
Console.Error.WriteLine($"! The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
return null;
}
var vm = Activator.CreateInstance(vmType, activity, performer, form, billingClient);
if (vm == null)
{
throw new InvalidOperationException($"Cannot create instance of '{formOnActivityVMName}' or '{formVMName}'");
}
return vm as BillingCommandPageViewModel ?? throw new InvalidOperationException($"The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace PostIt.Helpers;
public static class ImageHelper
{
private static readonly HttpClient HttpClient = new();
public static Bitmap LoadFromResource(Uri resourceUri)
{
return new Bitmap(AssetLoader.Open(resourceUri));
}
public static async Task<Bitmap?> LoadFromWeb(Uri url)
{
try
{
var response = await HttpClient.GetAsync(url).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return new Bitmap(new MemoryStream(data));
}
catch (HttpRequestException ex)
{
Console.WriteLine($"An error occurred while downloading image '{url}': {ex.Message}");
return null;
}
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Commands;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
@ -23,8 +24,11 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
var activityClient = new ActivityApiClient(
api,
settings.ApiUrl,
settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues
@ -49,7 +53,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<BillingCommandPage>();
services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
// ViewModels
services.AddSingleton(settings);

View file

@ -0,0 +1,28 @@
namespace PostIt.Services;
public sealed class CurrentLocationResult
{
private CurrentLocationResult(bool isSuccess, bool isPermissionDenied, double? latitude, double? longitude, string message)
{
IsSuccess = isSuccess;
IsPermissionDenied = isPermissionDenied;
Latitude = latitude;
Longitude = longitude;
Message = message;
}
public bool IsSuccess { get; }
public bool IsPermissionDenied { get; }
public double? Latitude { get; }
public double? Longitude { get; }
public string Message { get; }
public static CurrentLocationResult Success(double latitude, double longitude, string? message = null)
=> new(true, false, latitude, longitude, message ?? "Position récupérée.");
public static CurrentLocationResult PermissionDenied(string? message = null)
=> new(false, true, null, null, message ?? "La géolocalisation n'est pas autorisée.");
public static CurrentLocationResult Unavailable(string? message = null)
=> new(false, false, null, null, message ?? "La géolocalisation n'est pas disponible sur cette plateforme.");
}

View file

@ -1,4 +1,7 @@
using System;
using IdentityModel.OidcClient.Browser;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
@ -37,4 +40,12 @@ public static class Platform
/// </summary>
public static System.Func<IBrowser?>? CreateBrowser { get; set; } =
() => new CustomSchemeBrowser(CustomScheme);
/// <summary>
/// Optional platform hook used by the shared billing form to request a
/// current device position. Platforms that do not expose a native
/// location provider can leave the default delegate in place.
/// </summary>
public static Func<CancellationToken, Task<CurrentLocationResult>> TryGetCurrentLocationAsync { get; set; } =
_ => Task.FromResult(CurrentLocationResult.Unavailable());
}

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@ -351,6 +352,77 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_store.Save(_tokens);
}
/// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image
/// content type before persisting it.
/// </summary>
public async Task<string> SetAvatarAsync(
Stream imageStream,
string fileName,
string? contentType = null,
CancellationToken ct = default)
{
if (imageStream is null)
throw new ArgumentNullException(nameof(imageStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("A file name is required.", nameof(fileName));
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var attemptUpload = async () =>
{
if (imageStream.CanSeek)
imageStream.Position = 0;
using var content = new MultipartFormDataContent();
using var fileContent = new StreamContent(imageStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content,
};
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return "Avatar mis à jour.";
try
{
using var json = JsonDocument.Parse(payload);
if (json.RootElement.TryGetProperty("message", out var msgEl))
{
var message = msgEl.GetString();
if (!string.IsNullOrWhiteSpace(message))
return message;
}
}
catch (JsonException)
{
// Keep a user-friendly fallback when the API payload is not JSON.
}
return "Avatar mis à jour.";
}
public async Task LogoutAsync()
{
_store.Clear();

View file

@ -4,7 +4,9 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views;
using PostIt.Views.Commands;
namespace PostIt;
@ -41,8 +43,8 @@ public class ViewLocator : IDataTemplate
HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
BillingCommandPageViewModel => services.GetRequiredService<BillingCommandPage>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BrushViewModel => services.GetRequiredService<BrushPage>(),
RdvViewModel => services.GetRequiredService<RdvPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),

View file

@ -20,16 +20,16 @@ public partial class ActivitiesPageViewModel : ViewModelBase
private bool _syncingSelection;
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Activities { get; set; } = new();
public partial ObservableCollection<ActivityInfo> Activities { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedActivity { get; set; }
public partial ActivityInfo? SelectedActivity { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Specializations { get; set; } = new();
public partial ObservableCollection<ActivityInfo> Specializations { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; }
public partial ActivityInfo? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
@ -43,7 +43,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public ActivityInfo? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0;
@ -66,19 +66,19 @@ public partial class ActivitiesPageViewModel : ViewModelBase
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
}
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
partial void OnSelectedActivityChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowActivitySafeAsync(value);
}
partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value)
partial void OnSelectedSpecializationChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowSpecializationSafeAsync(value);
}
private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value)
private async Task ShowActivitySafeAsync(ActivityInfo? value)
{
try
{
@ -94,7 +94,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
}
}
private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value)
private async Task ShowSpecializationSafeAsync(ActivityInfo? value)
{
try
{
@ -117,7 +117,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
try
{
var list = await _client.GetCatalogAsync();
Activities = new ObservableCollection<ActivityBrowseItemDto>(list ?? new());
Activities = new ObservableCollection<ActivityInfo>(list ?? new());
var first = Activities.FirstOrDefault();
await ShowActivityAsync(first);
@ -128,15 +128,15 @@ public partial class ActivitiesPageViewModel : ViewModelBase
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
@ -146,7 +146,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
}
}
public async Task ShowActivityAsync(ActivityBrowseItemDto? activity)
public async Task ShowActivityAsync(ActivityInfo? activity)
{
_syncingSelection = true;
try
@ -163,7 +163,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
OnPropertyChanged(nameof(SelectedActivityLabel));
OnPropertyChanged(nameof(CurrentActivityLabel));
OnPropertyChanged(nameof(CurrentFormCount));
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
Specializations = new ObservableCollection<ActivityInfo>(activity?.Children ?? new());
if (activity is null)
{
@ -175,7 +175,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
await LoadPerformersAsync(activity);
}
public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization)
public async Task ShowSpecializationAsync(ActivityInfo? specialization)
{
_syncingSelection = true;
try
@ -203,14 +203,32 @@ public partial class ActivitiesPageViewModel : ViewModelBase
await LoadPerformersAsync(specialization);
}
private async Task LoadPerformersAsync(ActivityBrowseItemDto activity)
private async Task LoadPerformersAsync(ActivityInfo activity)
{
IsBusy = true;
try
{
var list = await _client.GetUsersAsync(activity.Code);
Performers = new ObservableCollection<ActivityUserDisplayItem>((list ?? new())
.Select(ActivityUserDisplayItem.FromDto));
var items = (list ?? new())
.Select(dto => ActivityUserDisplayItem.FromDto(dto, _client.BuildAvatarXsUrl(dto.UserName)))
.ToList();
await Task.WhenAll(items.Select(async item =>
{
if (string.IsNullOrWhiteSpace(item.AvatarXsUrl))
{
return;
}
if (!Uri.TryCreate(item.AvatarXsUrl, UriKind.Absolute, out var avatarUri))
{
return;
}
item.AvatarImage = await ImageHelper.LoadFromWeb(avatarUri);
}));
Performers = new ObservableCollection<ActivityUserDisplayItem>(items);
SelectedPerformer = null;
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
}

View file

@ -1,10 +1,13 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public sealed class ActivityUserDisplayItem
public sealed partial class ActivityUserDisplayItem : ObservableObject
{
public string PerformerId { get; init; } = string.Empty;
public string AvatarXsUrl { get; init; } = string.Empty;
public bool HasPerformerProfile { get; init; }
public string PerformerBadgeLabel { get; init; } = "Profil pro";
public bool IsPerformerActive { get; init; }
@ -13,17 +16,25 @@ public sealed class ActivityUserDisplayItem
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
public string UserName { get; init; } = string.Empty;
public string AvatarFallbackLabel { get; init; } = "?";
public string WebSite { get; init; } = string.Empty;
public int ExtraActivityCount { get; init; }
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
public static ActivityUserDisplayItem FromDto(ActivityPerformerDto dto)
[ObservableProperty]
public partial Bitmap? AvatarImage { get; set; }
public static ActivityUserDisplayItem FromDto(PerformerActivity dto, string avatarXsUrl)
{
return new ActivityUserDisplayItem
{
PerformerId = dto.PerformerId,
AvatarXsUrl = avatarXsUrl,
HasPerformerProfile = dto.HasPerformerProfile,
UserName = dto.UserName,
AvatarFallbackLabel = string.IsNullOrWhiteSpace(dto.UserName)
? "?"
: dto.UserName.Trim()[0].ToString().ToUpperInvariant(),
WebSite = dto.WebSite,
IsPerformerActive = dto.Active,
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",

View file

@ -1,399 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Relationship;
namespace PostIt.ViewModels;
public partial class BillingCommandPageViewModel : ViewModelBase
{
private readonly BillingApiClient _billingClient;
public ActivityBrowseItemDto Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummaryDto Form { get; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial string EventDateText { get; set; }
[ObservableProperty]
public partial string Reason { get; set; } = string.Empty;
[ObservableProperty]
public partial string Address { get; set; } = string.Empty;
[ObservableProperty]
public partial string LatitudeText { get; set; } = string.Empty;
[ObservableProperty]
public partial string LongitudeText { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Consent { get; set; } = true;
[ObservableProperty]
public partial ObservableCollection<HairPrestationDto> AvailablePrestations { get; set; } = new();
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
[ObservableProperty]
public partial ObservableCollection<SelectableHairPrestationItem> MultiPrestations { get; set; } = new();
[ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty;
[ObservableProperty]
public partial long? ExistingQueryId { get; set; }
[ObservableProperty]
public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted;
public string Title => Form.Title;
public string PerformerLabel => Performer.UserName;
public string ActivityLabel => Activity.Name;
public bool IsSupported => IsRdv || IsBrush || IsMultiBrush;
public bool IsRdv => string.Equals(Form.ActionName, BillingCodes.Rdv, StringComparison.Ordinal);
public bool IsBrush => string.Equals(Form.ActionName, BillingCodes.Brush, StringComparison.Ordinal);
public bool IsMultiBrush => string.Equals(Form.ActionName, BillingCodes.MBrush, StringComparison.Ordinal);
public bool ShowsReason => IsRdv;
public bool ShowsAdditionalInfo => IsBrush;
public bool ShowsSinglePrestation => IsBrush;
public bool ShowsMultiplePrestations => IsMultiBrush;
public string BillingRoute => $"/billing/{Form.ActionName}";
public bool IsEditingExisting => ExistingQueryId.HasValue;
public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande";
public string SupportMessage => IsSupported
? IsRdv
? "Complétez les informations du rendez-vous puis postez la commande."
: IsBrush
? "Choisissez une prestation coiffure puis postez la commande."
: "Choisissez une ou plusieurs prestations coiffure puis postez la commande."
: $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt.";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingCommandPageViewModel(
ActivityBrowseItemDto activity,
ActivityUserDisplayItem performer,
CommandFormSummaryDto form,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
EventDateText = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
StatusMessage = SupportMessage;
}
partial void OnExistingQueryIdChanged(long? value)
{
OnPropertyChanged(nameof(IsEditingExisting));
OnPropertyChanged(nameof(SubmitLabel));
}
public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null)
{
if (!IsBrush && !IsMultiBrush)
{
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
}
return;
}
IsBusy = true;
try
{
var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName).ConfigureAwait(true);
AvailablePrestations = new ObservableCollection<HairPrestationDto>(prestations ?? new List<HairPrestationDto>());
SelectedPrestation = AvailablePrestations.FirstOrDefault();
MultiPrestations = new ObservableCollection<SelectableHairPrestationItem>(AvailablePrestations.Select(SelectableHairPrestationItem.FromDto));
StatusMessage = AvailablePrestations.Count == 0
? "Aucune prestation coiffure disponible."
: SupportMessage;
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}";
}
finally
{
IsBusy = false;
}
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
}
}
[RelayCommand]
private async Task SubmitAsync()
{
if (!IsSupported)
{
StatusMessage = SupportMessage;
return;
}
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (!TryParseEventDate(out var eventDate))
{
StatusMessage = "La date doit être saisie au format yyyy-MM-dd HH:mm.";
return;
}
if (IsRdv && string.IsNullOrWhiteSpace(Reason))
{
StatusMessage = "Le motif du rendez-vous est requis.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (!TryParseCoordinate(LatitudeText, out var latitude))
{
StatusMessage = "Latitude invalide.";
return;
}
if (!TryParseCoordinate(LongitudeText, out var longitude))
{
StatusMessage = "Longitude invalide.";
return;
}
IsBusy = true;
try
{
var location = new Location
{
Address = Address.Trim(),
Latitude = latitude,
Longitude = longitude,
};
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = eventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = location.Address,
Latitude = location.Latitude,
Longitude = location.Longitude,
}
};
if (IsRdv)
{
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = location,
Reason = payload.Reason,
Status = payload.Status,
}).ConfigureAwait(true);
}
}
else if (IsBrush)
{
if (SelectedPrestation is null)
{
StatusMessage = "Sélectionnez une prestation coiffure.";
return;
}
payload.PrestationId = SelectedPrestation.Id;
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = (DateTime?)eventDate,
Location = location,
PrestationId = SelectedPrestation.Id,
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(),
Status = payload.Status,
}).ConfigureAwait(true);
}
}
else if (IsMultiBrush)
{
var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList();
if (selectedPrestations.Count == 0)
{
StatusMessage = "Sélectionnez au moins une prestation coiffure.";
return;
}
payload.PrestationIds = selectedPrestations.Select(x => x.Id).ToList();
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = eventDate,
Location = location,
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(),
Status = payload.Status,
}).ConfigureAwait(true);
}
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
Reason = existingQuery.Reason ?? string.Empty;
AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty;
if (existingQuery.EventDate is not null)
{
EventDateText = existingQuery.EventDate.Value
.ToLocalTime()
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
if (existingQuery.Location is not null)
{
Address = existingQuery.Location.Address ?? string.Empty;
LatitudeText = existingQuery.Location.Latitude.ToString(CultureInfo.InvariantCulture);
LongitudeText = existingQuery.Location.Longitude.ToString(CultureInfo.InvariantCulture);
}
if (IsBrush && existingQuery.PrestationId is not null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value);
}
if (IsMultiBrush)
{
var selectedIds = existingQuery.PrestationIds is null
? new HashSet<long>()
: new HashSet<long>(existingQuery.PrestationIds);
foreach (var item in MultiPrestations)
{
item.IsSelected = selectedIds.Contains(item.Id);
}
}
StatusMessage = $"Commande #{existingQuery.Id} chargée.";
}
private bool TryParseEventDate(out DateTime eventDate)
{
return DateTime.TryParse(
EventDateText,
CultureInfo.CurrentCulture,
DateTimeStyles.AssumeLocal,
out eventDate)
|| DateTime.TryParse(
EventDateText,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal,
out eventDate);
}
private static bool TryParseCoordinate(string text, out double value)
{
return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, out value)
|| double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value);
}
}

View file

@ -18,9 +18,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
{
private readonly BillingApiClient _billingClient;
public ActivityBrowseItemDto Activity { get; }
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummaryDto Form { get; }
public CommandFormSummary Form { get; }
public bool IsReadOnly { get; }
public bool OngoingOnly { get; }
@ -55,9 +55,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
}
public BillingQueriesPageViewModel(
ActivityBrowseItemDto activity,
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummaryDto form,
CommandFormSummary form,
BillingApiClient billingClient,
bool isReadOnly = false,
bool ongoingOnly = false)
@ -133,8 +133,8 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var vm = new BillingCommandPageViewModel(Activity, Performer, Form, _billingClient);
await vm.InitializeAsync(details).ConfigureAwait(true);
var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient);
await vm!.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
@ -170,4 +170,4 @@ public partial class BillingQueriesPageViewModel : ViewModelBase
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
}
}

View file

@ -15,14 +15,14 @@ public partial class CommandFormsPageViewModel : ViewModelBase
{
private readonly BillingApiClient _billingClient;
public ActivityBrowseItemDto Activity { get; }
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
[ObservableProperty]
public partial ObservableCollection<CommandFormSummaryDto> Forms { get; set; }
public partial ObservableCollection<CommandFormSummary> Forms { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))]
public partial CommandFormSummaryDto? SelectedForm { get; set; }
public partial CommandFormSummary? SelectedForm { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
@ -43,7 +43,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase
}
public CommandFormsPageViewModel(
ActivityBrowseItemDto activity,
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
@ -51,7 +51,7 @@ public partial class CommandFormsPageViewModel : ViewModelBase
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
Forms = new ObservableCollection<CommandFormSummaryDto>((activity.Forms ?? new())
Forms = new ObservableCollection<CommandFormSummary>((activity.Forms ?? new())
.OrderBy(f => f.Title)
.ThenBy(f => f.ActionName));
SelectedForm = Forms.FirstOrDefault();
@ -81,8 +81,9 @@ public partial class CommandFormsPageViewModel : ViewModelBase
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingCommandPageViewModel(Activity, Performer, SelectedForm, _billingClient);
await vm.InitializeAsync();
var vm = SelectedForm.CreateCommandPageViewModel(
Activity, Performer, _billingClient);
await vm!.InitializeAsync();
await app.PushPageAsync(vm);
}
@ -131,4 +132,4 @@ public partial class CommandFormsPageViewModel : ViewModelBase
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
}
}

View file

@ -0,0 +1,108 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
public abstract partial class BillingCommandPageViewModel : RemoteViewModelBase
{
protected readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial string Reason { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Consent { get; set; } = true;
[ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty;
[ObservableProperty]
public partial long? ExistingQueryId { get; set; }
[ObservableProperty]
public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted;
public bool CanUseCurrentLocation => IsSupported && !IsBusy;
public string Title => Form.Title;
public string PerformerLabel => Performer.UserName;
public string ActivityLabel => Activity.Name;
public virtual bool IsSupported => true;
public string BillingRoute => $"/billing/{Form.ActionName}";
public bool IsEditingExisting => ExistingQueryId.HasValue;
public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande";
public virtual string SupportMessage => $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt.";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingCommandPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
StatusMessage = SupportMessage;
}
partial void OnExistingQueryIdChanged(long? value)
{
OnPropertyChanged(nameof(IsEditingExisting));
OnPropertyChanged(nameof(SubmitLabel));
}
partial void OnIsBusyChanged(bool value)
{
OnPropertyChanged(nameof(CanUseCurrentLocation));
}
public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null)
{
await LoadAsync();
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
return;
}
}
protected abstract void ApplyExistingQuery(BillingQueryDetailsDto existingQuery);
[RelayCommand]
protected abstract Task SubmitAsync();
}

View file

@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels.Commands;
public partial class BrushViewModel : RdvViewModel
{
public override string SupportMessage => "Choisissez une prestation coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<HairPrestationDto> AvailablePrestations { get; set; } = new();
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName);
AvailablePrestations = new ObservableCollection<HairPrestationDto>
(prestations ?? new List<HairPrestationDto>());
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
if (existingQuery.PrestationId is not null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value);
}
IsBusy = true;
try
{
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
StatusMessage = AvailablePrestations.Count == 0
? "Aucune prestation coiffure disponible."
: SupportMessage;
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (SelectedPrestation is null)
{
StatusMessage = "Sélectionnez une prestation coiffure.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
payload.PrestationId = SelectedPrestation.Id;
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = (DateTime?)EventDate,
Location = locationPayload,
PrestationId = SelectedPrestation.Id,
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(),
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels.Commands;
public partial class MBrushViewModel : BrushViewModel
{
public override string SupportMessage => "Choisissez une ou plusieurs prestations coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<SelectableHairPrestationItem> MultiPrestations { get; set; } = new();
public MBrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
await base.LoadAsync().ConfigureAwait(true);
MultiPrestations = new ObservableCollection<SelectableHairPrestationItem>(
AvailablePrestations.Select(SelectableHairPrestationItem.FromDto));
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
var selectedIds = existingQuery.PrestationIds is null
? new HashSet<long>()
: new HashSet<long>(existingQuery.PrestationIds);
foreach (var item in MultiPrestations)
{
item.IsSelected = selectedIds.Contains(item.Id);
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList();
if (selectedPrestations.Count == 0)
{
StatusMessage = "Sélectionnez au moins une prestation coiffure.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
},
PrestationIds = selectedPrestations.Select(x => x.Id).ToList(),
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(),
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,211 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels.Commands;
public partial class RdvViewModel : BillingCommandPageViewModel
{
public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande.";
[ObservableProperty]
public partial string Address { get; set; } = string.Empty;
[ObservableProperty]
public partial double? Latitude { get; set; }
[ObservableProperty]
public partial double? Longitude { get; set; }
[ObservableProperty]
public partial DateTime EventDate { get; set; }
public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
EventDate = DateTime.Now.AddDays(1);
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
Reason = existingQuery.Reason ?? string.Empty;
AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty;
if (existingQuery.EventDate is not null)
{
EventDate = existingQuery.EventDate.Value
.ToLocalTime();
}
if (existingQuery.Location is not null)
{
Address = existingQuery.Location.Address ?? string.Empty;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
StatusMessage = $"Commande #{existingQuery.Id} chargée.";
}
[RelayCommand(CanExecute = nameof(CanUseCurrentLocation))]
private async Task UseCurrentLocationAsync()
{
if (!CanUseCurrentLocation)
{
return;
}
IsBusy = true;
try
{
var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true);
if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue)
{
StatusMessage = result.Message;
return;
}
Latitude = result.Latitude.Value;
Longitude = result.Longitude.Value;
StatusMessage = string.IsNullOrWhiteSpace(Address)
? "Position récupérée. Complétez l'adresse puis envoyez la commande."
: result.Message;
}
catch (OperationCanceledException)
{
StatusMessage = "La récupération de la position a été annulée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de récupérer la position: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
protected static object BuildLocationPayload(string address, double? latitude, double? longitude)
{
if (latitude.HasValue && longitude.HasValue)
{
return new
{
Address = address,
Latitude = latitude.Value,
Longitude = longitude.Value,
};
}
return new
{
Address = address,
};
}
protected override async Task SubmitAsync()
{
if (!IsSupported)
{
StatusMessage = SupportMessage;
return;
}
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (string.IsNullOrWhiteSpace(Reason))
{
StatusMessage = "Le motif du rendez-vous est requis.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Reason = payload.Reason,
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public override Task LoadAsync()
{
return Task.CompletedTask;
}
}

View file

@ -32,7 +32,7 @@ public class HomePageViewModel : ViewModelBase
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
}
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync);
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync);
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
namespace PostIt.ViewModels;
public abstract class RemoteViewModelBase : ViewModelBase
{
public abstract Task LoadAsync();
}

View file

@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary>
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
? settings.BusinessApiUrl
: this.BusinessApiUrl;
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null))
{
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
};
this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
}

View file

@ -1,8 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public abstract partial class ViewModelBase : ObservableObject
public abstract class ViewModelBase : ObservableObject
{
/// <summary>
/// Gets if the user can navigate to the next page

View file

@ -21,8 +21,8 @@
</Grid.Styles>
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
<Button Content="🗘 Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="📑 Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
@ -32,7 +32,7 @@
ItemsSource="{Binding Activities}"
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
@ -53,7 +53,7 @@
ItemsSource="{Binding Specializations}"
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
@ -76,7 +76,24 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
<StackPanel Spacing="2" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Border Width="24"
Height="24"
CornerRadius="12"
ClipToBounds="True"
Background="#E9ECEF">
<Grid>
<TextBlock Text="{Binding AvatarFallbackLabel}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="#495057" />
<Image Source="{Binding AvatarImage}"
Width="24"
Height="24"
Stretch="UniformToFill" />
</Grid>
</Border>
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"

View file

@ -1,131 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.BillingCommandPage"
x:DataType="vm:BillingCommandPageViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding EventDateText, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsReason}" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsReason}" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="7" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="7" Grid.Column="1" Text="{Binding LatitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="8" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding LongitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="9"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsSinglePrestation}" />
<ComboBox Grid.Row="9"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsSinglePrestation}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="10"
Text="Prestations"
VerticalAlignment="Top"
Margin="0,0,12,8"
IsVisible="{Binding ShowsMultiplePrestations}" />
<ListBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding MultiPrestations}"
IsVisible="{Binding ShowsMultiplePrestations}"
MaxHeight="180"
Margin="0,0,0,8">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="11"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<TextBox Grid.Row="11"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<CheckBox Grid.Row="12" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="13" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -20,7 +20,7 @@
ItemsSource="{Binding Forms}"
SelectedItem="{Binding SelectedForm, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:CommandFormSummaryDto">
<DataTemplate x:DataType="wf:CommandFormSummary">
<StackPanel Spacing="2" Margin="0,0,0,10">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding ActionName, StringFormat='Route billing : /billing/{0}'}"
@ -53,4 +53,4 @@
VerticalAlignment="Center" />
</Grid>
</Grid>
</ContentPage>
</ContentPage>

View file

@ -0,0 +1,113 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:cvm="using:PostIt.ViewModels.Commands"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.Commands.BrushPage"
x:DataType="cvm:BrushViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="10"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<ComboBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views.Commands;
public partial class BrushPage : ContentPage
{
public BrushPage()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,91 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cvm="using:PostIt.ViewModels.Commands"
x:Class="PostIt.Views.Commands.RdvPage"
x:DataType="cvm:RdvViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -1,11 +1,11 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
namespace PostIt.Views.Commands;
public partial class BillingCommandPage : ContentPage
public partial class RdvPage : ContentPage
{
public BillingCommandPage()
public RdvPage()
{
InitializeComponent();
}
@ -14,4 +14,4 @@ public partial class BillingCommandPage : ContentPage
{
AvaloniaXamlLoader.Load(this);
}
}
}

View file

@ -24,13 +24,13 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Spacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding RefreshAsync}" Content="Refresh" />
<Button Command="{Binding SearchAsync}" Content="Filter" />
<Border Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Padding="0">
<WrapPanel Orientation="Horizontal">
<Button Command="{Binding RefreshAsync}" Content="🗘 Refresh" />
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" />
<Button x:Name="ManageAclButton"
@ -62,8 +62,8 @@
Command="{Binding OpenSignatureDevAsync}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>
</WrapPanel>
</Border>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"

View file

@ -39,25 +39,41 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBlock Grid.Row="8" Text="API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
Text="{Binding ApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver"
HorizontalAlignment="Right"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
<StackPanel Grid.Row="12" Spacing="8">
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
Opacity="0.8"
TextWrapping="Wrap" />
<TextBlock x:Name="AvatarUploadStatusText"
Text=""
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir l'avatar"
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>
</Grid>
</ContentPage>

View file

@ -1,12 +1,116 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.Views;
public partial class SettingsPage: ContentPage
{
private const int MaxAvatarSizeMegabytes = 2;
private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
private int _avatarStatusVersion;
public SettingsPage()
{
InitializeComponent();
}
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
ChooseAvatarButton.IsEnabled = false;
SetAvatarStatus("Sélection d'un fichier avatar...", Brushes.Gray);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
SetAvatarStatus("Impossible d'accéder à la fenêtre active.", Brushes.IndianRed);
ChooseAvatarButton.IsEnabled = true;
return;
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
}
}
});
var file = files.FirstOrDefault();
if (file is null)
{
SetAvatarStatus("Upload annulé.", Brushes.Gray);
ChooseAvatarButton.IsEnabled = true;
return;
}
try
{
SetAvatarStatus("Upload avatar en cours...", Brushes.Gray);
await using var stream = await file.OpenReadAsync();
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
SetAvatarStatus(string.IsNullOrWhiteSpace(message)
? "Avatar mis à jour."
: message, Brushes.ForestGreen);
_ = ClearSuccessStatusLaterAsync(statusVersion);
}
catch (Exception ex)
{
SetAvatarStatus($"Échec upload avatar: {ex.Message}", Brushes.IndianRed);
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
}
finally
{
ChooseAvatarButton.IsEnabled = true;
}
}
private async Task ClearSuccessStatusLaterAsync(int statusVersion)
{
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
if (statusVersion != _avatarStatusVersion)
return;
if (AvatarUploadStatusText.Foreground == Brushes.ForestGreen)
{
SetAvatarStatus($"Avatar prêt. Formats supportés: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", Brushes.Gray);
}
}
private void SetAvatarStatus(string message, IBrush color)
{
AvatarUploadStatusText.Foreground = color;
AvatarUploadStatusText.Text = message;
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "application/octet-stream"
};
}
}

View file

@ -3,7 +3,7 @@ namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Activity node returned by the browsing API.
/// </summary>
public sealed class ActivityBrowseItemDto
public sealed class ActivityInfo
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
@ -12,6 +12,6 @@ public sealed class ActivityBrowseItemDto
public string Photo { get; set; } = string.Empty;
public int Rate { get; set; }
public int PerformerCount { get; set; }
public List<CommandFormSummaryDto> Forms { get; set; } = new();
public List<ActivityBrowseItemDto> Children { get; set; } = new();
public List<CommandFormSummary> Forms { get; set; } = new();
public List<ActivityInfo> Children { get; set; } = new();
}

View file

@ -3,7 +3,7 @@ namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight command-form description exposed to API clients.
/// </summary>
public sealed class CommandFormSummaryDto
public sealed class CommandFormSummary
{
public long Id { get; set; }
public string ActionName { get; set; } = string.Empty;

View file

@ -3,7 +3,7 @@ namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight performer description returned for one activity.
/// </summary>
public sealed class ActivityPerformerDto
public sealed class PerformerActivity
{
public string PerformerId { get; set; } = string.Empty;
public bool HasPerformerProfile { get; set; }

View file

@ -20,17 +20,21 @@ public sealed class ActivityApiClient
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly Uri _avatarBaseAddress;
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress)
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
_avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress)
? _baseAddress
: new Uri(avatarBaseAddress, UriKind.Absolute);
}
public Task<List<ActivityBrowseItemDto>> GetCatalogAsync(
public Task<List<ActivityInfo>> GetCatalogAsync(
string? parentCode = null,
CancellationToken ct = default)
{
@ -38,26 +42,38 @@ public sealed class ActivityApiClient
? $"{PathPrefix}/catalog"
: $"{PathPrefix}/catalog?parentCode={Uri.EscapeDataString(parentCode)}";
return _api.CallAsync<List<ActivityBrowseItemDto>>(HttpMethod.Get, Absolute(path), ct: ct);
return _api.CallAsync<List<ActivityInfo>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<List<ActivityPerformerDto>> GetUsersAsync(
public Task<List<PerformerActivity>> GetUsersAsync(
string activityCode,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(activityCode))
throw new ArgumentException("Activity code is required.", nameof(activityCode));
return _api.CallAsync<List<ActivityPerformerDto>>(
return _api.CallAsync<List<PerformerActivity>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/users"),
ct: ct);
}
public Task<List<ActivityPerformerDto>> GetPerformersAsync(
public Task<List<PerformerActivity>> GetPerformersAsync(
string activityCode,
CancellationToken ct = default)
=> GetUsersAsync(activityCode, ct);
public string BuildAvatarXsUrl(string? userName)
{
var siteRoot = new Uri(_avatarBaseAddress, "/");
if (string.IsNullOrWhiteSpace(userName))
{
return new Uri(siteRoot, "images/Users/icon_user.xs.png").ToString();
}
return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString();
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
}

View file

@ -286,12 +286,22 @@ public sealed class BillingApiClient
return null;
}
return new
var payload = new Dictionary<string, object?>
{
Address = location.Address,
Latitude = location.Latitude,
Longitude = location.Longitude,
["Address"] = location.Address,
};
if (location.Latitude.HasValue)
{
payload["Latitude"] = location.Latitude.Value;
}
if (location.Longitude.HasValue)
{
payload["Longitude"] = location.Longitude.Value;
}
return payload;
}
private sealed class BillingLocationResponse
@ -351,4 +361,4 @@ public sealed class BillingApiClient
{
public long PrestationId { get; set; }
}
}
}

View file

@ -30,6 +30,6 @@ public sealed class BillingQueryDetailsDto
public sealed class BillingLocationDto
{
public string Address { get; set; } = string.Empty;
public double Latitude { get; set; }
public double Longitude { get; set; }
}
public double? Latitude { get; set; }
public double? Longitude { get; set; }
}

View file

@ -42,7 +42,7 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
@ -68,7 +68,7 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
@ -84,7 +84,7 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
var dev = catalog!.Single(a => a.Code == "dev");
@ -93,7 +93,7 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var performersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var performers = await performersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var performers = await performersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(performers);
Assert.Equal(dev.PerformerCount, performers!.Count);
Assert.Contains(performers, p => p.PerformerId == "alice");
@ -108,7 +108,7 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.DoesNotContain(catalog!, a => a.Code == "ghost");
Assert.Contains(catalog!, a => a.Code == "dev");
@ -123,13 +123,13 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityInfo>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.Contains(catalog!, a => a.Code == "declared-only");
var response = await http.GetAsync("/api/v1/activity/declared-only/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var payload = await response.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("bob", payload[0].PerformerId);
@ -149,8 +149,8 @@ public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixtu
Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var usersPayload = await usersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var performersPayload = await performersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var usersPayload = await usersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
var performersPayload = await performersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(TestContext.Current.CancellationToken);
Assert.NotNull(usersPayload);
Assert.NotNull(performersPayload);

View file

@ -29,7 +29,7 @@ namespace Yavsc.Controllers
}
[HttpGet("catalog")]
public async Task<ActionResult<IEnumerable<ActivityBrowseItemDto>>> GetCatalog(
public async Task<ActionResult<IEnumerable<ActivityInfo>>> GetCatalog(
CancellationToken cancellationToken,
[FromQuery] string parentCode = null)
{
@ -74,7 +74,7 @@ namespace Yavsc.Controllers
}
[HttpGet("{id}/users")]
public async Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetUsers(
public async Task<ActionResult<IEnumerable<PerformerActivity>>> GetUsers(
[FromRoute] string id,
CancellationToken cancellationToken)
{
@ -97,7 +97,7 @@ namespace Yavsc.Controllers
}
[HttpGet("{id}/performers")]
public Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetPerformers(
public Task<ActionResult<IEnumerable<PerformerActivity>>> GetPerformers(
[FromRoute] string id,
CancellationToken cancellationToken)
{
@ -105,7 +105,7 @@ namespace Yavsc.Controllers
return GetUsers(id, cancellationToken);
}
private Task<List<ActivityPerformerDto>> QueryDeclaredUsersAsync(
private Task<List<PerformerActivity>> QueryDeclaredUsersAsync(
string activityCode,
string activityName,
CancellationToken cancellationToken)
@ -118,7 +118,7 @@ namespace Yavsc.Controllers
from performer in performerProfiles.DefaultIfEmpty()
where ua.DoesCode == activityCode
orderby user != null ? user.UserName : ua.UserId
select new ActivityPerformerDto
select new PerformerActivity
{
PerformerId = ua.UserId,
HasPerformerProfile = performer != null,
@ -260,11 +260,11 @@ namespace Yavsc.Controllers
return _context.Activities.Count(e => e.Code == id) > 0;
}
private static ActivityBrowseItemDto ToBrowseItem(
private static ActivityInfo ToBrowseItem(
Activity activity,
IReadOnlyDictionary<string, int> performerCounts)
{
return new ActivityBrowseItemDto
return new ActivityInfo
{
Code = activity.Code,
Name = activity.Name,
@ -274,7 +274,7 @@ namespace Yavsc.Controllers
Rate = activity.Rate,
PerformerCount = performerCounts.TryGetValue(activity.Code, out var count) ? count : 0,
Forms = (activity.Forms ?? Enumerable.Empty<CommandForm>())
.Select(f => new CommandFormSummaryDto
.Select(f => new CommandFormSummary
{
Id = f.Id,
ActionName = f.ActionName,
@ -285,7 +285,7 @@ namespace Yavsc.Controllers
.Where(c => !c.Hidden)
.Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)
.OrderByDescending(c => c.Rate)
.Select(c => new ActivityBrowseItemDto
.Select(c => new ActivityInfo
{
Code = c.Code,
Name = c.Name,
@ -295,7 +295,7 @@ namespace Yavsc.Controllers
Rate = c.Rate,
PerformerCount = performerCounts.TryGetValue(c.Code, out var childCount) ? childCount : 0,
Forms = (c.Forms ?? Enumerable.Empty<CommandForm>())
.Select(f => new CommandFormSummaryDto
.Select(f => new CommandFormSummary
{
Id = f.Id,
ActionName = f.ActionName,

View file

@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Api.Helpers;
@ -10,10 +12,21 @@ using System.Diagnostics;
namespace Yavsc.WebApi.Controllers
{
[Route("~/api/account")]
[Route( Constants.APIPrefix + "/account")]
[Authorize("ApiScope")]
public class ApiAccountController : Controller
{
private const long MaxAvatarSizeBytes = 2 * 1024 * 1024;
private static readonly string[] AcceptedAvatarMimeTypes =
[
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
readonly ApplicationDbContext _dbContext;
private readonly ILogger _logger;
@ -61,23 +74,102 @@ namespace Yavsc.WebApi.Controllers
return Ok(new { host = Request.ForwardedFor() });
}
/// <summary>
/// Updates the avatar
/// </summary>
/// <returns></returns>
[HttpPost("~/api/set-avatar")]
[HttpPost("set-avatar")]
public async Task<IActionResult> SetAvatar()
{
var user = await GetUserData(User.GetUserId());
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
return new BadRequestResult();
if (!Request.HasFormContentType)
{
return BadRequest(new
{
status = "invalid_request",
message = "A multipart/form-data request is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var info = user.ReceiveAvatar(Request.Form.Files[0]);
await _dbContext.SaveChangesAsync();
return Ok(info);
if (Request.Form.Files.Count != 1)
{
return BadRequest(new
{
status = "invalid_file_count",
message = "Exactly one file is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var avatarFile = Request.Form.Files[0];
if (avatarFile.Length <= 0)
{
return BadRequest(new
{
status = "empty_file",
message = "The uploaded file is empty.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (avatarFile.Length > MaxAvatarSizeBytes)
{
return BadRequest(new
{
status = "file_too_large",
message = "Avatar is too large.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (!AcceptedAvatarMimeTypes.Any(m => string.Equals(m, avatarFile.ContentType, StringComparison.OrdinalIgnoreCase)))
{
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new
{
status = "unsupported_media_type",
message = "Unsupported image format.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
try
{
var info = user.ReceiveAvatar(avatarFile);
await _dbContext.SaveChangesAsync();
return Ok(new
{
status = "uploaded",
message = "Avatar uploaded successfully.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
avatar = info,
});
}
catch (MagickException ex)
{
_logger.LogWarning(ex, "Avatar upload failed: invalid image data for user {UserId}", user.Id);
return BadRequest(new
{
status = "invalid_image_data",
message = "Image content could not be decoded.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
}
[HttpGet("identity")]

View file

@ -20,6 +20,7 @@ using Yavsc.Helpers;
using Yavsc.Interface;
using Yavsc.Interfaces;
using Yavsc.Models;
using Yavsc;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@ -32,6 +33,7 @@ internal class Program
var builder = WebApplication.CreateBuilder(args);
builder.AddConfiguration("api");
Config.SiteSetup = builder.Configuration.GetSection("Site").Get<SiteSettings>() ?? new SiteSettings();
var services = builder.Services;

View file

@ -2,7 +2,8 @@
"Site": {
"Authority": "https://localhost:5001",
"CorsAllowedOrigins": [
"https://localhost:5003"
"https://localhost:5003",
"https://yavsc.pschneider.fr"
]
},
"Logging": {

View file

@ -0,0 +1,34 @@
namespace Yavsc.Org.Tests.NonRegression;
/// <summary>
/// Non-regression: avatar requests under /avatars must never return 404
/// for missing files. The pipeline falls back to static defaults under
/// /images/Users/icon_user*.png.
/// </summary>
public class AvatarFallbackTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public AvatarFallbackTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Theory]
[InlineData("/avatars/user-does-not-exist.png")]
[InlineData("/avatars/user-does-not-exist.s.png")]
[InlineData("/avatars/user-does-not-exist.xs.png")]
public async Task Missing_avatar_file_returns_default_image_instead_of_404(string path)
{
using var client = _factory.CreateClient();
var ct = TestContext.Current.CancellationToken;
var response = await client.GetAsync(path, ct);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType);
var payload = await response.Content.ReadAsByteArrayAsync(ct);
Assert.True(payload.Length > 0, $"Expected a non-empty fallback image for {path}.");
}
}

View file

@ -1,3 +1,6 @@
using IdentityServer8.Stores;
using Microsoft.Extensions.DependencyInjection;
namespace Yavsc.Org.Tests.Smoke;
/// <summary>
@ -30,4 +33,27 @@ public class AccountSmokeTests : SmokeTestBase, IClassFixture<TestWebApplication
using var client = _factory.CreateClient();
await AssertResponds(client, "/signin");
}
[Fact]
public async Task GetOpenIdConfiguration_returns_ok()
{
using var client = _factory.CreateClient();
var response = await GetRaw(client, "/.well-known/openid-configuration");
var payload = await response.Content.ReadAsStringAsync();
Assert.True(
response.IsSuccessStatusCode,
$"GET /.well-known/openid-configuration returned {(int)response.StatusCode} {response.StatusCode}. Body: {payload}");
}
[Fact]
public async Task ResourceStore_get_all_resources_does_not_throw()
{
using var scope = _factory.Services.CreateScope();
var resourceStore = scope.ServiceProvider.GetRequiredService<IResourceStore>();
var exception = await Record.ExceptionAsync(resourceStore.GetAllResourcesAsync);
Assert.Null(exception);
}
}

View file

@ -94,107 +94,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
}
public async Task<IActionResult> SignIn(SignInModel model, [FromForm] string button)
{
if (Request.Method == "POST") // "hGbkk9B94NAae#aG"
{
if (model.Provider == null || model.Provider == "LOCAL")
{
if (ModelState.IsValid)
{
var user = await _userManager.FindByNameAsync(model.UserName);
var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
if (user != null)
{
var signin = await _signInManager.CheckPasswordSignInAsync(user, model.Password, true);
// validate username/password against in-memory store
if (signin.Succeeded)
{
await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
// only set explicit expiration here if user chooses "remember me".
// otherwise we rely upon expiration configured in cookie middleware.
await HttpContext.SignInAsync(user, _roleManager, model.RememberMe, _dbContext);
var authResult = await HttpContext.AuthenticateAsync();
if (!authResult.Succeeded)
{
return this.Unauthorized();
}
String bearer = await HttpContext.GetTokenAsync("Bearer", "Bearer");
HttpContext.Response.Cookies.Append("Bearer", bearer);
if (context != null)
{
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage("Redirect", model.ReturnUrl);
}
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
return Redirect(model.ReturnUrl);
}
// request for a local page
if (Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
else if (string.IsNullOrEmpty(model.ReturnUrl))
{
return Redirect("~/");
}
else
{
// user might have clicked on a malicious link - should be logged
throw new Exception("invalid return URL");
}
}
}
await _events.RaiseAsync(new UserLoginFailureEvent(model.UserName, "invalid credentials", clientId: context?.Client.ClientId));
ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
}
}
else
{
// Note: the "provider" parameter corresponds to the external
// authentication provider choosen by the user agent.
if (string.IsNullOrEmpty(model.Provider))
{
_logger.LogWarning("Provider not specified");
return BadRequest();
}
// Instruct the middleware corresponding to the requested external identity
// provider to redirect the user agent to its own authorization endpoint.
// Note: the authenticationScheme parameter must match the value configured in Startup.cs
// Note: the "returnUrl" parameter corresponds to the endpoint the user agent
// will be redirected to after a successful authentication and not
// the redirect_uri of the requesting client application.
if (string.IsNullOrEmpty(model.ReturnUrl))
{
_logger.LogWarning("ReturnUrl not specified");
return BadRequest();
}
// Note: this still is not the redirect uri given to the third party provider, at building the challenge.
var redirectUrl = Url.Action("ExternalLoginCallback", "Account", new { model.ReturnUrl }, protocol: "https", host: Config.Authority);
var properties = _signInManager.ConfigureExternalAuthenticationProperties(model.Provider, redirectUrl);
// var properties = new AuthenticationProperties{RedirectUri=ReturnUrl};
return new ChallengeResult(model.Provider, properties);
}
}
return View(model);
}
/// <summary>
/// Entry point into the login workflow
/// </summary>

View file

@ -16,6 +16,7 @@ using Yavsc.Services;
using Yavsc.ViewModels.Manage;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Authorization;
using IdentityServer8;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
@ -524,8 +525,45 @@ namespace Yavsc.Controllers
}
[HttpGet]
public IActionResult SetAvatar()
public async Task<IActionResult> SetAvatar(
[FromServices] IdentityServerTools identityServerTools)
{
var currentUser = await GetCurrentUserAsync();
if (currentUser == null)
{
return Challenge();
}
var claims = new List<Claim>
{
new("sub", currentUser.Id),
new("name", currentUser.UserName ?? currentUser.Email ?? currentUser.Id),
new("scope", "api"),
new("aud", "api")
};
// Short-lived token limited to avatar upload from this page.
string avatarAccessToken = string.Empty;
try
{
avatarAccessToken = await identityServerTools.IssueClientJwtAsync(
"postit",
300,
new[] { "api" },
new[] { "api" },
claims);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "IssueClientJwtAsync failed for SetAvatar, trying direct IssueJwtAsync fallback.");
}
if (string.IsNullOrWhiteSpace(avatarAccessToken))
{
avatarAccessToken = await identityServerTools.IssueJwtAsync(300, claims);
}
ViewData["AvatarAccessToken"] = avatarAccessToken;
return View();
}

View file

@ -1,24 +0,0 @@
<Project>
<!-- Pull in shared package versions from the repository root. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- Yavsc.Org-specific versions -->
<ItemGroup>
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="8.1.0-alpha.171" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
</ItemGroup>
</Project>

View file

@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -1282,10 +1283,52 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
app.UseFileServer(Config.AvatarsOptions);
app.Use(async (context, next) =>
{
await next();
if (context.Response.StatusCode != StatusCodes.Status404NotFound
|| context.Response.HasStarted
|| !HttpMethods.IsGet(context.Request.Method)
|| !context.Request.Path.StartsWithSegments(Constants.AvatarsPath, out var avatarFile))
{
return;
}
var webHostEnvironment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
var fallbackAsset = ResolveAvatarFallbackAsset(avatarFile);
var fallbackFile = webHostEnvironment.WebRootFileProvider.GetFileInfo(fallbackAsset.TrimStart('/'));
if (!fallbackFile.Exists)
{
return;
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "image/png";
await context.Response.SendFileAsync(fallbackFile);
});
app.UseFileServer(Config.GitOptions);
app.UseStaticFiles();
return app;
}
private static string ResolveAvatarFallbackAsset(PathString avatarFile)
{
var fileName = avatarFile.Value ?? string.Empty;
if (fileName.EndsWith(".xs.png", StringComparison.OrdinalIgnoreCase))
{
return "/images/Users/icon_user.xs.png";
}
if (fileName.EndsWith(".s.png", StringComparison.OrdinalIgnoreCase))
{
return "/images/Users/icon_user.s.png";
}
return Constants.DefaultAvatar;
}
}

View file

@ -2,6 +2,9 @@
@using System.Security.Claims
@{
ViewBag.Title = "Manage your account";
var avatarSrc = string.IsNullOrWhiteSpace(Model.UserName)
? Yavsc.Constants.DefaultAvatar
: $"{Yavsc.Constants.AvatarsPath}/{Model.UserName}.s.png";
}
<h2>@ViewBag.Title</h2>
@ -11,15 +14,15 @@
<hr />
<dl class="dl-horizontal">
<dt>UserName:</dt>
<dd>
<dd>
@Model.UserName
<a asp-action="SetUserName">[modifier]</a>
</dd>
<dt>E-mail</dt>
<dd>
<dd>
@Model.EMail
@if (Model.EmailConfirmed) {
@ -46,22 +49,22 @@
<dt>FullName:</dt>
<dd>
@Html.DisplayFor(m=>m.FullName)
<dd>
@Html.DisplayFor(m=>m.FullName)
<a asp-action="SetFullName">[modifier]</a>
</dd>
@if (Model.Roles.Count()>0) {
<dt>Roles:</dt>
<dd>
<dd>
@string.Join(", ",Model.Roles)
</dd>}
<dt>Password:</dt>
<dd>[@{if (Model.HasPassword)
{<a asp-controller="Manage"
{<a asp-controller="Manage"
asp-action="ChangePassword">Change</a>}
else
{<a asp-controller="Manage"
{<a asp-controller="Manage"
asp-action="SetPassword">Create</a>}}]</dd>
<dt>External Logins:</dt>
@ -76,19 +79,19 @@
{
Html.DisplayText("Set");
}
else
else
{
Html.DisplayText("Modify");
}
}
</a>]
</dd>
<dt>Avatar:</dt>
<dd>
<img src="/Avatars/@(User.GetUserName()+".s").png" asp-append-version="true" class="smalltofhol" />
<img src="@avatarSrc" asp-append-version="true" class="smalltofhol" />
[<a asp-controller="Manage" asp-action="SetAvatar">Modify</a>]
</dd>
</dd>
<dt>Vos cercles</dt>
<dd> (WIP) <a asp-action="Index" asp-controller="Circle">Ajouter suprimer des cercles</a>
@ -115,8 +118,8 @@ Html.DisplayText("Modify");
<dt><a href="/Blog/@Model.UserName">Your posts:</a></dt>
<dd>@Model.PostsCounter</dd>
<dt>TwoFactorAuthentication:</dt>
<dt>TwoFactorAuthentication:</dt>
<dd>
@if (Model.TwoFactor)
{
@ -139,19 +142,19 @@ Html.DisplayText("Modify");
}
}
</dd>
<dt>Calendar</dt>
<dd>
<dt>Calendar</dt>
<dd>
@Html.DisplayText(Model.HasDedicatedCalendar?"Yes":"No" )
@{
if (Model.HasDedicatedCalendar) {
if (Model.HasDedicatedCalendar) {
<text> : @Model.DedicatedCalendarId </text>
}
}
[<a asp-action="SetGoogleCalendar">Select a Google calendar</a>]
</dd>
<environment names="Development">
<dt>Credits:</dt>
<dd>
<dt>Credits:</dt>
<dd>
@(Model.Balance?.Credits ?? 0) &euro;
[<a asp-action="Credits">Manage</a>]
</dd>
@ -162,9 +165,9 @@ Html.DisplayText("Modify");
@if (Model.DiskQuota>0)
{
<text>
@(((double)Model.DiskUsage/Model.DiskQuota).ToString("%#0")) :
@(((double)Model.DiskUsage/Model.DiskQuota).ToString("%#0")) :
</text>
}
}
<code>
@(Model.DiskUsage.ToString("0,#")) / @(Model.DiskQuota.ToString("0,#"))
</code>

View file

@ -1,29 +1,88 @@
@model PerformerProfile
@{ ViewBag.Title = "Edit your avatar"; }
@model PerformerProfile
@{ ViewBag.Title = "Edit your avatar"; }
@{
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
? string.Empty
: SiteSettings.Value.ApiUrl.TrimEnd('/');
const int maxAvatarSizeMb = 2;
var acceptedAvatarFormats = new[] { "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff" };
var acceptedAvatarMimeTypes = "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff";
var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl)
? "/api/v1/account/set-avatar"
: $"{apiBaseUrl}/api/v1/account/set-avatar";
var previewAvatarSrc = string.IsNullOrWhiteSpace(User?.Identity?.Name)
? Yavsc.Constants.DefaultAvatar
: $"{Yavsc.Constants.AvatarsPath}/{User.Identity.Name}.png";
var accessToken = ViewData["AvatarAccessToken"] as string ?? string.Empty;
}
@section header{
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" type="text/css" />
}
}
@section scripts{
<script>
$(document).ready(function() {
(() => {
const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken));
const uploadUrlBase = "@setAvatarUrl";
const statusNode = document.getElementById('avatar-upload-status');
const uploadUrl = bearer
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
: uploadUrlBase;
function setStatus(message, isError) {
if (!statusNode) return;
statusNode.textContent = message || '';
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
}
Dropzone.options.postavatar = {
maxFilesize: 2, // MB (an avatar)
maxFilesize: @maxAvatarSizeMb, // MB
acceptedFiles: "@acceptedAvatarMimeTypes",
maxFiles: 1,
addRemoveLinks: true,
autoProcessQueue: true,
accept: function(file, done) {
if (file.name == "justinbieber.jpg") {
done("Naha, you don't.");
}
else { done(); }
},
url: "/api/setavatar"
};
});
url: uploadUrl,
init: function() {
this.on('sending', function(file, xhr) {
setStatus('Upload en cours...', false);
if (bearer) {
xhr.setRequestHeader('Authorization', 'Bearer ' + bearer);
}
});
this.on('success', function(file, response) {
const msg = response && response.message
? response.message
: 'Avatar mis a jour.';
setStatus(msg + ' Format de sortie: PNG.', false);
});
this.on('error', function(file, errorMessage, xhr) {
let message = 'Echec de l\'upload avatar.';
if (xhr && xhr.responseText) {
try {
const payload = JSON.parse(xhr.responseText);
if (payload && payload.message) {
message = payload.message;
}
} catch (_) {
}
} else if (typeof errorMessage === 'string' && errorMessage.length > 0) {
message = errorMessage;
}
setStatus(message + ' Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille max: @maxAvatarSizeMb MB.', true);
});
}
};
})();
</script>
}
<img src="~/avatars/@(User.Identity.Name).png">
<img src="@previewAvatarSrc">
<p>Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille maximale: @maxAvatarSizeMb MB. L'image finale est en PNG.</p>
<p id="avatar-upload-status" aria-live="polite"></p>
<form id="postavatar" action="/api/setavatar" class="dropzone" method="post" enctype="multipart/form-data">
<form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data">
<div class="fallback">
<input name="Avatar" type="file" id="Avatar" />
</div>

View file

@ -1,5 +1,13 @@
@using Yavsc.Abstract.Identity
@model ApplicationUser
@if (Model!=null) {
<img src="/Avatars/@(Model.UserName+".xs").png" asp-append-version="true" class="smalltofhol" />
@if (Model != null)
{
var avatarSrc = UserDisplayHelpers.AvatarSrc(Model);
if (avatarSrc.EndsWith(".s.png", StringComparison.Ordinal))
{
avatarSrc = avatarSrc.Replace(".s.png", ".xs.png", StringComparison.Ordinal);
}
<img src="@avatarSrc" asp-append-version="true" class="smalltofhol" />
}

View file

@ -10,7 +10,7 @@ if (Model ==null || Model.UserName==null)
}
}
else {
string avuri = "/Avatars/" + Model.UserName + ".s.png";
string avuri = UserDisplayHelpers.AvatarSrc(Model);
<div class="userinfo">
<a title="Posts" asp-controller="Blogspot" asp-action="Index" asp-route-id="@Model.UserName" class="btn btn-primary">

View file

@ -34,17 +34,13 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
<PackageReference Include="Google.Apis.Compute.v1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" />
<PackageReference Include="Microsoft.AspNetCore.Razor" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" />
<PackageReference Include="System.Security.Cryptography.Pkcs" />
<PackageReference Include="Microsoft.AspNetCore.Antiforgery" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="AsciiDocSharp" />
<PackageReference Include="AsciiDocSharp.Converters.Html" />
<PackageReference Include="BouncyCastle.Cryptography" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>

View file

@ -18,6 +18,7 @@
"Authority": "https://[Your domaine name]",
"Audience": ["blogs"],
"ExternalUrl": "https://[Your domaine name]",
"ApiUrl": "https://[Your API domaine name]",
"CorsAllowedOrigins": [
"*"
],

View file

@ -179,7 +179,7 @@ namespace Yavsc.Server.Helpers
/// <param name="contentType"></param>
/// <param name="token"></param>
/// <returns></returns> <summary>
///
///
/// </summary>
/// <param name="user"></param>
/// <param name="root"></param>
@ -244,8 +244,24 @@ namespace Yavsc.Server.Helpers
public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user, IFormFile formFile)
{
if (user == null) throw new ArgumentNullException(nameof(user));
if (formFile == null) throw new ArgumentNullException(nameof(formFile));
var avatarsRequestPath = Config.AvatarsOptions?.RequestPath.ToUriComponent();
if (string.IsNullOrWhiteSpace(avatarsRequestPath))
{
avatarsRequestPath = Constants.AvatarsPath;
}
var avatarsDirectory = Config.SiteSetup?.Avatars;
if (string.IsNullOrWhiteSpace(avatarsDirectory))
{
avatarsDirectory = "avatars";
}
Directory.CreateDirectory(avatarsDirectory);
var item = new FileReceivedInfo
(Config.AvatarsOptions.RequestPath.ToUriComponent(),
(avatarsRequestPath,
user.UserName + ".png");
using (var org = formFile.OpenReadStream())
@ -256,15 +272,15 @@ namespace Yavsc.Server.Helpers
using var image = new MagickImage(org);
image.Resize(size);
image.Write(Path.Combine(Config.SiteSetup.Avatars, item.FileName));
image.Write(Path.Combine(avatarsDirectory, item.FileName));
size.X = 64;
size.Y = 64;
image.Resize(size);
image.Write(Path.Combine(Config.SiteSetup.Avatars, user.UserName + ".s.png"));
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".s.png"));
size.X = 32;
size.Y = 32;
image.Resize(size);
image.Write(Path.Combine(Config.SiteSetup.Avatars, user.UserName + ".xs.png"));
image.Write(Path.Combine(avatarsDirectory, user.UserName + ".xs.png"));
}

View file

@ -9,84 +9,6 @@ namespace Yavsc.Server.Helpers
/// </summary>
public class RequestHelper
{
string WRPostMultipart(string url, Dictionary<string, object> parameters, string authorizationHeader = null)
{
string boundary = "---------------------------" + DateTime.UtcNow.Ticks.ToString("x");
byte[] boundaryBytes = System.Text.Encoding.ASCII.GetBytes("\r\n--" + boundary + "\r\n");
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.ContentType = "multipart/form-data; boundary=" + boundary;
request.Method = "POST";
request.KeepAlive = true;
request.Credentials = System.Net.CredentialCache.DefaultCredentials;
if (authorizationHeader != null)
request.Headers["Authorization"] = authorizationHeader;
if (parameters != null && parameters.Count > 0)
{
using (Stream requestStream = request.GetRequestStream())
{
using (WebResponse response = request.GetResponse())
{
foreach (KeyValuePair<string, object> pair in parameters)
{
requestStream.Write(boundaryBytes, 0, boundaryBytes.Length);
if (pair.Value is FormFile)
{
FormFile file = pair.Value as FormFile;
string header = "Content-Disposition: form-data; name=\"" + pair.Key + "\"; filename=\"" + file.Name + "\"\r\nContent-Type: " + file.ContentType + "\r\n\r\n";
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(header);
requestStream.Write(bytes, 0, bytes.Length);
byte[] buffer = new byte[32768];
int bytesRead;
if (file.Stream == null)
{
// upload from file
using (FileStream fileStream = File.OpenRead(file.FilePath))
{
while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) != 0)
requestStream.Write(buffer, 0, bytesRead);
fileStream.Close();
}
}
else
{
// upload from given stream
while ((bytesRead = file.Stream.Read(buffer, 0, buffer.Length)) != 0)
requestStream.Write(buffer, 0, bytesRead);
}
}
else
{
string data = "Content-Disposition: form-data; name=\"" + pair.Key + "\"\r\n\r\n" + pair.Value;
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(data);
requestStream.Write(bytes, 0, bytes.Length);
}
}
byte[] trailer = System.Text.Encoding.ASCII.GetBytes("\r\n--" + boundary + "--\r\n");
requestStream.Write(trailer, 0, trailer.Length);
requestStream.Close();
using (Stream responseStream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(responseStream))
{
return reader.ReadToEnd();
}
} // end WebResponse response
} // end using requestStream
}
else throw new ArgumentOutOfRangeException("no parameter found ");
}
public static async Task<string> PostMultipart(string url, FormFile[] formFiles, string access_token = null)
{

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
@ -133,6 +134,23 @@ public static class ServiceExtensions
(_, _, _, _) => true
};
}
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Fallback for clients that cannot reliably attach Authorization header
// on multipart uploads. Restrict query-token support to this endpoint only.
if (string.IsNullOrEmpty(context.Token)
&& context.Request.Path.Value?.Contains("/api/v1/account/set-avatar", StringComparison.OrdinalIgnoreCase) == true
&& context.Request.Query.TryGetValue("access_token", out var tokenValues))
{
context.Token = tokenValues.ToString();
}
return Task.CompletedTask;
}
};
});
return result;

View file

@ -13,15 +13,15 @@ namespace Yavsc.Models.Messaging
{
Topic = "Estimation";
Estimation = estimate;
perfer = estimate.Owner;
performer = estimate.Owner;
ProviderInfo = new ProviderClientInfo {
Rate = perfer.Rate,
UserName = perfer.Performer.UserName,
Avatar = perfer.Performer.Avatar,
UserId = perfer.PerformerId
Rate = performer.Rate,
UserName = performer.Performer.UserName,
Avatar = performer.Performer.Avatar,
UserId = performer.PerformerId
};
Sender = perfer.Performer.UserName;
Sender = performer.Performer.UserName;
_localizer = SR;
}
@ -31,7 +31,7 @@ namespace Yavsc.Models.Messaging
ProviderClientInfo ProviderInfo { get; set; }
Estimate Estimation { get; set; }
private readonly PerformerProfile perfer;
private readonly PerformerProfile performer;
public string Topic
{
@ -47,7 +47,7 @@ namespace Yavsc.Models.Messaging
public string CreateBody()
{
return string.Format(_localizer["EstimationMessageToClient"], perfer.Performer.UserName, this.Estimation.Bill.Addition());
return string.Format(_localizer["EstimationMessageToClient"], performer.Performer.UserName, this.Estimation.Bill.Addition());
}
}
}

View file

@ -5,7 +5,7 @@ namespace Yavsc
public class SiteSettings
{
public string Title { get; set; } = "Yavsc";
public string Slogan { get; set; } = "";
public string Banner { get; set; } = "";
@ -27,6 +27,10 @@ namespace Yavsc
/// <value></value>
public string ExternalUrl { get; set; } = "http://lua.pschneider.fr";
/// <summary>
/// Base URL of the API fronting this site.
/// </summary>
public string ApiUrl { get; set; } = "";
/// <summary>
/// Must be a fqdn.
/// </summary>
/// <returns></returns>