Compare commits

..
Author SHA1 Message Date
b90f15333d
fix(PostIt): disable links without a reader context
All checks were successful
Dotnet build and test / build (push) Successful in 9m57s
Prevent the Markdown viewer's default browser handler from activating when the blog reader has no view model, while keeping reader links bound to PostIt after context changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:21:58 +01:00
dc4412b9e8
fix(PostIt): render embedded blog images reliably
Use seekable streams for embedded images, bind reader commands only when a page view model is present, and avoid empty-selection binding errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:21:57 +01:00
c1254a522f
feat(PostIt): add dedicated blog reader and video playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:21:57 +01:00
40eb5086c1
Honor the administration setup 2026-10-16 16:21:56 +01:00
d24244aae6
Honor the administration setup
All checks were successful
Dotnet build and test / build (push) Successful in 22m1s
2026-10-11 08:31:27 +01:00
2744619249
fix(postit): mount session banner in headless tests
All checks were successful
Dotnet build and test / build (push) Successful in 25m17s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 07:28:17 +01:00
da35ac0a83
do not decide to attach dev tools from the compilation flag
Some checks failed
Dotnet build and test / build (push) Failing after 32m38s
2026-10-11 06:34:53 +01:00
875a020e0d
simpler, better, faster, stronger 2026-10-11 06:27:13 +01:00
eb347e6900
upload test resultes, in case of failure 2026-10-11 06:12:41 +01:00
c34941a4b9
no need for .env to test 2026-10-11 05:59:59 +01:00
afbefae111
make test>/dev/null 2026-10-11 05:43:03 +01:00
29a5b8be8d
run-on ubuntu-26.04, using the default container
All checks were successful
Dotnet build and test / build (push) Successful in 33m13s
2026-10-11 05:11:53 +01:00
d7e274c93a
run-on ubuntu-26.04 2026-10-11 05:08:24 +01:00
bc8ae74fc3
using pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 2026-10-11 04:29:29 +01:00
b5fa7b9072
Github tests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 26m40s
Dotnet build and test / build (push) Successful in 26m24s
2026-10-11 02:26:31 +01:00
4ec169ed71
Improve PostIt draft attachment and save workflow
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 26m22s
Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 02:03:48 +01:00
63b44b5a92
Fix PostIt attachment links to match uploaded filenames
Apply the shared filename filter before URL encoding, preserve escaped URLs and original link labels, and add regression coverage for accented and spaced filenames. Update the changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:38:25 +01:00
96ff4af440
Document changelog updates in project instructions
Require changelog updates by default for each commit and record the uploaded filename encoding fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:21:02 +01:00
79ba934bce
UseStaticFiles was called twice 2026-10-11 01:18:10 +01:00
2765d3eef8
Fix URL-safe encoding of uploaded filenames
Replace the fragment marker used for unsupported filename characters with an underscore and cover upload metadata and static-file downloads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:17:26 +01:00
446b595077
renaming of the 3Blog3 setting => UserFilesRoot
All checks were successful
Dotnet build and test / build (pull_request) Successful in 33m37s
2026-10-11 00:16:44 +01:00
f9d95fd98f
Notify performers of API bookings and client-signed estimates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 00:14:33 +01:00
fae754f102
Display notifications on PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:54:55 +01:00
8b4b6a7c53
Add notifications API for PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:23:55 +01:00
7ee0c30500
Fix authenticated resource usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:03:19 +01:00
ebdf51f468
fix(postit): restore Markdown editor and preview scrolling
All checks were successful
Forgejo Release / release (push) Successful in 7m38s
Dotnet build and test / build (pull_request) Successful in 8m25s
Constrain the blog page layout, separate list and article scrolling, and remove content height limits and nested editor scrolling. Add six headless regression cases and update the changelog and layout documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 18:28:21 +01:00
cd7351a783
feat(postit): rejoin chat room automatically after reconnect
Also drop unused usings in BlogsViewModel.
2026-10-09 17:55:03 +01:00
95e8f251d2
test(postit): verify published badge and publication state notifications
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:47:01 +01:00
e7721bc3ab
feat(postit): show a live published badge beside blog post titles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:46:32 +01:00
3f73d7a909
co-auteurs
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m0s
2026-10-09 17:14:41 +01:00
a3207352c3
Release 1.0.8-rc23
All checks were successful
Forgejo Release / release (push) Successful in 14m32s
Dotnet build and test / build (pull_request) Successful in 17m1s
2026-10-09 16:01:49 +01:00
12f8b52173
admin page presentation 2026-10-09 16:01:26 +01:00
1faa80c884
fix(postit): serialize refresh token rotation across authentication paths
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 15:53:05 +01:00
f050ee4456
feat(account): enable native account management and register API Identity services
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 13:30:01 +01:00
5e22b9a232
fix(postit): preserve performer identity during save 2026-10-07 12:35:09 +01:00
008295c8f6
fix(postit): normalize profile calendar and bank clear state 2026-10-07 12:32:17 +01:00
6b75ae7682
feat(postit): admin search includes profile metadata 2026-10-07 12:29:16 +01:00
05e35d0677
feat(postit): allow username updates in profile flow 2026-10-07 12:26:02 +01:00
137665bfe0
feat(postit): include google calendar and bank details in profile 2026-10-07 12:22:49 +01:00
af1e572f32
feat(postit): migrate editable profile data and admin profile flow 2026-10-07 12:20:26 +01:00
f8c2a01ef4
Add PostIt resource usage admin view
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m24s
2026-10-07 02:26:01 +01:00
a843e77ae9
mise en forme du web 2026-10-06 17:22:48 +01:00
0c72c49ec1
rollback on test artifacts
All checks were successful
Dotnet build and test / build (push) Successful in 12m0s
2026-10-05 15:22:25 +01:00
7d10060efd
Release 1.0.8-rc22
All checks were successful
Dotnet build and test / build (push) Successful in 14m59s
Forgejo Release / release (push) Successful in 9m31s
2026-10-05 14:48:45 +01:00
4ff072ba06
load the CodeBlock 2026-10-05 14:07:15 +01:00
eeae7379e6
release 1.0.8-rc21
All checks were successful
Dotnet build and test / build (push) Successful in 12m29s
Forgejo Release / release (push) Successful in 8m27s
2026-10-04 21:58:35 +01:00
8b61887865
release 1.0.8-rc21 2026-10-04 21:58:08 +01:00
1897a6807d
Fix long-form markdown editor scrolling and focus
Some checks failed
Dotnet build and test / build (push) Failing after 7m50s
2026-10-04 20:18:43 +01:00
bba5fe093c
+ scroll 2026-10-04 20:12:34 +01:00
7e9e7613ba
Fix markdown draft preview sync in PostIt blog editor 2026-10-04 20:03:01 +01:00
122 changed files with 11620 additions and 523 deletions

View file

@ -50,10 +50,6 @@ jobs:
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
- name: Test
artifacts:
name: test-results
path: test-results.html
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \

3
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,3 @@
# Conventions du projet
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.

View file

@ -8,8 +8,7 @@ on:
jobs:
production-deploy:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
steps:
# 1. Récupération du code
- name: Check out the repo
@ -25,8 +24,24 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 10.0.x
- name: Workloads restore
run: dotnet workload restore
- name: Test
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
env:
TEST_FAILED: ""
run: make test || export TEST_FAILED=$?
- uses: actions/upload-artifact@v7
with:
name: my-artifact
path: test-reports/**/*
- name: Fail if tests failed
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
run: exit $TEST_FAILED
# 4. Build et Push de l'image de production finale
- name: Build and push production image
uses: docker/build-push-action@v7

1
.gitignore vendored
View file

@ -48,3 +48,4 @@ DataDir/
# Exclude test reports (local test report, not for the repo)
/test-reports
/doc/**/.*.md

View file

@ -1,5 +1,169 @@
# Changelog
## [1.0.8-rc26] - unstable
### Added
* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur,
date et contenu Markdown défilant ; l'édition et la création restent
accessibles par des actions explicites, sans perdre le brouillon en
passant de la lecture à l'édition.
* [PostIt] Résolution des liens et images relatifs depuis
`<autorité Yavsc.Org>/files/<login auteur>/`, reconnaissance des fichiers
utilisateur sur cette origine et détection du type MIME par HTTP.
* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop,
avec lecture/pause, libération du lecteur à la fermeture et accès au
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
### Changed
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées.
* [PostIt] Affichage des notifications sur la page d'accueil, avec
actualisation, indicateur de chargement et message d'erreur. Les
notifications publiques sont accessibles sans connexion et la liste
est rechargée lors d'un changement de session.
### Changed
* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ;
les liens provisoires des nouveaux billets sont résolus au premier
enregistrement. Save conserve l'édition ouverte et est mis en valeur
lorsque le titre, le corps ou les pièces jointes sont modifiés.
* [Projet] Instructions Copilot persistantes : mise à jour du changelog
par défaut à chaque commit, avec justification explicite des exceptions.
### Fixed
* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
Les fenêtres sont fermées après chaque test et les quatre boutons
de navigation sont vérifiés.
* [PostIt] Les liens Markdown des pièces jointes utilisent désormais
le même filtre de nom que l'upload serveur, avant l'encodage URL.
Le libellé conserve le nom original, notamment ses accents.
* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés
dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour
éviter les fragments d'URL et permettre le téléchargement via les
fichiers statiques, notamment pour les noms accentués.
* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont
désormais enregistrés côté serveur pour les requêtes authentifiées :
le middleware de télémétrie est maintenant branché après
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
`BandwidthMb` au lieu d'enregistrer des zéros.
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable
### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel,
création ou changement de mot de passe et activation/désactivation
de l'authentification à deux facteurs.
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
comptes bancaires, dissociation des connexions externes et suppression
du compte avec confirmation du nom d'utilisateur.
* [PostIt] Configuration du profil professionnel et des activités,
navigation vers les cercles et les publications depuis le compte.
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
dans les métadonnées du profil et consultation de la consommation
de ressources.
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
ressources, API de synthèse et migration `ResourceUsageRecords`.
* [Tests] Couverture des parcours profil et professionnel, de l'API de
consommation de ressources et du renouvellement des jetons :
rotation à usage unique, appels simultanés, réponses 401 concurrentes
et rejet d'un jeton de renouvellement révoqué.
### Changed
* [PostIt] Réorganisation de la page de compte en sections de profil,
sécurité et préférences ; actions de modification désactivées tant
que le profil n'est pas chargé ou qu'une opération est en cours.
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
dans les pages de profil et de configuration professionnelle.
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
PostIt, avec l'état de sécurité, les connexions externes, les comptes
bancaires et les compteurs du compte.
### Fixed
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
empêchait l'activation du contrôleur de compte et provoquait une 500.
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
ne contient pas d'identifiant utilisateur ou que le compte n'existe
plus, au lieu d'une exception serveur.
* [PostIt] Synchronisation de tous les chemins de renouvellement des
jetons, y compris après une 401 et au démarrage : les appels
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
`invalid_grant` efface la session et invite à se reconnecter.
* [PostIt] Effacement des informations obsolètes après un échec de
rechargement du profil et normalisation des valeurs de calendrier
lors de la sauvegarde.
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
lors de son enregistrement.
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
et validation de longueur des champs.
### Known limitations
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
crédits ne sont pas encore disponibles dans l'interface native.
## [1.0.8-rc22] - unstable
### Fixed
User experience login into PostIt
## [1.0.8-rc21] - unstable
### Fixed
User experience editing the blog article.
## [1.0.8-rc20] - unstable
Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,

View file

@ -1,36 +1,12 @@
# Utilisation de l'image globale pour le build et l'exécution
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
WORKDIR /src
# 1. Copie de la solution
COPY *.sln ./
COPY Directory.*.props ./
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
# Projets associés à PostIt
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . .
RUN dotnet workload restore
# 4. Restauration des dépendances pour tous les projets
RUN dotnet restore
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
COPY . .
# 6. Compilation du serveur Web principal
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly

View file

@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc
APP_PATH=srv/www/yavsc
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
include .env
all:
dotnet build --nologo

View file

@ -232,7 +232,7 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml
# 👥 Crédits & Co-autoraClaudt
# 👥 Crédits & Co-auteurs
Ce projet a été développé de manière hybride :
- **Conception, architecture et revue de code :** Paul Schneider

BIN
contrib/blog-edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
contrib/blog-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -8,7 +8,7 @@
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -30,7 +30,7 @@
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"UserFilesRoot": "files",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",

View file

@ -40,6 +40,51 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop).
Toute la logique (VM, services, navigation, settings, OIDC) est
dans le code-base partagé.
## Lecture des billets et fichiers utilisateur
La page des blogs affiche par défaut une vue de lecture : titre, login
de l'auteur, date de modification et contenu Markdown dans une zone
défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et
**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche
le contenu enregistré sans effacer les modifications non enregistrées ;
un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé.
La base des ressources est dérivée de `Settings.Authentication.Authority`
(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` :
`https://<hôte Org>/files/<Author.UserName>/`. Le repli sur `AuthorId`
conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé.
Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ;
un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org.
Les liens absolus ne sont pas réécrits. Les images Markdown `![texte](url)`
sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
sans accès aux fichiers locaux du poste.
Le résolveur charge chaque image dans un flux mémoire repositionnable
initialement à la position zéro, nécessaire au décodage du lecteur Markdown.
Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible
Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur
conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur
avant d'émettre l'événement de lien et ne permet donc pas ce routage.
Au clic sur un fichier sous `/files/<login>/...` de la même origine Org
(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec
`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse
`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une
extension connue sert de repli uniquement en l'absence de type exploitable
(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont
affichées dans la barre de statut, sans les masquer par une déduction MIME.
Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
une action d'ouverture externe. Le lecteur et ses ressources natives sont
arrêtés et libérés à la sortie de la page. **LibVLC doit être installé**
sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
bibliothèques natives adaptées sont nécessaires sous Windows/macOS).
Une absence de LibVLC ou une erreur de décodage est signalée dans la page.
Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible,
les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
(images liées, PDF, téléchargements) et les liens externes.
## ViewLocator custo
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
@ -126,6 +171,20 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

@ -20,7 +20,8 @@
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />

View file

@ -24,4 +24,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
_recorder.Calls.Add((method, path, contentFactory));
if (typeof(T) == typeof(BlogPostDto))
return Task.FromResult((T)(object)new BlogPostDto
{
Id = 42,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
});
return Task.FromResult(default(T)!);
}
}

View file

@ -20,8 +20,13 @@ namespace PostIt.Tests;
/// </summary>
public class BlogAttachmentLinkTests
{
[Fact]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
[Theory]
[InlineData("note.txt", "note.txt")]
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
string originalName, string urlName)
{
const string authority = "https://idp.example.test";
@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests
vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile(
"note.txt",
originalName,
System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain"));
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
Assert.Empty(recorder.Calls);
var draftBeforeSave = vm.DraftArticle;
await vm.SaveAsync();
// The update branch issues two PUTs: the multipart one
// carrying the file, then — because TryAppendAttachmentLinks
// appended a link — a JSON one carrying the final article.
var linkPut = recorder.Calls.LastOrDefault(
c => c.method == HttpMethod.Put);
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
var sentStringContent = multipartContent.First() as StringContent ;
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
using var document = System.Text.Json.JsonDocument.Parse(sentString);
var article = document.RootElement.GetProperty("article").GetString();
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
// The regression shape: a relative, authority-less link.
Assert.DoesNotContain("](/files/", sentString);
Assert.DoesNotContain("](/files/", article);
Assert.Equal(draftBeforeSave, vm.DraftArticle);
Assert.NotNull(vm.SelectedPost);
Assert.False(vm.IsDraftModified);
}
[Fact]
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
{
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
vm.DraftTitle = "Nouveau billet";
vm.DraftArticle = "Texte conservé.";
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
Assert.Contains("postit-attachment:", vm.DraftArticle);
Assert.Empty(recorder.Calls);
await vm.SaveAsync();
Assert.Equal("Nouveau billet", vm.DraftTitle);
Assert.Contains("Texte conservé.", vm.DraftArticle);
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
vm.DraftArticle);
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
Assert.Equal(42, vm.SelectedPost?.Id);
Assert.Empty(vm.DraftAttachments);
Assert.False(vm.IsDraftModified);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
}
}

View file

@ -0,0 +1,346 @@
using System.Net;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Headless.XUnit;
using Avalonia.Logging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Markdown.Avalonia;
using ColorTextBlock.Avalonia;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
using SkiaSharp;
namespace PostIt.Tests;
public class BlogReaderTests
{
private static BlogPostDto Post(string article = "Texte du billet.") => new()
{
Id = 42,
Title = "Un billet à lire",
Article = article,
AuthorId = "user-id-not-login",
Author = new BlogPostAuthorDto { UserName = "paul" },
DateModified = DateTime.UtcNow
};
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
resources: resources);
[Fact]
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
Assert.False(vm.IsEditing);
Assert.Equal("paul", vm.ReaderAuthor);
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
Assert.Equal("Texte du billet.", vm.ReaderArticle);
}
[Fact]
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
vm.EditPostCommand.Execute(null);
vm.DraftArticle = "Modifications non enregistrées";
vm.ReadPostCommand.Execute(null);
Assert.False(vm.IsEditing);
Assert.Equal("Texte du billet.", vm.ReaderArticle);
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
Assert.True(vm.IsDraftModified);
vm.EditPostCommand.Execute(null);
Assert.True(vm.IsEditing);
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
}
[Fact]
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
{
var vm = CreateViewModel();
vm.SelectedPost = Post();
vm.NewPostCommand.Execute(null);
Assert.True(vm.IsEditing);
Assert.Null(vm.SelectedPost);
Assert.Empty(vm.DraftTitle);
Assert.Empty(vm.DraftArticle!);
vm.DraftTitle = "Nouveau brouillon";
vm.NewPostCommand.Execute(null);
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
}
[AvaloniaFact]
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
var page = new BlogsPage { DataContext = vm };
var nav = new NavigationPage();
await nav.PushAsync(page);
var window = new Window { Width = 800, Height = 700, Content = nav };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.False(page.SaveButton.IsVisible);
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Assert.Equal(vm.ReaderArticle, reader.Markdown);
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
vm.EditPostCommand.Execute(null);
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.True(page.SaveButton.IsVisible);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
var post = Post();
vm.Posts.Add(post);
vm.FilteredPosts.Add(post);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
Window? window = null;
try
{
var page = new BlogsPage();
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
Assert.NotNull(engine.HyperlinkCommand);
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
page.DataContext = vm;
var nav = new NavigationPage();
await nav.PushAsync(page);
window = new Window { Width = 800, Height = 700, Content = nav };
window.Show();
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
vm.SelectedPost = post;
Dispatcher.UIThread.RunJobs();
Assert.Equal(post.Title, vm.ReaderTitle);
Assert.Equal(post.DateModified, vm.ReaderDateModified);
vm.SelectedPost = null;
page.DataContext = null;
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.NotNull(engine.HyperlinkCommand);
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
}
finally
{
window?.Close();
Logger.Sink = previousSink;
}
}
[AvaloniaFact]
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
{
const string url = "https://org.example.test/files/paul/absent.jpg";
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
var resolver = new BlogImageResolver(client);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
try
{
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
Assert.Contains(url, error.Values);
}
finally
{
Logger.Sink = previousSink;
}
}
private sealed class RecordingLogSink : ILogSink
{
public List<(string Area, object?[] Values)> Entries { get; } = [];
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
=> Entries.Add((area, []));
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
=> Entries.Add((area, values));
}
[AvaloniaFact]
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
var page = new BlogsPage { DataContext = vm };
var nav = new NavigationPage();
await nav.PushAsync(page);
var window = new Window { Width = 800, Height = 700, Content = nav };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
scroll.ScrollToEnd();
Assert.True(scroll.Offset.Y > 0);
vm.SelectedPost = null;
Dispatcher.UIThread.RunJobs();
Assert.Empty(reader.Markdown!);
}
finally
{
window.Close();
}
}
[Theory]
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
{
using var source = new SKBitmap(32, 24);
source.Erase(SKColors.CornflowerBlue);
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
var jpeg = encoded.ToArray();
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
{
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
request.RequestUri!.AbsoluteUri);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new NonSeekableStream(jpeg))
};
}));
var resolver = new BlogImageResolver(client)
{
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
};
await using var stream = await resolver.ResolveImageResource(url);
Assert.NotNull(stream);
Assert.True(stream.CanSeek);
Assert.Equal(0, stream.Position);
using var decoded = SKBitmap.Decode(stream);
Assert.NotNull(decoded);
Assert.Equal(32, decoded.Width);
Assert.Equal(24, decoded.Height);
}
private sealed class NonSeekableStream(byte[] bytes) : Stream
{
private readonly MemoryStream _inner = new(bytes, writable: false);
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer, cancellationToken);
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
_inner.Dispose();
base.Dispose(disposing);
}
}
[AvaloniaFact]
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
{
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
{
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content.Headers.ContentType = new("video/mp4");
return response;
}));
var vm = CreateViewModel(new BlogResourceService(client));
vm.SelectedPost = Post();
var app = (App)Application.Current!;
var root = new MainView();
app.AttachMainWindow(root);
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
app.DataTemplates.Insert(0, template);
try
{
var page = new BlogsPage { DataContext = vm };
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Dispatcher.UIThread.RunJobs();
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
Assert.NotNull(link.Command);
link.Command(link.CommandParameter!);
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
await vm.OpenResourceCommand.ExecutionTask;
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
Assert.Equal("video/mp4", target.MimeType);
}
finally
{
app.DataTemplates.Remove(template);
}
}
[AvaloniaFact]
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
{
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
}
[Fact]
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
{
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
var vm = CreateViewModel(new BlogResourceService(client));
vm.SelectedPost = Post();
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
Assert.False(vm.IsBusy);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
}
}

View file

@ -0,0 +1,137 @@
using System.Net;
using System.Net.Http.Headers;
using PostIt.Services;
namespace PostIt.Tests;
public class BlogResourceServiceTests
{
private const string Authority = "https://yavsc.pschneider.fr";
[Theory]
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
[Fact]
public void Encodes_login_and_keeps_trailing_slash()
=> Assert.Equal(Authority + "/files/paul%20test/",
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
[Theory]
[InlineData(Authority + "/files/paul/movie.mp4", true)]
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
[InlineData(Authority + "/files/paul/", false)]
[InlineData(Authority + "/files/paul", false)]
[InlineData(Authority + "/files/", false)]
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
[InlineData(Authority + "/page.mp4", false)]
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
[Theory]
[InlineData("javascript:alert(1)")]
[InlineData("file:///tmp/video.mp4")]
[InlineData("data:video/mp4;base64,AAAA")]
public void Rejects_non_web_links(string link)
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
[Theory]
[InlineData("video/webm", "movie.jpg", "video/webm")]
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
[InlineData(null, "movie.webm", "video/webm")]
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
[InlineData(null, "file.unknown", "application/octet-stream")]
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
{
using var client = new HttpClient(new StubHandler(request =>
{
Assert.Equal(HttpMethod.Head, request.Method);
return Response(HttpStatusCode.OK, mime);
}));
var service = new BlogResourceService(client);
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(HttpStatusCode.MethodNotAllowed)]
[InlineData(HttpStatusCode.NotImplemented)]
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
{
var calls = 0;
using var client = new HttpClient(new StubHandler(request =>
{
calls++;
if (request.Method == HttpMethod.Head)
return Response(status, null);
Assert.Equal(HttpMethod.Get, request.Method);
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
return Response(HttpStatusCode.PartialContent, "video/mp4");
}));
Assert.Equal("video/mp4", await new BlogResourceService(client)
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
Assert.Equal(2, calls);
}
[Theory]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.InternalServerError)]
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
{
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
{
using var client = new HttpClient(new StubHandler(request =>
{
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
return Response(HttpStatusCode.OK, "image/jpeg");
}));
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
await using var stream = await resolver.ResolveImageResource(link);
Assert.NotNull(stream);
}
[Theory]
[InlineData("file:///etc/passwd")]
[InlineData("data:image/png;base64,AAAA")]
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
{
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
}
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
{
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
if (mime is not null)
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
return response;
}
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}

View file

@ -0,0 +1,199 @@
using System.Net.Http;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class HomePageNotificationsTests
{
[Fact]
public async Task Refresh_loads_public_notifications_from_the_business_host()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
await vm.RefreshNotificationsAsync();
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
Assert.True(transport.WasAnonymous);
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
Assert.False(vm.IsLoadingNotifications);
Assert.False(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Old"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.False(vm.HasNoNotifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
Assert.False(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
await vm.RefreshNotificationsAsync();
Assert.Null(vm.NotificationsError);
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
[Fact]
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
var oldResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
await oldLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.False(vm.IsLoadingNotifications);
}
[AvaloniaFact]
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
var settings = new Settings();
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(settings, store);
try
{
var session = new SessionStatusViewModel { Api = api };
var transport = new NotificationTransport();
var vm = CreateViewModel(transport, session);
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
vm.Notifications.Add(Note(9, "Private"));
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
await privateLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
File.Delete(tokenPath);
}
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
var transport = new NotificationTransport
{
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
};
var vm = CreateViewModel(transport);
var page = new HomePage { DataContext = vm };
var nav = new NavigationPage();
var window = new Window { Content = nav, Width = 320, Height = 450 };
try
{
window.Show();
await nav.PushAsync(page);
window.UpdateLayout();
Assert.Single(vm.Notifications);
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
Assert.Contains(texts, t => t.Text == "Notification title"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
Assert.Contains(texts, t => t.Text == "Notification body"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -1,3 +1,4 @@
using Yavsc.Abstract.Identity;
using PostIt.ViewModels;
namespace PostIt.Tests;
@ -12,4 +13,170 @@ public class HomePageProviderFlowTests
Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null));
}
[Fact]
public void HomePage_exposes_profile_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProfile);
Assert.True(vm.OpenProfile.CanExecute(null));
}
[Fact]
public void HomePage_exposes_performer_configuration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenPerformerConfiguration);
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
}
[Fact]
public void HomePage_exposes_administration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
}
[Fact]
public void Administration_page_filters_users_by_search_text()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" });
vm.SearchText = "ali";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_filters_users_by_full_name()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.SearchText = "example";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_tracks_selected_user()
{
var vm = new AdministrationPageViewModel();
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
vm.Users.Add(user);
vm.SelectedUser = user;
Assert.Same(user, vm.SelectedUser);
Assert.Equal("alice", vm.SelectedUser?.UserName);
}
[Fact]
public void UserProfilePage_exposes_full_name_and_address_fields()
{
var vm = new UserProfilePageViewModel();
Assert.Equal("—", vm.FullName);
Assert.Equal("—", vm.Address);
Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar);
Assert.Equal("Aucune information bancaire", vm.BankInfoSummary);
Assert.NotNull(vm.RefreshCommand);
}
[Fact]
public void ProfileUpdateRequest_allows_username_updates()
{
var request = new ProfileUpdateRequest
{
UserName = "alice.new"
};
Assert.Equal("alice.new", request.UserName);
}
[Fact]
public void UserProfilePage_can_save_profile_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
Assert.NotNull(vm.SaveProfileCommand);
Assert.False(vm.SaveProfileCommand.CanExecute(null));
}
[Fact]
public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal("alice.new", payload.UserName);
Assert.Equal("calendar-123", payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "Non configuré",
BankInfoSummary = "Aucune information bancaire"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal(string.Empty, payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload()
{
var vm = new PerformerConfigurationPageViewModel
{
PerformerId = "user-42",
UserName = "alice",
ExerciseCountryCode = "fr",
SIREN = "123456789",
Website = "https://example.com",
Active = true,
AcceptNotifications = true,
AcceptPublicContact = true,
UseGeoLocalizationToReduceDistanceWithClients = true
};
var payload = vm.BuildPerformerProfileSettings();
Assert.Equal("user-42", payload.PerformerId);
Assert.Equal("alice", payload.UserName);
Assert.Equal("fr", payload.ExerciseCountryCode);
}
}

View file

@ -0,0 +1,34 @@
Loin vers l'infini s'étendent
Les grands prés marécageux .
Pas un seul oiseau ne chante
Sur les arbres secs et creux !
*(Refrain)*
Ô terre de détresse,
Où nous devons sans cesse,
Piocher, piocher .
Dans ce camp morne et sauvage
Entouré de murs de fer,
Il nous semble vivre en cage
Au milieu d'un grand désert .
Bruit des pas et bruit des armes,
Sentinelles jours et nuits,
Et du sang, et des cris, des larmes,
La mort pour celui qui fuit .
Mais un jour dans notre vie,
Le printemps refleurira .
Liberté, liberté chérie
Je dirai : « Tu es à moi ! »
*(Dernier refrain)*
Ô terre d'allégresse,
Où nous pourrons sans cesse,
Aimer, aimer !
[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)

View file

@ -42,6 +42,7 @@ public class MainPageSaveTests
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new BlogsViewModel(blog);
viewModel.NewPostCommand.Execute(null);
var page = new BlogsPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it

View file

@ -0,0 +1,143 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -21,9 +21,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
private readonly RSA _rsa;
private readonly string _kid;
private readonly CancellationTokenSource _cts = new();
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
public string Issuer { get; }
public string LoopbackRedirectUri { get; }
public int RefreshRequestCount { get; private set; }
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{
@ -100,7 +102,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["grant_types_supported"] = new[] { "authorization_code" },
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
["code_challenge_methods_supported"] = new[] { "S256" },
};
@ -132,6 +134,16 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
body = await reader.ReadToEndAsync();
var form = ParseForm(body);
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
{
RefreshRequestCount++;
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|| !_refreshTokens.Remove(suppliedToken))
{
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
return;
}
}
// We accept any code and don't validate PKCE on the stub side;
// the OidcClient itself validates the redirect_uri match.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@ -142,11 +154,13 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
["exp"] = now + 600,
["iat"] = now,
["jti"] = Guid.NewGuid().ToString("N"),
};
var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
_refreshTokens.Add(refreshToken);
var response = new
{
access_token = accessToken,
@ -193,10 +207,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
return dict;
}
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200)
{
response.ContentType = "application/json";
response.StatusCode = 200;
response.StatusCode = statusCode;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
await response.OutputStream.WriteAsync(bytes);
response.Close();

View file

@ -1,12 +1,132 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class PostItViewModelTests
{
[Fact]
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
Assert.False(vm.IsDraftModified);
vm.DraftIsPublished = true;
Assert.False(vm.IsDraftModified);
vm.DraftTitle = "Autre";
Assert.True(vm.IsDraftModified);
vm.DraftTitle = "Titre";
Assert.False(vm.IsDraftModified);
vm.DraftArticleDocument!.Insert(0, "Edit ");
Assert.True(vm.IsDraftModified);
vm.DraftArticle = "Corps";
Assert.False(vm.IsDraftModified);
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
Assert.True(vm.IsDraftModified);
}
[AvaloniaFact]
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
{
var recorder = new CallRecorder();
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
"https://blogs.example.test/api/v1/"));
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.Posts.Add(selected);
vm.SearchText = "";
vm.SelectedPost = selected;
var page = new BlogsPage { DataContext = vm };
var navigation = new NavigationPage();
await navigation.PushAsync(page);
var window = new Window { Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
selected = Assert.Single(vm.FilteredPosts);
selected.AuthorId = "tester";
selected.Title = "Titre";
selected.Article = "Corps";
page.PostsListBox.SelectedItem = selected;
Dispatcher.UIThread.RunJobs();
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Same(selected, vm.SelectedPost);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
vm.DraftTitle = "Titre modifié";
Dispatcher.UIThread.RunJobs();
Assert.Contains("modified", page.SaveButton.Classes);
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
await vm.SaveAsync();
Dispatcher.UIThread.RunJobs();
Assert.Same(page, navigation.NavigationStack.Last());
Assert.Same(selected, vm.SelectedPost);
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal("Corps", vm.DraftArticle);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
}
finally
{
window.Close();
}
}
[Fact]
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.SelectedPost = post;
vm.DraftTitle = "Titre modifié";
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
var article = vm.DraftArticle;
await vm.SaveAsync();
Assert.Same(post, vm.SelectedPost);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal(article, vm.DraftArticle);
Assert.Single(vm.DraftAttachments);
Assert.True(vm.IsDraftModified);
Assert.False(vm.IsBusy);
}
[AvaloniaFact]
public void Published_badge_tracks_post_publication_state()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
@ -61,11 +181,22 @@ public class PostItViewModelTests
var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
}
/// <summary>Test fake that always throws if the API is invoked.</summary>

View file

@ -0,0 +1,117 @@
using System.Net.Http;
using System.Text.Json;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ResourceUsageApiClientTests
{
[Fact]
public async Task GetCurrentAsync_calls_user_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetCurrentAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(12.5m, usage.ApiCalls);
Assert.Equal(3.2m, usage.CpuSeconds);
}
[Fact]
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetAdminOverviewAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(2, usage.Count);
Assert.Equal("administrator", usage[0].UserName);
}
private sealed class FakeYavscApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public string? LastMethod { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method.Method;
LastPath = path;
if (typeof(T) == typeof(ResourceUsageSummary))
{
var summary = new ResourceUsageSummary
{
UserId = "user-42",
UserName = "alice",
ApiCalls = 12.5m,
CpuSeconds = 3.2m,
BandwidthMb = 18.4m,
StorageMb = 51.2m,
EstimatedCompensation = 4.9m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
};
return Task.FromResult((T)(object)summary);
}
if (typeof(T) == typeof(List<ResourceUsageSummary>))
{
var list = new List<ResourceUsageSummary>
{
new()
{
UserId = "admin-user",
UserName = "administrator",
ApiCalls = 90m,
CpuSeconds = 14m,
BandwidthMb = 500m,
StorageMb = 2048m,
EstimatedCompensation = 32m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
},
new()
{
UserId = "user-99",
UserName = "bob",
ApiCalls = 40m,
CpuSeconds = 7m,
BandwidthMb = 120m,
StorageMb = 600m,
EstimatedCompensation = 15m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
}
};
return Task.FromResult((T)(object)list);
}
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,6 +1,5 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
@ -23,41 +22,36 @@ namespace PostIt.Tests;
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// <para>These tests set the DataContext directly on the banner
/// mounted in the window. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// </summary>
public class SessionStatusBannerTests
public class SessionStatusBannerTests : IDisposable
{
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
MainWindow window = new MainWindow();
window.Show();
private MainWindow? _window;
var buttons = window.GetVisualDescendants()
[AvaloniaFact]
public void Banner_renders_four_buttons_in_the_visual_tree()
{
SessionStatusBanner banner = CreateBanner();
var buttons = banner.GetVisualDescendants()
.OfType<Button>()
.ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.True(buttons.Count>=4);
Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
Assert.Contains(buttons, b => b.Content as string == "À propos");
}
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
MainWindow window = new MainWindow();
window.Show();
SessionStatusBanner banner = CreateBanner();
var login = window.GetVisualDescendants()
var login = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
@ -69,12 +63,9 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
SessionStatusBanner banner = CreateBanner();
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
SessionStatusBanner banner = CreateBanner();
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
var logout = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
@ -91,22 +82,23 @@ public class SessionStatusBannerTests
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
// Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
// Paramètres has no IsVisible binding and is always shown.
Assert.True(settings.IsVisible);
}
private static SessionStatusBanner CreateBanner()
private SessionStatusBanner CreateBanner()
{
MainWindow window = new MainWindow();
window.Show();
var banner = new SessionStatusBanner();
var status = new SessionStatusViewModel();
banner.DataContext = status;
_window = new MainWindow();
_window.Show();
var banner = _window.GetVisualDescendants()
.OfType<SessionStatusBanner>()
.Single();
banner.DataContext = new SessionStatusViewModel();
return banner;
}
public void Dispose() => _window?.Close();
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{

View file

@ -17,6 +17,56 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
@ -370,20 +420,105 @@ public class YavscApiClientTests
}
}
// SKIPPED — see comment.
//
// We can't cover "TrySilentLoginAsync purges the store when the
// refresh token is rejected" with OidcStubAuthority: the stub's
// /connect/token endpoint is unconditional and hands out a fresh
// refresh token regardless of what the caller sends. To exercise
// the RefreshFailedException path we'd need an authority option
// to fail on a specific refresh-token string; until then the
// production refresh-failure path is covered manually (and by
// the structural guarantee that _store.Clear() runs in the catch
// block of ForceRefreshAsync when result.IsError).
//
// [Fact]
// public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
[Fact]
public async Task Concurrent_silent_login_and_api_calls_share_one_refresh()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
ExpireCachedAccessToken(tokensPath);
var store = new TokenStore(tokensPath);
var previousRefreshToken = store.Load()!.RefreshToken;
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var ct = TestContext.Current.CancellationToken;
var silentLogins = Enumerable.Range(0, 8)
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
Assert.All(silentLogins, login => Assert.True(login.Result));
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task Concurrent_401_responses_share_one_refresh()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
var ct = TestContext.Current.CancellationToken;
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(calls);
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
var tokens = store.Load()!;
store.Save(tokens with
{
RefreshToken = "revoked-refresh-token",
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
Assert.True(error.IsPermanent);
Assert.Contains("reconnecter", error.Message);
Assert.False(client.HasValidSession);
Assert.Null(store.Load());
Assert.Equal(0, apiServer.RequestCount);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
private static T Last<T>(System.Collections.Generic.List<T> list)
{
@ -467,6 +602,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
public string BaseUrl { get; private set; } = string.Empty;
public List<string> SeenBearers { get; } = new();
public int RequestCount => _requestCount;
public string? RejectedBearer { get; set; }
public StubApiServer(bool forceFirstRequest = false)
{
@ -499,7 +635,8 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
if (!string.IsNullOrEmpty(auth))
SeenBearers.Add(auth!);
if (_forceFirstRequest && _requestCount == 1)
if ((_forceFirstRequest && _requestCount == 1)
|| (RejectedBearer is not null && auth == RejectedBearer))
{
ctx.Response.StatusCode = 401;
ctx.Response.Close();

View file

@ -6,6 +6,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
@ -43,10 +44,6 @@ public partial class App : Application, ILiveView, IVMPusherApp
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
}
public override void OnFrameworkInitializationCompleted()
@ -200,7 +197,8 @@ public partial class App : Application, ILiveView, IVMPusherApp
public void ConfigureRootView(MainView rootView)
{
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
rootView.AttachedToVisualTree += (_, _) =>
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>

View file

@ -0,0 +1,56 @@
<UserControl
x:Class="PostIt.Controls.MarkdownEditorControl"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls"
x:Name="Root"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0">
<AvaloniaEdit:TextEditor
x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="0"
Padding="5,5,5,15"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
</Border>
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer
x:Name="PreviewViewer"
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,63 @@
using System;
using Avalonia;
using Avalonia.Controls;
using AvaloniaEdit.Document;
namespace PostIt.Controls;
public partial class MarkdownEditorControl : UserControl
{
public static readonly StyledProperty<string?> MarkdownTextProperty =
AvaloniaProperty.Register<MarkdownEditorControl, string?>(nameof(MarkdownText));
public static readonly StyledProperty<TextDocument?> DocumentProperty =
AvaloniaProperty.Register<MarkdownEditorControl, TextDocument?>(nameof(Document));
public static readonly StyledProperty<bool> IsPreviewVisibleProperty =
AvaloniaProperty.Register<MarkdownEditorControl, bool>(nameof(IsPreviewVisible), false);
public string? MarkdownText
{
get => GetValue(MarkdownTextProperty);
set => SetValue(MarkdownTextProperty, value);
}
public TextDocument? Document
{
get => GetValue(DocumentProperty);
set => SetValue(DocumentProperty, value);
}
public bool IsPreviewVisible
{
get => GetValue(IsPreviewVisibleProperty);
set => SetValue(IsPreviewVisibleProperty, value);
}
public MarkdownEditorControl()
{
InitializeComponent();
PropertyChanged += OnPropertyChanged;
}
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == MarkdownTextProperty)
{
var text = e.NewValue as string ?? string.Empty;
if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
{
Document = new TextDocument(text);
}
}
else if (e.Property == DocumentProperty)
{
var newDocument = e.NewValue as TextDocument;
var text = newDocument?.Text ?? string.Empty;
if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
{
MarkdownText = text;
}
}
}
}

View file

@ -35,6 +35,8 @@ public static class ServiceCollectionHelpers
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -42,6 +44,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainWindow>();
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
// Models
@ -75,6 +78,10 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>();
// ViewModels
services.AddSingleton(settings);
@ -87,11 +94,16 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>();
services.AddTransient<UserProfilePageViewModel>();
services.AddTransient<PerformerConfigurationPageViewModel>();
services.AddTransient<AdministrationPageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();

View file

@ -39,6 +39,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Markdown.Avalonia.Tight" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />

View file

@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Markdown.Avalonia.Utils;
using Avalonia.Logging;
namespace PostIt.Services;
public sealed class BlogImageResolver : IPathResolver
{
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly HttpClient _client;
public BlogImageResolver() : this(Client) { }
public BlogImageResolver(HttpClient client) => _client = client;
public string? AssetPathRoot { get; set; }
public IEnumerable<string>? CallerAssemblyNames { get; set; }
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
{
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
? absolute
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
try
{
// The Markdown decoder needs a seekable stream positioned at the start.
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
throw;
}
}
}

View file

@ -0,0 +1,98 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Files;
namespace PostIt.Services;
public sealed class BlogResourceService
{
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly HttpClient _client;
public BlogResourceService() : this(SharedClient) { }
public BlogResourceService(HttpClient client) => _client = client;
public static Uri GetUserBaseUri(string authority, string login)
{
if (string.IsNullOrWhiteSpace(login))
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
}
public static Uri Resolve(string link, string authority, string login)
{
if (string.IsNullOrWhiteSpace(link))
throw new ArgumentException("Le lien est vide.", nameof(link));
var origin = GetAuthority(authority);
var uri = link.StartsWith("/", StringComparison.Ordinal)
? new Uri(origin, link)
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
? absolute
: new Uri(GetUserBaseUri(authority, login), link);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
return uri;
}
public static bool IsUserResource(Uri uri, string authority)
{
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
return false;
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
var separator = path.IndexOf('/');
return separator > 0 && separator < path.Length - 1;
}
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
{
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
{
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
fallback.EnsureSuccessStatusCode();
return GetMimeType(fallback, uri);
}
response.EnsureSuccessStatusCode();
return GetMimeType(response, uri);
}
private static string GetMimeType(HttpResponseMessage response, Uri uri)
{
var mime = response.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
return mime;
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
{
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".ogv" => "video/ogg",
".ogg" => "audio/ogg",
".mov" => "video/quicktime",
".mkv" => "video/x-matroska",
".avi" => "video/x-msvideo",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => mime ?? "application/octet-stream"
};
}
private static Uri GetAuthority(string authority)
{
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|| !string.IsNullOrEmpty(uri.UserInfo))
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
}
}

View file

@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
@ -314,7 +324,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
// Server rejected: could be revocation, clock skew, audience mismatch.
// Force a refresh and retry exactly once.
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path);
if (contentFactory is not null)
@ -379,13 +389,46 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
{
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
var tokens = _tokens;
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
if (rejectedAccessToken is null
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
if (isPermanent)
{
_store.Clear();
_tokens = null;
}
throw new RefreshFailedException(
isPermanent
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
isPermanent);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? tokens.IdToken);
_store.Save(_tokens);
}
finally
{
@ -393,30 +436,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
}
}
private async Task ForceRefreshAsync(CancellationToken ct)
{
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
throw new InvalidOperationException("No refresh token available.");
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
// Refresh token dead: revoked, expired, or rotation-theft
// detected. Purge and force an interactive re-login.
_store.Clear();
_tokens = null;
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? _tokens.IdToken);
_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
@ -459,8 +478,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
@ -514,8 +534,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
@ -603,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_owner._tokens is not null)
if (_owner._tokens is not null
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken);

View file

@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
@ -60,6 +61,10 @@ public class ViewLocator : IDataTemplate
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }

View file

@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial ObservableCollection<UserInfo> Users { get; set; } = new();
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement…";
[ObservableProperty]
public partial UserInfo? SelectedUser { get; set; }
public IReadOnlyList<UserInfo> FilteredUsers =>
string.IsNullOrWhiteSpace(SearchText)
? Users.ToList()
: Users.Where(user =>
(user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.FullName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.ToList();
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api)
{
_api = api;
}
public AdministrationPageViewModel() : this(null) { }
public Task InitializeAsync() => RefreshAsync();
partial void OnSearchTextChanged(string value)
{
OnPropertyChanged(nameof(FilteredUsers));
}
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
StatusMessage = "Client API d’administration indisponible.";
return;
}
IsBusy = true;
try
{
var query = SearchText.Trim();
var usersEndpoint = string.IsNullOrWhiteSpace(query)
? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString()
: new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString();
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true);
Users.Clear();
foreach (var user in users ?? [])
{
Users.Add(user);
}
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)
{
Users.Clear();
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
@ -10,9 +11,10 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using Yavsc.Server.Helpers;
using AvaloniaEdit.Document;
using PostIt.Models;
using Avalonia.Controls;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -39,18 +41,66 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary>
public string DraftArticle
public string? DraftArticle
{
get => DraftArticleDocument.Text;
set {
get => DraftArticleDocument?.Text ?? null;
set
{
var currentText = DraftArticleDocument?.Text;
if (string.Equals(currentText, value, StringComparison.Ordinal))
{
OnPropertyChanged(nameof(DraftArticle));
return;
}
OnPropertyChanging(nameof(DraftArticle));
DraftArticleDocument = new TextDocument(value);
this.OnPropertyChanged(nameof(DraftArticle));
try
{
DraftArticleDocument = value is not null ? new TextDocument(value) : null;
}
catch (Exception ex)
{
StatusMessage = $"Error setting DraftArticleDocument: {ex.Message}";
// Handle any exceptions that might occur when setting the DraftArticleDocument
}
OnPropertyChanged(nameof(DraftArticle));
}
}
[ObservableProperty]
public partial TextDocument DraftArticleDocument {get; set;}
private TextDocument? _draftArticleDocument;
public TextDocument? DraftArticleDocument
{
get => _draftArticleDocument;
set
{
if (ReferenceEquals(_draftArticleDocument, value))
{
return;
}
if (_draftArticleDocument is not null)
{
_draftArticleDocument.TextChanged -= DraftArticleDocument_TextChanged;
}
_draftArticleDocument = value;
if (_draftArticleDocument is not null)
{
_draftArticleDocument.TextChanged += DraftArticleDocument_TextChanged;
}
OnPropertyChanged(nameof(DraftArticleDocument));
UpdateDraftModified();
}
}
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(DraftArticle));
UpdateDraftModified();
}
/// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the
@ -63,6 +113,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty]
public partial bool DraftIsPublished { get; set; }
[ObservableProperty]
public partial bool IsDraftModified { get; private set; }
private bool _preserveDraft;
private string _savedTitle = string.Empty;
private string _savedArticle = string.Empty;
private readonly Dictionary<string, string> _pendingAttachmentLinks = new();
public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; }
@ -85,8 +143,87 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReaderArticle))]
[NotifyPropertyChangedFor(nameof(ReaderAuthor))]
public partial BlogPostDto? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsEditing { get; set; }
[ObservableProperty]
public partial string? ReaderBaseUrl { get; private set; }
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
public string ReaderTitle => SelectedPost?.Title ?? string.Empty;
public DateTime? ReaderDateModified => SelectedPost?.DateModified;
private readonly BlogResourceService _resources = new();
[RelayCommand]
private void NewPost()
{
if (IsDraftModified)
{
this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet.");
return;
}
SelectedPost = null;
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
DraftIsPublished = false;
MarkDraftSaved();
IsEditing = true;
}
[RelayCommand]
private void EditPost() => IsEditing = true;
[RelayCommand]
private void ReadPost()
{
IsEditing = false;
UpdateReader();
}
[RelayCommand]
private async Task OpenResourceAsync(string? link)
{
try
{
IsBusy = true;
var uri = BlogResourceService.Resolve(link ?? string.Empty,
Settings.Authentication.Authority, ReaderAuthor);
if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority))
{
this.SetInfoStatus("Détection du type de fichier...");
var mime = await _resources.GetMimeTypeAsync(uri);
if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
&& !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS())
{
await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime));
this.SetInfoStatus($"Vidéo : {mime}");
return;
}
this.SetInfoStatus($"Ouverture dans le navigateur ({mime}).");
}
var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!)
?? throw new InvalidOperationException("La fenêtre de l'application est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(uri))
throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -112,7 +249,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
});
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanSave))]
internal async Task SaveAsync()
{
// The button is already disabled when the title is empty
@ -130,16 +267,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{
var attachments = DraftAttachments.ToArray();
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behavior copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
@ -150,12 +277,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished
};
var savedTitle = draft.Title;
var savedArticle = draft.Article;
var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null)
{
SelectedPost = created;
_preserveDraft = true;
try
{
SelectedPost = created;
}
finally
{
_preserveDraft = false;
}
if (TryAppendAttachmentLinks(created, attachments))
if (ResolvePendingAttachmentLinks(created))
{
var linkUpdate = new BlogPostDto
{
@ -167,16 +304,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow,
};
savedTitle = linkUpdate.Title;
savedArticle = linkUpdate.Article;
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
}
created.Title = savedTitle;
created.Article = savedArticle;
this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear();
MarkDraftSaved(savedTitle, savedArticle);
}
else
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
}
else
{
TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto
{
Id = SelectedPost.Id,
@ -188,11 +331,15 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
SelectedPost.Title = update.Title;
SelectedPost.Article = update.Article;
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear();
MarkDraftSaved(update.Title, update.Article);
}
await RefreshPostsAsync();
UpdateReader();
});
}
@ -385,6 +532,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>();
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
SelectedPost = null;
IsBusy = false;
this.SetInfoStatus("Prêt.");
@ -396,6 +544,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DraftArticle = string.Empty;
DraftIsPublished = false;
MarkDraftSaved();
IsLoaded = false;
// Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor
@ -436,12 +585,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null,
BlogResourceService? resources = null)
{
WindowTitle = "Blog";
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
_resources = resources ?? new BlogResourceService();
Init(settings);
}
@ -456,6 +607,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
partial void OnSelectedPostChanged(BlogPostDto? value)
{
UpdateReader();
if (!_preserveDraft)
IsEditing = false;
if (_preserveDraft) return;
// Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the
@ -469,15 +624,59 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
MarkDraftSaved();
UpdateCommandStates();
}
private void UpdateReader()
{
string? baseUrl = null;
try
{
if (SelectedPost is not null)
baseUrl = BlogResourceService.GetUserBaseUri(
Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException)
{
this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}");
}
ReaderBaseUrl = baseUrl;
OnPropertyChanged(nameof(SelectedPost));
OnPropertyChanged(nameof(ReaderArticle));
OnPropertyChanged(nameof(ReaderAuthor));
OnPropertyChanged(nameof(ReaderTitle));
OnPropertyChanged(nameof(ReaderDateModified));
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
// Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftTitleChanged(string value)
{
SaveCommand.NotifyCanExecuteChanged();
UpdateDraftModified();
}
private void UpdateDraftModified()
{
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|| (DraftAttachments?.Count ?? 0) > 0;
}
private void MarkDraftSaved()
=> MarkDraftSaved(DraftTitle, DraftArticle);
private void MarkDraftSaved(string? title, string? article)
{
_savedTitle = title ?? string.Empty;
_savedArticle = article ?? string.Empty;
UpdateDraftModified();
}
private async Task RefreshPostsAsync()
@ -490,10 +689,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
}
private void ApplyFilter()
@ -508,10 +703,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified);
FilteredPosts.Clear();
foreach (var post in filtered)
var selection = SelectedPost;
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray();
_preserveDraft = true;
try
{
FilteredPosts.Add(post);
for (var i = FilteredPosts.Count - 1; i >= 0; i--)
{
if (!visiblePosts.Contains(FilteredPosts[i]))
FilteredPosts.RemoveAt(i);
}
for (var i = 0; i < visiblePosts.Length; i++)
{
var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]);
if (previousIndex < 0)
FilteredPosts.Insert(i, visiblePosts[i]);
else if (previousIndex != i)
FilteredPosts.Move(previousIndex, i);
}
SelectedPost = selection;
}
finally
{
_preserveDraft = false;
}
}
@ -550,40 +764,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
}
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (attachments.Count == 0)
return false;
var ownerSegment = post.Author?.UserName;
if (string.IsNullOrWhiteSpace(ownerSegment))
ownerSegment = post.AuthorId;
if (string.IsNullOrWhiteSpace(ownerSegment))
return false;
var article = DraftArticle ?? string.Empty;
var links = new List<string>();
foreach (var attachment in attachments)
if (e.NewItems is not null)
{
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
var fileUrl = ResolveUserFileUrl(relativePath);
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
links.Add(markdownLine);
foreach (BlogUploadFile attachment in e.NewItems)
{
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
string fileUrl;
if (SelectedPost is { Id: > 0 } post)
fileUrl = AttachmentUrl(post, storedName);
else
{
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
_pendingAttachmentLinks.Add(fileUrl, storedName);
}
var article = DraftArticle ?? string.Empty;
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
{
var prefix = article.Length == 0 ? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + markdownLine;
}
}
}
UpdateDraftModified();
}
if (links.Count == 0)
return false;
private string AttachmentUrl(BlogPostDto post, string storedName)
{
var owner = post.Author?.UserName ?? post.AuthorId;
if (string.IsNullOrWhiteSpace(owner))
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier.");
return ResolveUserFileUrl(
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
}
var prefix = article.Length == 0
? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + string.Join("\n", links);
return true;
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
{
var article = DraftArticle ?? string.Empty;
foreach (var link in _pendingAttachmentLinks)
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
StringComparison.Ordinal);
var changed = article != DraftArticle;
if (changed) DraftArticle = article;
_pendingAttachmentLinks.Clear();
return changed;
}
private string ResolveUserFileUrl(string relativePath)
@ -592,7 +819,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
{
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri;
}
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";

View file

@ -0,0 +1,12 @@
using System;
namespace PostIt.ViewModels;
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
{
public Uri Source { get; } = source;
public string MimeType { get; } = mimeType;
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } }
}

View file

@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat
});
return Task.CompletedTask;
};
_connection.Reconnected += _ =>
_connection.Reconnected += async _ =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
@ -254,7 +254,11 @@ namespace PostIt.ViewModels.Chat
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
});
return Task.CompletedTask;
// La reconnexion crée une nouvelle ConnectionId côté
// serveur : l'appartenance aux salons est perdue, il
// faut rejoindre à nouveau le salon courant.
if (!string.IsNullOrWhiteSpace(CurrentRoom))
await JoinRoomAsync();
};
_connection.Closed += error =>
{

View file

@ -1,11 +1,16 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
public ObservableCollection<Notification> Notifications { get; } = new();
private readonly NotificationsApiClient? _notificationsClient;
private int _notificationsLoadVersion;
private bool _isLoadingNotifications;
private string? _notificationsError;
private bool _notificationsLoaded;
public bool IsLoadingNotifications
{
get => _isLoadingNotifications;
private set => SetProperty(ref _isLoadingNotifications, value);
}
public string? NotificationsError
{
get => _notificationsError;
private set => SetProperty(ref _notificationsError, value);
}
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus,
NotificationsApiClient notificationsClient)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
_notificationsClient = notificationsClient;
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -36,17 +66,78 @@ public class HomePageViewModel : ViewModelBase
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
}
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
public IAsyncRelayCommand OpenAdministration { get; }
public IAsyncRelayCommand OpenResourceUsage { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public async Task RefreshNotificationsAsync()
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
var version = ++_notificationsLoadVersion;
Notifications.Clear();
NotificationsError = null;
_notificationsLoaded = false;
OnPropertyChanged(nameof(HasNoNotifications));
IsLoadingNotifications = true;
try
{
var notifications = SessionStatus.IsLoggedIn
? await _notificationsClient.GetCurrentAsync()
: await _notificationsClient.GetPublicAsync();
if (version != _notificationsLoadVersion)
return;
if (notifications is null)
throw new InvalidOperationException("The notifications response is empty.");
foreach (var notification in notifications)
Notifications.Add(notification);
_notificationsLoaded = true;
}
catch (Exception ex)
{
if (version == _notificationsLoadVersion)
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
}
finally
{
if (version == _notificationsLoadVersion)
{
IsLoadingNotifications = false;
OnPropertyChanged(nameof(HasNoNotifications));
}
}
}
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
}
private async Task OpenActivitiesAsync()
{
@ -138,6 +229,79 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm);
}
private async Task OpenProfileAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<UserProfilePageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page profil indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenPerformerConfigurationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page performer indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenAdministrationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<AdministrationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page administration indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenResourceUsageAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var resourceUsageClient = app.ServiceProvider?.GetRequiredService<ResourceUsageApiClient>();
if (resourceUsageClient is null)
{
throw new InvalidOperationException("Client usage indisponible.");
}
var vm = new ResourceUsagePageViewModel(resourceUsageClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
@ -147,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
{
}

View file

@ -0,0 +1,90 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ResourceUsagePageViewModel : ViewModelBase
{
private readonly ResourceUsageApiClient _client;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial bool IsAdminView { get; set; }
[ObservableProperty]
public partial ResourceUsageSummary? CurrentSummary { get; set; }
[ObservableProperty]
public partial ObservableCollection<ResourceUsageSummary> Overview { get; set; } = new();
public string Heading => IsAdminView ? "Vue d'administration des usages" : "Mon usage des ressources";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public ResourceUsagePageViewModel(ResourceUsageApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
Overview.Clear();
try
{
var adminOverview = await _client.GetAdminOverviewAsync().ConfigureAwait(true);
if (adminOverview is { Count: > 0 })
{
foreach (var item in adminOverview)
{
Overview.Add(item);
}
CurrentSummary = adminOverview[0];
IsAdminView = true;
return;
}
}
catch (Exception)
{
// Non-admin users are not allowed on the admin route; fall back
// to the current-user summary below.
}
var current = await _client.GetCurrentAsync().ConfigureAwait(true);
CurrentSummary = current;
if (current is not null)
{
Overview.Clear();
Overview.Add(current);
}
IsAdminView = false;
}
catch (Exception ex)
{
CurrentSummary = null;
Overview.Clear();
IsAdminView = false;
throw new InvalidOperationException($"Impossible de charger l'usage des ressources : {ex.Message}", ex);
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,612 @@
using System;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class UserProfilePageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly SessionStatusViewModel? _sessionStatus;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsProfileLoaded { get; set; }
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string CurrentUserName { get; set; } = "—";
[ObservableProperty]
public partial string UserId { get; set; } = "—";
[ObservableProperty]
public partial string FullName { get; set; } = "—";
[ObservableProperty]
public partial string Email { get; set; } = "—";
[ObservableProperty]
public partial string Address { get; set; } = "—";
[ObservableProperty]
public partial string Avatar { get; set; } = "—";
[ObservableProperty]
public partial string Roles { get; set; } = "—";
[ObservableProperty]
public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré";
[ObservableProperty]
public partial string BankInfoSummary { get; set; } = "Aucune information bancaire";
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial bool EmailConfirmed { get; set; }
[ObservableProperty]
public partial bool AllowMonthlyEmail { get; set; }
[ObservableProperty]
public partial bool HasPassword { get; set; }
[ObservableProperty]
public partial bool TwoFactorEnabled { get; set; }
[ObservableProperty]
public partial int ExternalLoginCount { get; set; }
[ObservableProperty]
public partial long PostsCounter { get; set; }
[ObservableProperty]
public partial long DiskUsage { get; set; }
[ObservableProperty]
public partial long DiskQuota { get; set; }
[ObservableProperty]
public partial decimal Credits { get; set; }
[ObservableProperty]
public partial string CurrentPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string NewPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string ConfirmPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankIban { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankBic { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankWicketCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankAccountNumber { get; set; } = string.Empty;
[ObservableProperty]
public partial string UsernameConfirmation { get; set; } = string.Empty;
[ObservableProperty]
public partial int BankedKey { get; set; }
public ObservableCollection<BankAccountInfo> BankAccounts { get; } = [];
public ObservableCollection<LinkedExternalLogin> ExternalLogins { get; } = [];
public ObservableCollection<CalendarOption> Calendars { get; } = [];
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null)
{
_api = api;
_sessionStatus = sessionStatus;
}
public UserProfilePageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
IsProfileLoaded = false;
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
IsProfileLoaded = false;
try
{
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
if (me is null)
{
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
}
UserName = me.UserName ?? "—";
CurrentUserName = UserName;
UserId = me.UserId;
FullName = me.FullName ?? "—";
Email = me.Email ?? "—";
Address = me.Address ?? "—";
Avatar = string.IsNullOrWhiteSpace(me.Avatar) ? "Aucun avatar" : me.Avatar;
Roles = me.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle";
DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar;
BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary;
EmailConfirmed = me.EmailConfirmed;
AllowMonthlyEmail = me.AllowMonthlyEmail;
HasPassword = me.HasPassword;
TwoFactorEnabled = me.TwoFactorEnabled;
ExternalLoginCount = me.ExternalLoginCount;
PostsCounter = me.PostsCounter;
DiskUsage = me.DiskUsage;
DiskQuota = me.DiskQuota;
Credits = me.Credits;
BankAccounts.Clear();
foreach (var account in me.BankAccounts)
{
BankAccounts.Add(account);
}
ExternalLogins.Clear();
foreach (var login in me.ExternalLogins)
{
ExternalLogins.Add(login);
}
IsProfileLoaded = true;
StatusMessage = "Profil chargé.";
}
catch (Exception ex)
{
IsProfileLoaded = false;
UserName = "—";
CurrentUserName = "—";
UserId = "—";
FullName = "—";
Email = "—";
Address = "—";
Avatar = "—";
Roles = "—";
DedicatedGoogleCalendar = "Non configuré";
BankInfoSummary = "Aucune information bancaire";
EmailConfirmed = false;
AllowMonthlyEmail = false;
HasPassword = false;
TwoFactorEnabled = false;
ExternalLoginCount = 0;
PostsCounter = 0;
DiskUsage = 0;
DiskQuota = 0;
Credits = 0;
BankAccounts.Clear();
ExternalLogins.Clear();
Calendars.Clear();
StatusMessage = $"Impossible de charger le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public ProfileUpdateRequest BuildProfileUpdateRequest()
{
static string NormalizeField(string? value, string placeholder)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
return string.Equals(trimmed, placeholder, StringComparison.OrdinalIgnoreCase)
? string.Empty
: trimmed;
}
return new ProfileUpdateRequest
{
UserName = NormalizeField(UserName, "—"),
FullName = NormalizeField(FullName, "—"),
Address = NormalizeField(Address, "—"),
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
};
}
private bool CanSaveProfile() => _api is not null && IsProfileLoaded && !IsBusy;
[RelayCommand(CanExecute = nameof(CanSaveProfile))]
public async Task SaveProfileAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var payload = BuildProfileUpdateRequest();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString();
await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true);
StatusMessage = "Profil enregistré.";
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetMonthlyEmailPreferenceAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/preferences/monthly-email",
new MonthlyEmailPreferenceRequest { Enabled = AllowMonthlyEmail },
enabled => AllowMonthlyEmail = enabled,
"préférence de courriel");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetTwoFactorAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/security/two-factor",
new TwoFactorRequest { Enabled = TwoFactorEnabled },
enabled => TwoFactorEnabled = enabled,
"authentification à deux facteurs");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChangePasswordAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(NewPassword)
|| !string.Equals(NewPassword, ConfirmPassword, StringComparison.Ordinal))
{
StatusMessage = "Le nouveau mot de passe et sa confirmation doivent être identiques.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/security/password");
await _api.CallAsync(HttpMethod.Put, endpoint, new ChangePasswordRequest
{
CurrentPassword = CurrentPassword,
NewPassword = NewPassword,
ConfirmPassword = ConfirmPassword
}).ConfigureAwait(true);
CurrentPassword = string.Empty;
NewPassword = string.Empty;
ConfirmPassword = string.Empty;
HasPassword = true;
StatusMessage = "Mot de passe mis à jour.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de modifier le mot de passe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task AddBankAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(BankIban) && string.IsNullOrWhiteSpace(BankBic))
{
StatusMessage = "Renseignez un IBAN ou un BIC.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/bank-info");
await _api.CallAsync<BankAccountInfo>(HttpMethod.Post, endpoint, new BankAccountInfoRequest
{
IBAN = BankIban.Trim(),
BIC = BankBic.Trim(),
BankCode = BankCode.Trim(),
WicketCode = BankWicketCode.Trim(),
AccountNumber = BankAccountNumber.Trim(),
BankedKey = BankedKey
}).ConfigureAwait(true);
BankIban = string.Empty;
BankBic = string.Empty;
BankCode = string.Empty;
BankWicketCode = string.Empty;
BankAccountNumber = string.Empty;
BankedKey = 0;
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’ajouter le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteBankAccountAsync(BankAccountInfo? account)
{
if (_api is null || account is null)
{
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/bank-info/{account.Id}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task RemoveExternalLoginAsync(LinkedExternalLogin? login)
{
if (_api is null || login is null)
{
return;
}
IsBusy = true;
try
{
var query = $"provider={Uri.EscapeDataString(login.Provider)}&providerKey={Uri.EscapeDataString(login.ProviderKey)}";
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/security/external-logins?{query}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer cette connexion externe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SendEmailConfirmationAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync<JsonElement>(HttpMethod.Post, BuildEndpoint("account/email/confirmation")).ConfigureAwait(true);
StatusMessage = "E-mail de confirmation envoyé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’envoyer l’e-mail de confirmation : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task LoadCalendarsAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var calendars = await _api.CallAsync<CalendarOption[]>(
HttpMethod.Get,
BuildEndpoint("account/calendars")).ConfigureAwait(true);
Calendars.Clear();
foreach (var calendar in calendars ?? [])
{
Calendars.Add(calendar);
}
StatusMessage = Calendars.Count == 0
? "Aucun agenda Google dont vous êtes propriétaire n’est disponible."
: "Agendas Google chargés.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger les agendas Google : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChooseCalendarAsync(CalendarOption? calendar)
{
if (calendar is null)
{
DedicatedGoogleCalendar = "Non configuré";
}
else
{
DedicatedGoogleCalendar = calendar.Id;
}
await SaveProfileAsync().ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (!string.Equals(UsernameConfirmation, CurrentUserName, StringComparison.Ordinal))
{
StatusMessage = "Saisissez exactement votre nom d’utilisateur pour confirmer la suppression.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint("account/me"), new DeleteAccountRequest
{
UsernameConfirmation = UsernameConfirmation
}).ConfigureAwait(true);
UsernameConfirmation = string.Empty;
StatusMessage = "Compte supprimé.";
if (_sessionStatus is not null)
{
await _sessionStatus.LogoutAsync().ConfigureAwait(true);
}
else
{
await _api.LogoutAsync().ConfigureAwait(true);
}
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task UpdateBooleanPreferenceAsync<TRequest>(
string path,
TRequest request,
Action<bool> applyValue,
string preferenceName)
where TRequest : class
{
IsBusy = true;
try
{
var endpoint = BuildEndpoint(path);
var response = await _api!.CallAsync<System.Text.Json.JsonElement>(HttpMethod.Put, endpoint, request).ConfigureAwait(true);
var enabled = response.GetProperty("enabled").GetBoolean();
applyValue(enabled);
StatusMessage = $"Préférence mise à jour : {preferenceName}.";
}
catch (Exception ex)
{
await RefreshAsync().ConfigureAwait(true);
StatusMessage = $"Impossible de mettre à jour {preferenceName} : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private string BuildEndpoint(string path) =>
new Uri(new Uri(_api!.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), path).ToString();
private bool CanModifyAccount() => _api is not null && IsProfileLoaded && !IsBusy;
}

View file

@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
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;
public partial class ActivitySelectionOption : ObservableObject
{
public string Code { get; }
public string Name { get; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public ActivitySelectionOption(string code, string name)
{
Code = code;
Name = name;
}
}
public partial class PerformerConfigurationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly ActivityApiClient? _activityClient;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string PerformerId { get; set; } = string.Empty;
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string ExerciseCountryCode { get; set; } = "fr";
[ObservableProperty]
public partial string SIREN { get; set; } = string.Empty;
[ObservableProperty]
public partial string Website { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Active { get; set; }
[ObservableProperty]
public partial bool AcceptNotifications { get; set; }
[ObservableProperty]
public partial bool AcceptPublicContact { get; set; }
[ObservableProperty]
public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivitySelectionOption> AvailableActivities { get; set; } = new();
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public PerformerConfigurationPageViewModel(YavscApiClient? api, ActivityApiClient? activityClient)
{
_api = api;
_activityClient = activityClient;
}
public PerformerConfigurationPageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null || _activityClient is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var dto = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Get, endpoint).ConfigureAwait(true);
PerformerId = dto?.PerformerId ?? string.Empty;
UserName = dto?.UserName ?? "—";
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
SIREN = dto?.SIREN ?? string.Empty;
Website = dto?.WebSite ?? string.Empty;
Active = dto?.Active ?? false;
AcceptNotifications = dto?.AcceptNotifications ?? false;
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false;
var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true);
var options = Flatten(catalog)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(a => new ActivitySelectionOption(a.Code, a.Name)
{
IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true
})
.ToList();
AvailableActivities.Clear();
foreach (var option in options)
{
AvailableActivities.Add(option);
}
StatusMessage = "Profil performer chargé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public PerformerProfileSettings BuildPerformerProfileSettings()
{
return new PerformerProfileSettings
{
PerformerId = string.IsNullOrWhiteSpace(PerformerId) ? string.Empty : PerformerId,
UserName = UserName,
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients,
SelectedActivityCodes = AvailableActivities
.Where(a => a.IsSelected)
.Select(a => a.Code)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
[RelayCommand]
public async Task SaveAsync()
{
if (_api is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var dto = BuildPerformerProfileSettings();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var response = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Put, endpoint, dto).ConfigureAwait(true);
StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static IEnumerable<ActivityInfo> Flatten(IEnumerable<ActivityInfo> activities)
{
foreach (var activity in activities)
{
yield return activity;
foreach (var child in Flatten(activity.Children ?? Enumerable.Empty<ActivityInfo>()))
{
yield return child;
}
}
}
}

View file

@ -2,13 +2,12 @@
x:Class="PostIt.Views.Blogs.BlogsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
xmlns:services="using:PostIt.Services"
xmlns:models="using:Yavsc.Blogspot"
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:vm="using:PostIt.ViewModels"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
@ -17,10 +16,18 @@
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
Margin="5,5,10,5" HorizontalAlignment="Stretch" BringIntoViewOnFocusChange="False"
<ContentPage.Styles>
<Style Selector="CommandBarButton.modified">
<Setter Property="Background" Value="#FFF0C2" />
<Setter Property="Foreground" Value="#663C00" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*,Auto"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel>
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5"
@ -28,36 +35,48 @@
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="Blog Posts" />
Text="Billets de blog" />
<TextBox
HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..."
PlaceholderText="Rechercher un titre ou un auteur..."
Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel>
<CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
IsVisible="{Binding IsEditing}" Label="Lire" />
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
IsVisible="{Binding !IsEditing}" Label="Modifier" />
<CommandBarButton
x:Name="SaveButton"
Command="{Binding SaveAsync}"
IsVisible="{Binding IsEditing}"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
Label="Save">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="AddAttachmentButton"
IsVisible="{Binding IsEditing}"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
@ -65,6 +84,7 @@
</CommandBarButton>
<CommandBarButton
x:Name="ManageAclButton"
IsVisible="{Binding IsEditing}"
Command="{Binding ManageAclAsync}"
Label="ACL">
<CommandBarButton.Icon>
@ -80,6 +100,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
IsVisible="{Binding IsEditing}"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
@ -87,13 +108,14 @@
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Markdown Viewer">
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
x:Name="OpenSignatureDevButton"
IsVisible="{Binding IsEditing}"
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon>
@ -103,26 +125,45 @@
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="4" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<Border
Grid.Row="1"
Grid.Row="0"
Padding="8"
BorderBrush="Gray"
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding Title}" />
<Border
x:Name="PublishedBadge"
VerticalAlignment="Center"
Padding="6,2"
Background="#F2F7F2"
BorderBrush="#2F6D2F"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsPublished}">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="#2F6D2F"
Text="Publié" />
</Border>
</StackPanel>
<TextBlock
FontSize="10"
Foreground="Gray"
@ -137,91 +178,92 @@
</ListBox>
</Border>
<Border
<GridSplitter
Grid.Row="1"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid x:Name="PostEditor"
IsVisible="{Binding IsEditing}"
Grid.Row="2"
Padding="8"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RowSpacing="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
FontWeight="SemiBold"
Text="Post detail" />
<TextBox
x:Name="DraftTitleTextBox"
Grid.Row="1"
PlaceholderText="Title"
Text="{Binding DraftTitle, Mode=TwoWay}" />
<postitControls:MarkdownEditorControl
Grid.Row="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="Gray"
BorderThickness="1"
IsVisible="{Binding IsMarkdownViewerVisible}">
<md:MarkdownViewer Name="MarkdownViewer" MarkdownText="{Binding DraftArticle}" />
</Border>
IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
<Border
Grid.Row="2"
Padding="8"
BorderBrush="Gray"
BorderThickness="1"
<Border x:Name="AttachmentsBorder"
Grid.Row="3"
Padding="8"
BorderBrush="LightGray"
BorderThickness="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsVisible="{Binding !IsMarkdownViewerVisible}">
<Grid
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RowSpacing="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
VerticalAlignment="Stretch">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock
Grid.Row="0"
FontWeight="SemiBold"
Text="Post detail" />
<TextBox
x:Name="DraftTitleTextBox"
Grid.Row="1"
PlaceholderText="Title"
Text="{Binding DraftTitle, Mode=TwoWay}" />
<AvaloniaEdit:TextEditor
x:Name="DraftArticleTextEditor"
Grid.Row="2"
VerticalAlignment="Stretch"
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="1"
BorderBrush="LightGray"
CornerRadius="5"
Padding="5,5,5,15"
HorizontalScrollBarVisibility="Disabled"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default"
/>
<Border x:Name="AttachmentsBorder"
Grid.Row="3"
Padding="8"
BorderBrush="LightGray"
BorderThickness="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</Border>
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
<WrapPanel Grid.Row="1" Orientation="Horizontal"
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
Opacity="0.65" />
</WrapPanel>
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
AssetPathRoot="{Binding ReaderBaseUrl}"
Markdown="{Binding ReaderArticle}"
SaveScrollValueWhenContentUpdated="False">
<reader:MarkdownScrollViewer.Plugins>
<reader:MdAvPlugins>
<reader:MdAvPlugins.PathResolver>
<services:BlogImageResolver />
</reader:MdAvPlugins.PathResolver>
</reader:MdAvPlugins>
</reader:MarkdownScrollViewer.Plugins>
</reader:MarkdownScrollViewer>
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</Grid>
</StackPanel>
</ScrollViewer>
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -4,78 +4,40 @@ using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Controls.Platform;
using CommunityToolkit.Mvvm.Input;
using Markdown.Avalonia;
namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage
{
private IInputPane? _inputPane;
private static readonly RelayCommand<string?> DisabledLinkCommand =
new(static _ => { }, static _ => false);
public BlogsPage()
{
InitializeComponent();
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
UpdateHyperlinkCommand();
}
protected override void OnLoaded(RoutedEventArgs e)
private void UpdateHyperlinkCommand()
{
base.OnLoaded(e);
// Récupère l'accès au TopLevel de la fenêtre/application
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
if (ArticleReader.Engine is IMarkdownEngine2 engine)
{
_inputPane = topLevel!.InputPane;
if (_inputPane != null)
{
// S'abonne aux changements d'état du clavier virtuel
_inputPane.StateChanged += OnInputPaneStateChanged;
// Applique la configuration initiale si le clavier est déjà présent
UpdateLayoutForKeyboard(_inputPane.OccludedRect.Height);
}
if (DataContext is ViewModels.BlogsViewModel vm)
engine.HyperlinkCommand = vm.OpenResourceCommand;
else
engine.HyperlinkCommand = DisabledLinkCommand;
}
}
protected override void OnUnloaded(RoutedEventArgs e)
{
if (_inputPane != null)
{
_inputPane.StateChanged -= OnInputPaneStateChanged;
}
base.OnUnloaded(e);
}
private void UpdateLayoutForKeyboard(double keyboardHeight)
{
double visibleHeight = this.Bounds.Height - keyboardHeight;
if (keyboardHeight > 0)
{
DraftArticleTextEditor.MaxHeight = visibleHeight;
}
else
{
DraftArticleTextEditor.MaxHeight = double.PositiveInfinity;
}
}
private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e)
{
// e.EndRect contient le rectangle final du clavier virtuel
// e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques)
UpdateLayoutForKeyboard(e.EndRect.Height);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
await vm.InitializeAsync();
}
}
@ -103,7 +65,6 @@ public partial class BlogsPage : ContentPage
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
vm.DraftAttachments.Clear();
foreach (var upload in uploads)
vm.DraftAttachments.Add(upload);
}

View file

@ -0,0 +1,19 @@
<ContentPage
x:Class="PostIt.Views.Blogs.VideoPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
<Border Grid.Row="1" Background="Black">
<vlc:VideoView x:Name="Video" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
</StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid>
</ContentPage>

View file

@ -0,0 +1,108 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.ViewModels;
namespace PostIt.Views.Blogs;
public partial class VideoPage : ContentPage
{
private LibVLC? _libVlc;
private MediaPlayer? _player;
private Media? _media;
public VideoPage() => InitializeComponent();
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
ReleasePlayer();
if (DataContext is not VideoPageViewModel vm)
return;
try
{
Core.Initialize();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
_media = new Media(_libVlc, vm.Source);
Video.MediaPlayer = _player;
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
if (!_player.Play(_media))
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
}
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
or BadImageFormatException or PlatformNotSupportedException)
{
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
}
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
ReleasePlayer();
base.OnNavigatedFrom(e);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ReleasePlayer();
base.OnDetachedFromVisualTree(e);
}
private void ReleasePlayer()
{
PlayButton.IsEnabled = false;
Video.MediaPlayer = null;
if (_player is not null)
{
_player.EncounteredError -= Player_EncounteredError;
_player.Stop();
_player.Dispose();
_player = null;
}
_media?.Dispose();
_media = null;
_libVlc?.Dispose();
_libVlc = null;
}
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(sender, _player))
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
});
private void PlayPause_Click(object? sender, RoutedEventArgs e)
{
if (_player is null || _media is null)
return;
if (_player.IsPlaying)
_player.Pause();
else if (!_player.Play())
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
}
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is not VideoPageViewModel vm)
return;
try
{
var topLevel = TopLevel.GetTopLevel(this)
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
}
catch (Exception ex)
{
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
}
}
}

View file

@ -0,0 +1,59 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.AdministrationPage"
x:DataType="vm:AdministrationPageViewModel"
Header="Administration">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Administration"
FontSize="22"
FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Spacing="8">
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
PlaceholderText="Rechercher un utilisateur..."/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<ListBox ItemsSource="{Binding FilteredUsers}"
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
<StackPanel Spacing="8">
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class AdministrationPage : ContentPage
{
public AdministrationPage()
{
InitializeComponent();
}
}

View file

@ -2,20 +2,50 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:lang="using:Yavsc.Lang"
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel"
Header="Home">
<Design.DataContext>
<vm:HomePageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
HorizontalAlignment="Stretch">
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Actualiser"
Command="{Binding RefreshNotificationsCommand}"
IsEnabled="{Binding !IsLoadingNotifications}"/>
</Grid>
<ProgressBar IsIndeterminate="True" Height="4"
IsVisible="{Binding IsLoadingNotifications}"/>
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
<TextBlock Text="Aucune notification pour le moment."
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="messaging:Notification">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Les Salons de discussion"
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
@ -44,5 +74,22 @@
Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon profil"
Command="{Binding OpenProfile}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon activité / performer"
Command="{Binding OpenPerformerConfiguration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Administration"
Command="{Binding OpenAdministration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon usage / administration"
Command="{Binding OpenResourceUsage}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,4 +1,5 @@
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
&& vm.RefreshNotificationsCommand.CanExecute(null))
_ = vm.RefreshNotificationsAsync();
}
}

View file

@ -0,0 +1,59 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.PerformerConfigurationPage"
x:DataType="vm:PerformerConfigurationPageViewModel"
Header="Mon activité / performer">
<ScrollViewer>
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Mon activité / performer"
FontSize="22"
FontWeight="SemiBold"/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
<TextBox Text="{Binding ExerciseCountryCode, Mode=TwoWay}"
PlaceholderText="Code pays (fr, en...)"/>
<TextBox Text="{Binding SIREN, Mode=TwoWay}"
PlaceholderText="SIREN"/>
<TextBox Text="{Binding Website, Mode=TwoWay}"
PlaceholderText="Site web"/>
<CheckBox Content="Disponible / actif"
IsChecked="{Binding Active, Mode=TwoWay}"/>
<CheckBox Content="Accepter les notifications"
IsChecked="{Binding AcceptNotifications, Mode=TwoWay}"/>
<CheckBox Content="Accepter les demandes publiques"
IsChecked="{Binding AcceptPublicContact, Mode=TwoWay}"/>
<CheckBox Content="Utiliser la géolocalisation"
IsChecked="{Binding UseGeoLocalizationToReduceDistanceWithClients, Mode=TwoWay}"/>
<TextBlock Text="Activités" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding AvailableActivities}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Margin="0,0,0,6"
Content="{Binding Name}"
IsChecked="{Binding IsSelected, Mode=TwoWay}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Enregistrer"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}"/>
<TextBlock Text="{Binding StatusMessage}"
Foreground="#4B5563"
TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class PerformerConfigurationPage : ContentPage
{
public PerformerConfigurationPage()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,57 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:resources="using:Yavsc.Abstract.Resources"
x:Class="PostIt.Views.ResourceUsagePage"
x:DataType="vm:ResourceUsagePageViewModel"
Header="Usage des ressources">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="{Binding Heading}"
FontSize="22"
FontWeight="SemiBold"/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<StackPanel IsVisible="{Binding !IsAdminView}">
<TextBlock Text="Résumé personnel" FontWeight="Bold"/>
<StackPanel DataContext="{Binding CurrentSummary}" Spacing="4">
<TextBlock Text="{Binding UserName, StringFormat='Utilisateur : {0}'}"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
</StackPanel>
</StackPanel>
<ItemsControl ItemsSource="{Binding Overview}"
IsVisible="{Binding IsAdminView}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="resources:ResourceUsageSummary">
<Border Margin="0,0,0,8"
Padding="12"
CornerRadius="8"
BorderBrush="#D0D7DE"
BorderThickness="1">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class ResourceUsagePage : ContentPage
{
public ResourceUsagePage()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,298 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:identity="using:Yavsc.Abstract.Identity"
x:Class="PostIt.Views.UserProfilePage"
x:DataType="vm:UserProfilePageViewModel"
Header="Mon compte">
<ScrollViewer>
<StackPanel Margin="16" Spacing="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="4">
<TextBlock Text="Gérer votre compte"
FontSize="22"
FontWeight="SemiBold"/>
<TextBlock Text="Paramètres de votre compte"
Foreground="#4B5563"/>
</StackPanel>
<Button Grid.Column="1"
Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"
VerticalAlignment="Center"/>
</Grid>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="12">
<TextBlock Text="Paramètres du compte"
FontSize="18"
FontWeight="SemiBold"/>
<Grid ColumnDefinitions="150,*"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"
RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Nom d’utilisateur"
VerticalAlignment="Center"/>
<TextBox Grid.Row="0"
Grid.Column="1"
Text="{Binding UserName, Mode=TwoWay}"/>
<TextBlock Grid.Row="1"
Text="Adresse e-mail"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding Email}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="2"
Text="Nom complet"
VerticalAlignment="Center"/>
<TextBox Grid.Row="2"
Grid.Column="1"
Text="{Binding FullName, Mode=TwoWay}"/>
<TextBlock Grid.Row="3"
Text="Rôles"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="3"
Grid.Column="1"
Text="{Binding Roles}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="4"
Text="Adresse"
VerticalAlignment="Top"/>
<TextBox Grid.Row="4"
Grid.Column="1"
Text="{Binding Address, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="64"/>
<TextBlock Grid.Row="5"
Text="Avatar"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="5"
Grid.Column="1"
Text="{Binding Avatar}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="6"
Text="Calendrier Google"
VerticalAlignment="Center"/>
<TextBox Grid.Row="6"
Grid.Column="1"
Text="{Binding DedicatedGoogleCalendar, Mode=TwoWay}"/>
<TextBlock Grid.Row="7"
Text="Courriel mensuel"
VerticalAlignment="Center"/>
<StackPanel Grid.Row="7"
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<CheckBox Content="Recevoir les nouvelles mensuelles"
IsChecked="{Binding AllowMonthlyEmail, Mode=TwoWay}"/>
<Button Content="Enregistrer"
Command="{Binding SetMonthlyEmailPreferenceCommand}"
IsEnabled="{Binding !IsBusy}"/>
</StackPanel>
<TextBlock Grid.Row="8"
Text="E-mail confirmé"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="8"
Grid.Column="1"
Text="{Binding EmailConfirmed}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir un avatar"
Click="ChooseAvatar_Click"
IsEnabled="{Binding !IsBusy}"/>
<Button Content="Enregistrer le profil"
Command="{Binding SaveProfileCommand}"/>
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Sécurité et connexions"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="{Binding Email, StringFormat='E-mail : {0}'}"/>
<TextBlock Text="{Binding EmailConfirmed, StringFormat='Adresse confirmée : {0}'}"/>
<Button Content="Renvoyer le courriel de confirmation"
Command="{Binding SendEmailConfirmationCommand}"
IsVisible="{Binding !EmailConfirmed}"/>
<TextBlock Text="{Binding ExternalLoginCount, StringFormat='Connexions externes : {0}'}"/>
<ItemsControl ItemsSource="{Binding ExternalLogins}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:LinkedExternalLogin">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Text="{Binding DisplayName}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Content="Dissocier"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).RemoveExternalLoginCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<CheckBox Content="Activer l’authentification à deux facteurs"
IsChecked="{Binding TwoFactorEnabled, Mode=TwoWay}"/>
<Button Content="Enregistrer le réglage de sécurité"
Command="{Binding SetTwoFactorCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
<TextBlock Text="{Binding HasPassword, StringFormat='Mot de passe local configuré : {0}'}"/>
<TextBox PlaceholderText="Mot de passe actuel"
PasswordChar="●"
Text="{Binding CurrentPassword, Mode=TwoWay}"
IsVisible="{Binding HasPassword}"/>
<TextBox PlaceholderText="Nouveau mot de passe"
PasswordChar="●"
Text="{Binding NewPassword, Mode=TwoWay}"/>
<TextBox PlaceholderText="Confirmer le nouveau mot de passe"
PasswordChar="●"
Text="{Binding ConfirmPassword, Mode=TwoWay}"/>
<Button Content="Modifier le mot de passe"
Command="{Binding ChangePasswordCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Informations bancaires"
FontSize="18"
FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding BankAccounts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:BankAccountInfo">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Spacing="2">
<TextBlock Text="{Binding IBAN, StringFormat='IBAN : {0}'}"/>
<TextBlock Text="{Binding BIC, StringFormat='BIC : {0}'}"/>
</StackPanel>
<Button Grid.Column="1"
Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).DeleteBankAccountCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Ajouter un compte bancaire"
FontWeight="SemiBold"/>
<TextBox PlaceholderText="IBAN" Text="{Binding BankIban, Mode=TwoWay}"/>
<TextBox PlaceholderText="BIC" Text="{Binding BankBic, Mode=TwoWay}"/>
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
<TextBox PlaceholderText="Code banque" Text="{Binding BankCode, Mode=TwoWay}"/>
<TextBox Grid.Column="1" PlaceholderText="Code guichet" Text="{Binding BankWicketCode, Mode=TwoWay}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox PlaceholderText="Numéro de compte" Text="{Binding BankAccountNumber, Mode=TwoWay}"/>
<NumericUpDown Grid.Column="1"
Width="120"
Minimum="0"
Maximum="99"
Value="{Binding BankedKey, Mode=TwoWay}"/>
</Grid>
<Button Content="Ajouter les informations bancaires"
Command="{Binding AddBankAccountCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Autres paramètres du compte"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="{Binding PostsCounter, StringFormat='Articles publiés : {0}'}"/>
<TextBlock Text="{Binding DiskUsage, StringFormat='Espace utilisé (octets) : {0}'}"/>
<TextBlock Text="{Binding DiskQuota, StringFormat='Quota disque (octets) : {0}'}"/>
<TextBlock Text="{Binding Credits, StringFormat='Crédits : {0}'}"/>
<TextBlock Text="{Binding UserId, StringFormat='Identifiant utilisateur : {0}'}"/>
<TextBlock Text="Calendrier Google"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Charger les calendriers"
Command="{Binding LoadCalendarsCommand}"/>
<Button Content="Désactiver le calendrier"
Command="{Binding ChooseCalendarCommand}"
CommandParameter="{x:Null}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Calendars}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:CalendarOption">
<Button Content="{Binding Name}"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).ChooseCalendarCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Gérer mes cercles" Click="OpenCircles_Click"/>
<Button Content="Gérer mon activité professionnelle" Click="OpenPerformerConfiguration_Click"/>
<Button Content="Mes publications" Click="OpenBlogs_Click"/>
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="8">
<TextBlock Text="Supprimer le compte"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="Cette action est définitive. Saisissez votre nom d’utilisateur actuel pour confirmer."
TextWrapping="Wrap"/>
<TextBox PlaceholderText="{Binding CurrentUserName, StringFormat='Saisir {0} pour confirmer'}"
Text="{Binding UsernameConfirmation, Mode=TwoWay}"/>
<Button Content="Supprimer définitivement mon compte"
Command="{Binding DeleteAccountCommand}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<TextBlock Text="{Binding StatusMessage}"
Foreground="#4B5563"
TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,139 @@
using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class UserProfilePage : ContentPage
{
public UserProfilePage()
{
InitializeComponent();
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not UserProfilePageViewModel viewModel)
{
return;
}
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
viewModel.StatusMessage = "Impossible d’accéder au sélecteur de fichiers.";
return;
}
viewModel.IsBusy = true;
try
{
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Images")
{
Patterns = ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "*.bmp", "*.tif", "*.tiff"]
}
]
});
var file = files.FirstOrDefault();
if (file is null)
{
return;
}
var properties = await file.GetBasicPropertiesAsync();
if (properties.Size is > 2 * 1024 * 1024)
{
viewModel.StatusMessage = "L’avatar ne doit pas dépasser 2 Mo.";
return;
}
var app = (App?)Application.Current;
var api = app?.ServiceProvider?.GetRequiredService<YavscApiClient>();
if (api is null)
{
viewModel.StatusMessage = "Client API indisponible.";
return;
}
await using var stream = await file.OpenReadAsync();
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
await viewModel.RefreshAsync();
if (viewModel.IsProfileLoaded)
{
viewModel.StatusMessage = message;
}
}
catch (Exception ex)
{
viewModel.StatusMessage = $"Impossible d’envoyer l’avatar : {ex.Message}";
}
finally
{
viewModel.IsBusy = false;
}
}
private async void OpenCircles_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var app = (App?)Application.Current;
var viewModel = app?.ServiceProvider?.GetRequiredService<CirclesPageViewModel>();
if (app is null || viewModel is null)
{
if (DataContext is UserProfilePageViewModel profile)
{
profile.StatusMessage = "La page des cercles est indisponible.";
}
return;
}
await viewModel.RefreshAsync();
await app.PushPageAsync(viewModel);
}
private async void OpenPerformerConfiguration_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var app = (App?)Application.Current;
var viewModel = app?.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (app is null || viewModel is null)
{
if (DataContext is UserProfilePageViewModel profile)
{
profile.StatusMessage = "La configuration professionnelle est indisponible.";
}
return;
}
await viewModel.InitializeAsync();
await app.PushPageAsync(viewModel);
}
private async void OpenBlogs_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await App.PushBlogsPageAsync();
}
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
".tif" or ".tiff" => "image/tiff",
_ => "application/octet-stream"
};
}

View file

@ -1,12 +1,16 @@
#nullable enable annotations
using Yavsc.Abstract.Identity.Security;
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost
public class BlogPostDto : IBlogPost, INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private bool _isPublished;
public string AuthorId { get; set; }
public BlogPostAuthorDto? Author { get; set; }
@ -30,7 +34,18 @@ public class BlogPostDto : IBlogPost
/// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary>
public bool IsPublished { get; set; }
public bool IsPublished
{
get => _isPublished;
set
{
if (_isPublished == value)
return;
_isPublished = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished)));
}
}
public virtual bool AuthorizeCircle(long circleId)
{

View file

@ -30,6 +30,15 @@ namespace Yavsc
public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$";
public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$";
public const string ResourceUsagePath = "resource-usage";
public const string ResourceUsageCurrentUserRoute = "me";
public const string ResourceUsageAdminRoute = "admin";
public const string NotificationsPath = "notifications";
public const string NotificationsCurrentUserRoute = "me";
public const string ResourceUsageDefaultCurrency = "EUR";
public const string ResourceUsageCurrentUserDisplayName = "current-user";
public const string ResourceUsageAdminDisplayName = "administrator";
public const string SigninPath = "~/signin";
public const string LogoutPath = "~/signout";

View file

@ -49,6 +49,7 @@ namespace Yavsc.Server.Helpers
// Ensure this path is canonical,
// No "dirto/./this", neither "dirt/to/that/"
// no .. and each char must be listed as valid in constants
// Encode unsupported chars with '_' rather than the URL fragment marker '#'.
public static string FilterFileName(string fileName)
{
if (fileName==null) return null;
@ -57,7 +58,7 @@ namespace Yavsc.Server.Helpers
{
if (ValidFileNameChars.Contains(c))
sb.Append(c);
else sb.Append("#" + ((int)c).ToString("D3"));
else sb.Append("_" + ((int)c).ToString("D3"));
}
return sb.ToString();
}

View file

@ -0,0 +1,59 @@
#nullable enable
namespace Yavsc.Abstract.Identity;
public sealed class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
}
public sealed class TwoFactorRequest
{
public bool Enabled { get; set; }
}
public sealed class MonthlyEmailPreferenceRequest
{
public bool Enabled { get; set; }
}
public sealed class DeleteAccountRequest
{
public string UsernameConfirmation { get; set; } = string.Empty;
}
public sealed class LinkedExternalLogin
{
public string Provider { get; set; } = string.Empty;
public string ProviderKey { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
public sealed class BankAccountInfo
{
public long Id { get; set; }
public string IBAN { get; set; } = string.Empty;
public string BIC { get; set; } = string.Empty;
public string BankCode { get; set; } = string.Empty;
public string WicketCode { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public int BankedKey { get; set; }
}
public sealed class BankAccountInfoRequest
{
public string IBAN { get; set; } = string.Empty;
public string BIC { get; set; } = string.Empty;
public string BankCode { get; set; } = string.Empty;
public string WicketCode { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public int BankedKey { get; set; }
}
public sealed class CalendarOption
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}

View file

@ -1,9 +1,23 @@
#nullable enable
namespace Yavsc.Abstract.Identity {
public class Me : UserInfo
{
public AuthToken Token {
public bool EmailConfirmed { get; set; }
public bool AllowMonthlyEmail { get; set; }
public bool HasPassword { get; set; }
public bool TwoFactorEnabled { get; set; }
public int ExternalLoginCount { get; set; }
public long PostsCounter { get; set; }
public long DiskUsage { get; set; }
public long DiskQuota { get; set; }
public decimal Credits { get; set; }
public BankAccountInfo[] BankAccounts { get; set; } = [];
public LinkedExternalLogin[] ExternalLogins { get; set; } = [];
public AuthToken? Token {
get;
set;
}
}
}
}

View file

@ -0,0 +1,12 @@
#nullable enable
namespace Yavsc.Abstract.Identity;
public class ProfileUpdateRequest
{
public string? UserName { get; set; }
public string? FullName { get; set; }
public string? Address { get; set; }
public string? GoogleCalendarId { get; set; }
public string? BankInfoSummary { get; set; }
}

View file

@ -1,10 +1,11 @@
#nullable enable
namespace Yavsc.Abstract.Identity
{
public class UserInfo {
public UserInfo()
{
}
public UserInfo(string userId, string userName, string email, string avatar)
@ -14,10 +15,15 @@ namespace Yavsc.Abstract.Identity
Email = email;
Avatar = avatar;
}
public string UserId { get; set; }
public string UserName { get; set; }
public string Email { get; }
public string Avatar { get; set; }
public string UserId { get; set; } = string.Empty;
public string? UserName { get; set; }
public string? FullName { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? Avatar { get; set; }
public string[] Roles { get; set; } = [];
public string? DedicatedGoogleCalendar { get; set; }
public string? BankInfoSummary { get; set; }
}
}

View file

@ -0,0 +1,22 @@
namespace Yavsc.Abstract.Resources;
/// <summary>
/// Lightweight usage snapshot for PostIt / Yavsc resource accounting.
/// The initial implementation is intentionally intentionally small and
/// serialisable so it can be exposed through the API and consumed by the
/// PostIt client without requiring a full database model yet.
/// </summary>
public sealed class ResourceUsageSummary
{
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public DateTimeOffset RecordedAtUtc { get; set; }
public decimal ApiCalls { get; set; }
public decimal CpuSeconds { get; set; }
public decimal BandwidthMb { get; set; }
public decimal StorageMb { get; set; }
public decimal EstimatedCompensation { get; set; }
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
}

View file

@ -0,0 +1,15 @@
namespace Yavsc.Abstract.Workflow;
public sealed class PerformerProfileSettings
{
public string PerformerId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string ExerciseCountryCode { get; set; } = "fr";
public string SIREN { get; set; } = string.Empty;
public string WebSite { get; set; } = string.Empty;
public bool Active { get; set; }
public bool AcceptNotifications { get; set; }
public bool AcceptPublicContact { get; set; }
public bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
public List<string> SelectedActivityCodes { get; set; } = new();
}

View file

@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
/// </summary>
HttpClient Http { get; }
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>

View file

@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Api.Client;
/// <summary>
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
/// </summary>
public sealed class NotificationsApiClient
{
private const string PathPrefix = Constants.NotificationsPath;
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public NotificationsApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
public Task<List<Notification>> GetCurrentAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"),
ct: ct);
}
public Task<List<Notification>> GetAllAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute(PathPrefix),
ct: ct);
}
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Resources;
namespace Yavsc.Api.Client;
/// <summary>
/// Thin HTTP client for the PostIt resource-usage reporting flow.
/// It follows the same pattern as the other Yavsc API clients: absolute
/// paths, JSON request/response mapping, and no application logic.
/// </summary>
public sealed class ResourceUsageApiClient
{
private const string PathPrefix = Constants.ResourceUsagePath;
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public ResourceUsageApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public ResourceUsageApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
public Task<ResourceUsageSummary> GetCurrentAsync(CancellationToken ct = default)
{
return _api.CallAsync<ResourceUsageSummary>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.ResourceUsageCurrentUserRoute}"),
ct: ct);
}
public Task<List<ResourceUsageSummary>> GetAdminOverviewAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<ResourceUsageSummary>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.ResourceUsageAdminRoute}"),
ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Models;
using Yavsc.Server.Helpers;
namespace Yavsc.ApiControllers.Administration;
[Produces("application/json")]
[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)]
public sealed class NotificationsApiController : Controller
{
private readonly ApplicationDbContext _dbContext;
public NotificationsApiController(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet]
[HttpGet(Constants.NotificationsCurrentUserRoute)]
[AllowAnonymous]
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
{
var currentUserId = User.GetUserId();
var isAdmin = User.IsInRole(Constants.AdminGroupName);
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
? new HashSet<long>()
: await _dbContext.DismissClicked
.AsNoTracking()
.Where(d => d.UserId == currentUserId)
.Select(d => d.NotificationId)
.ToHashSetAsync();
var notifications = await _dbContext.Notification
.AsNoTracking()
.Where(n => !dismissedIds.Contains(n.Id))
.Where(n =>
string.IsNullOrWhiteSpace(n.Target)
|| (n.Target != null && n.Target.StartsWith("pub/"))
|| (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}")
|| (isAdmin && n.Target == "administration"))
.OrderByDescending(n => n.Id)
.ToListAsync();
return Ok(notifications);
}
}

View file

@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Resources;
using Yavsc.Models;
using Yavsc.Server.Helpers;
namespace Yavsc.ApiControllers.Administration;
[Produces("application/json")]
[Authorize]
[Route(Constants.APIPrefix + "/" + Constants.ResourceUsagePath)]
public sealed class ResourceUsageApiController : Controller
{
private readonly ApplicationDbContext _dbContext;
public ResourceUsageApiController(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
private static ResourceUsageSummary ToSummary(ResourceUsageRecord record)
=> new()
{
UserId = record.UserId,
UserName = record.UserName,
RecordedAtUtc = new DateTimeOffset(record.RecordedAtUtc, TimeSpan.Zero),
ApiCalls = record.ApiCalls,
CpuSeconds = record.CpuSeconds,
BandwidthMb = record.BandwidthMb,
StorageMb = record.StorageMb,
EstimatedCompensation = record.EstimatedCompensation,
Currency = record.Currency
};
[HttpGet(Constants.ResourceUsageCurrentUserRoute)]
public async Task<ActionResult<ResourceUsageSummary>> GetCurrentUserUsage()
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var userName = User.Identity?.Name ?? Constants.ResourceUsageCurrentUserDisplayName;
var record = await _dbContext.ResourceUsageRecords
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RecordedAtUtc)
.FirstOrDefaultAsync();
if (record is null)
{
var zeroUsage = new ResourceUsageRecord
{
UserId = userId,
UserName = userName,
RecordedAtUtc = DateTime.UtcNow,
ApiCalls = 0m,
CpuSeconds = 0m,
BandwidthMb = 0m,
StorageMb = 0m,
EstimatedCompensation = 0m,
Currency = Constants.ResourceUsageDefaultCurrency,
UserCreated = userId,
UserModified = userId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
_dbContext.ResourceUsageRecords.Add(zeroUsage);
await _dbContext.SaveChangesAsync(userId);
return Ok(ToSummary(zeroUsage));
}
return Ok(ToSummary(record));
}
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
{
if (!User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
var latestByUser = (await _dbContext.ResourceUsageRecords
.OrderByDescending(r => r.RecordedAtUtc)
.ToListAsync())
.GroupBy(r => r.UserId)
.Select(g => g.First())
.OrderByDescending(r => r.RecordedAtUtc)
.ToList();
return Ok(latestByUser.Select(ToSummary).ToList());
}
}

View file

@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
estimate.ClientValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
}
@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers
.FirstOrDefaultAsync(e => e.Id == id, token);
if (estimate is null) return NotFound(new { Error = "estimate not found" });
// The signer is identified by userId in the body, not
// by the bearer token, because the OAuth scope we
// carry is for the API client (PostIt), not the end
// user. We trust the body's userId to match either
// Owner or Client, and reject everything else.
var userId = body.SignerUserId;
if (userId != User.GetUserId())
return Forbid();
if (userId != estimate.OwnerId && userId != estimate.ClientId)
return Forbid();
@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers
var signature = await EstimateSignaturePersister.StageAsync(
dbContext, id, type, userId, payload, token);
if (type == SignatureType.Client)
estimate.ClientValidationDate = DateTime.UtcNow;
try
{
await dbContext.SaveChangesAsync(token);
@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (type == SignatureType.Client)
{
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
}
var location = Url.Action(nameof(Sign), new { id })
?? $"/api/bill/estimate/{id}/sign";
return Created(location, new
@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// field disambiguates which side of the estimate signed
/// because the bearer token belongs to the PostIt OAuth
/// client, not the end user.
/// field must match the authenticated caller and identifies
/// which party of the estimate signed.
/// </summary>
public class SignatureSubmission
{

View file

@ -1,3 +1,6 @@
#nullable enable annotations
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
@ -9,6 +12,7 @@ using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Messaging;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
@ -25,6 +29,7 @@ namespace Yavsc.ApiControllers
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
private readonly IRazorViewEngine viewEngine;
private readonly IYavscMessageSender messageSender;
// The view-rendering dependencies (siteSettings, viewEngine) are
// optional: only EstimateTex/EstimatePdf use them, and those run in
@ -44,11 +49,13 @@ namespace Yavsc.ApiControllers
public FrontOfficeApiController(
ApplicationDbContext context,
ILoggerFactory loggerFactory,
IYavscMessageSender messageSender,
IOptions<SiteSettings> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{
dbContext = context;
this.messageSender = messageSender;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
@ -129,6 +136,9 @@ namespace Yavsc.ApiControllers
Temp = siteSettings.TempDir,
};
var stopwatch = Stopwatch.StartNew();
FileInfo? renderedPdf = null;
try
{
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
@ -141,10 +151,28 @@ namespace Yavsc.ApiControllers
// we stream the generated PDF bytes — but driving the
// generation through the view keeps the template as the
// output producer, per the original design.
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
renderedPdf = model.GenerateEstimatePdf();
if (renderedPdf is FileInfo fo && fo.Exists)
{
return File(fo.OpenRead(), "application/pdf");
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: fo.Length,
actorUserId: uid);
return File(fo.OpenRead(), "application/pdf");
}
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: 0,
actorUserId: uid);
string msg = "TeX render failed: " + model.GenerationErrorMessage;
logger.LogError(msg);
return Problem(msg);
@ -152,6 +180,14 @@ namespace Yavsc.ApiControllers
}
catch (Exception ex)
{
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: renderedPdf?.Length ?? 0,
actorUserId: uid);
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
return Problem("TeX render failed: " + ex.Message);
}
@ -203,6 +239,7 @@ namespace Yavsc.ApiControllers
if (query is null) return BadRequest(new { Error = "query not found" });
Signature? signature = null;
ClientEstimateSignedEvent? signedEvent = null;
// actingRole is the role the caller is signing / accepting
// as. It defaults to the role inferred from the caller's
// identity, but when a signature is submitted the caller
@ -269,7 +306,11 @@ namespace Yavsc.ApiControllers
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
{
estimate.ClientValidationDate = DateTime.UtcNow;
var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token);
signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName);
}
}
// The party-specific status records who accepted: the
@ -293,6 +334,9 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (signedEvent is not null)
await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok(new
{
queryId,

View file

@ -10,6 +10,8 @@ using Yavsc.Models.Billing;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Helpers;
using Yavsc.Services;
namespace Yavsc.Controllers;
@ -19,10 +21,12 @@ namespace Yavsc.Controllers;
public class RdvQueryApiController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IYavscMessageSender _messageSender;
public RdvQueryApiController(ApplicationDbContext context)
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
{
_context = context;
_messageSender = messageSender;
}
[HttpGet]
@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller
return BadRequest(new { Error = "location is required" });
}
var performer = await _context.Performers
.AsNoTracking()
.SingleOrDefaultAsync(p => p.PerformerId == query.PerformerId, cancellationToken);
if (performer is null)
return BadRequest(new { Error = "performer not found" });
var client = await _context.Users.SingleOrDefaultAsync(u => u.Id == uid, cancellationToken);
if (client is null)
return Unauthorized();
query.Client = client;
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
if (resolvedLocation is null)
{
@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller
throw;
}
if (performer.AcceptNotifications && performer.AcceptPublicContact)
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
}

View file

@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Identity;
#nullable enable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -9,6 +11,10 @@ using Yavsc.Api.Helpers;
using Yavsc.Server.Helpers;
using System.Diagnostics;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Workflow;
using Yavsc.Interface;
using Yavsc.Services;
namespace Yavsc.WebApi.Controllers
{
@ -30,35 +36,100 @@ namespace Yavsc.WebApi.Controllers
readonly ApplicationDbContext _dbContext;
private readonly SiteSettings siteSettings;
private readonly ILogger _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITrueEmailSender _emailSender;
private readonly ICalendarManager _calendarManager;
public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions<SiteSettings> siteSettings)
IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> userManager,
ITrueEmailSender emailSender, ICalendarManager calendarManager)
{
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext;
this.siteSettings = siteSettings.Value;
_userManager = userManager;
_emailSender = emailSender;
_calendarManager = calendarManager;
}
[HttpGet("me")]
public async Task<IActionResult> Me()
{
if (User == null)
return new BadRequestObjectResult(
new { error = "user not found" });
if (User?.Identity?.IsAuthenticated != true)
{
return Challenge();
}
var uid = User.GetUserId();
Debug.Assert(uid != null, "uid is null");
var userData = await GetUserData(uid);
Debug.Assert(userData != null, "userData is null");
var user = new Yavsc.Models.Auth.Me(userData.Id, userData.UserName, userData.Email,
userData.Avatar,
userData.PostalAddress, userData.DedicatedGoogleCalendar);
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var userRoles = _dbContext.UserRoles.Where(u => u.UserId == uid).Select(r => r.RoleId).ToArray();
var userData = await _dbContext.Users
.Include(u => u.PostalAddress)
.Include(u => u.AccountBalance)
.Include(u => u.BankInfo)
.FirstOrDefaultAsync(u => u.Id == uid);
IdentityRole[] roles = _dbContext.Roles.Where(r => userRoles.Contains(r.Id)).ToArray();
if (userData is null)
{
return NotFound(new { error = "user not found" });
}
user.Roles = roles.Select(r => r.Name).ToArray();
var user = new Yavsc.Abstract.Identity.Me
{
UserId = userData.Id,
UserName = userData.UserName,
Email = userData.Email,
Avatar = userData.Avatar,
FullName = userData.FullName,
Address = userData.PostalAddress?.Address ?? string.Empty,
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar
};
user.BankInfoSummary = userData.BankInfo is { Count: > 0 }
? string.Join(" | ", userData.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
: string.Empty;
var userRoles = await _dbContext.UserRoles
.Where(u => u.UserId == uid)
.Select(r => r.RoleId)
.ToArrayAsync();
var roles = await _dbContext.Roles
.Where(r => userRoles.Contains(r.Id))
.ToArrayAsync();
user.Roles = roles.Select(r => r.Name).OfType<string>().ToArray();
user.EmailConfirmed = await _userManager.IsEmailConfirmedAsync(userData);
user.AllowMonthlyEmail = userData.AllowMonthlyEmail;
user.HasPassword = await _userManager.HasPasswordAsync(userData);
user.TwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(userData);
var externalLogins = await _userManager.GetLoginsAsync(userData);
user.ExternalLoginCount = externalLogins.Count;
user.ExternalLogins = externalLogins.Select(login => new LinkedExternalLogin
{
Provider = login.LoginProvider,
ProviderKey = login.ProviderKey,
DisplayName = login.ProviderDisplayName ?? login.LoginProvider
}).ToArray();
user.PostsCounter = await _dbContext.BlogSpot.LongCountAsync(x => x.AuthorId == uid);
user.DiskUsage = userData.DiskUsage;
user.DiskQuota = userData.DiskQuota;
user.Credits = userData.AccountBalance?.Credits ?? 0;
user.BankAccounts = (userData.BankInfo ?? [])
.Select(bank => new BankAccountInfo
{
Id = bank.Id,
IBAN = bank.IBAN,
BIC = bank.BIC,
BankCode = bank.BankCode,
WicketCode = bank.WicketCode,
AccountNumber = bank.AccountNumber,
BankedKey = bank.BankedKey
})
.ToArray();
return Ok(user);
}
@ -68,9 +139,571 @@ namespace Yavsc.WebApi.Controllers
return await _dbContext.Users
.Include(u => u.PostalAddress)
.Include(u => u.AccountBalance)
.Include(u => u.BankInfo)
.FirstAsync(u => u.Id == uid);
}
[HttpPut("me")]
public async Task<IActionResult> UpdateMe([FromBody] ProfileUpdateRequest request)
{
if (request is null)
{
return BadRequest(new { error = "Payload is required." });
}
if (request.BankInfoSummary is not null)
{
return BadRequest(new { error = "Bank information must be managed through the bank-info endpoint." });
}
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _dbContext.Users
.Include(u => u.PostalAddress)
.Include(u => u.BankInfo)
.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
return NotFound();
}
if (!string.IsNullOrWhiteSpace(request.UserName))
{
var trimmedUserName = request.UserName.Trim();
if (!string.Equals(user.UserName, trimmedUserName, StringComparison.Ordinal))
{
var usernameUpdate = await _userManager.SetUserNameAsync(user, trimmedUserName);
if (!usernameUpdate.Succeeded)
{
return BadRequest(new { error = "Unable to update username.", details = usernameUpdate.Errors.Select(e => e.Description) });
}
}
}
if (request.FullName is not null)
{
user.FullName = request.FullName.Trim();
}
if (request.Address is not null)
{
var trimmedAddress = request.Address.Trim();
if (string.IsNullOrWhiteSpace(trimmedAddress))
{
user.PostalAddress = null;
}
else if (user.PostalAddress is null)
{
user.PostalAddress = new Yavsc.Models.Relationship.Location { Address = trimmedAddress };
}
else
{
user.PostalAddress.Address = trimmedAddress;
}
}
if (request.GoogleCalendarId is not null)
{
user.DedicatedGoogleCalendar = request.GoogleCalendarId.Trim();
}
await _dbContext.SaveChangesAsync(uid);
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar });
}
[HttpPut("preferences/monthly-email")]
public async Task<IActionResult> UpdateMonthlyEmailPreference([FromBody] MonthlyEmailPreferenceRequest request)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
user.AllowMonthlyEmail = request.Enabled;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(new { error = "Unable to update email preferences.", details = result.Errors.Select(e => e.Description) });
}
return Ok(new { enabled = user.AllowMonthlyEmail });
}
[HttpPut("security/two-factor")]
public async Task<IActionResult> UpdateTwoFactor([FromBody] TwoFactorRequest request)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
var result = await _userManager.SetTwoFactorEnabledAsync(user, request.Enabled);
if (!result.Succeeded)
{
return BadRequest(new { error = "Unable to update two-factor authentication.", details = result.Errors.Select(e => e.Description) });
}
return Ok(new { enabled = user.TwoFactorEnabled });
}
[HttpPut("security/password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
if (request is null
|| string.IsNullOrWhiteSpace(request.NewPassword)
|| !string.Equals(request.NewPassword, request.ConfirmPassword, StringComparison.Ordinal))
{
return BadRequest(new { error = "A matching new password and confirmation are required." });
}
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
var result = await _userManager.HasPasswordAsync(user)
? await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword)
: await _userManager.AddPasswordAsync(user, request.NewPassword);
if (!result.Succeeded)
{
return BadRequest(new { error = "Unable to change the password.", details = result.Errors.Select(e => e.Description) });
}
return NoContent();
}
[HttpDelete("security/external-logins")]
public async Task<IActionResult> RemoveExternalLogin([FromQuery] string provider, [FromQuery] string providerKey)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(providerKey))
{
return BadRequest(new { error = "A provider and provider key are required." });
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
if (await _userManager.GetLoginsAsync(user) is { Count: <= 1 }
&& !await _userManager.HasPasswordAsync(user))
{
return Conflict(new { error = "Add a password before removing the only sign-in method." });
}
var result = await _userManager.RemoveLoginAsync(user, provider, providerKey);
if (!result.Succeeded)
{
return BadRequest(new { error = "Unable to remove the external login.", details = result.Errors.Select(e => e.Description) });
}
return NoContent();
}
[HttpDelete("me")]
public async Task<IActionResult> DeleteMyAccount([FromBody] DeleteAccountRequest request)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
if (!string.Equals(request?.UsernameConfirmation, user.UserName, StringComparison.Ordinal))
{
return BadRequest(new { error = "Type your exact username to confirm account deletion." });
}
_dbContext.DeviceDeclaration.RemoveRange(
_dbContext.DeviceDeclaration.Where(device => device.DeviceOwnerId == uid));
var result = await _userManager.DeleteAsync(user);
if (!result.Succeeded)
{
return BadRequest(new { error = "Unable to delete this account.", details = result.Errors.Select(e => e.Description) });
}
return NoContent();
}
[HttpPost("email/confirmation")]
public async Task<IActionResult> SendEmailConfirmation()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _userManager.FindByIdAsync(uid);
if (user is null)
{
return NotFound();
}
if (user.EmailConfirmed)
{
return Conflict(new { error = "This email address is already confirmed." });
}
if (string.IsNullOrWhiteSpace(user.Email))
{
return BadRequest(new { error = "No email address is associated with this account." });
}
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var confirmationUrl = new Uri(
new Uri(siteSettings.ExternalUrl.TrimEnd('/') + "/", UriKind.Absolute),
"Account/ConfirmEmail").ToString();
confirmationUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(
confirmationUrl,
new Dictionary<string, string?> { ["userId"] = user.Id, ["code"] = code });
await _emailSender.SendEmailAsync(
user.UserName ?? user.Email,
user.Email,
$"[{siteSettings.Title}] Confirmation de votre adresse e-mail",
$"<p>Confirmez votre adresse e-mail en suivant ce lien :</p><p><a href=\"{System.Text.Encodings.Web.HtmlEncoder.Default.Encode(confirmationUrl)}\">Confirmer mon adresse</a></p>");
return Ok(new { message = "Un e-mail de confirmation a été envoyé." });
}
[HttpGet("calendars")]
public async Task<IActionResult> GetCalendars([FromQuery] string? pageToken = null)
{
var calendars = await _calendarManager.GetCalendarsAsync(pageToken ?? string.Empty);
var options = (calendars.Items ?? [])
.Where(calendar => string.Equals(calendar.AccessRole, "owner", StringComparison.OrdinalIgnoreCase))
.Select(calendar => new CalendarOption
{
Id = calendar.Id,
Name = calendar.Summary ?? calendar.Id
})
.ToArray();
return Ok(options);
}
[HttpGet("bank-info")]
public async Task<IActionResult> GetBankInfo()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var accounts = await _dbContext.BankIdentity
.AsNoTracking()
.Where(bank => bank.UserId == uid)
.Select(bank => new BankAccountInfo
{
Id = bank.Id,
IBAN = bank.IBAN,
BIC = bank.BIC,
BankCode = bank.BankCode,
WicketCode = bank.WicketCode,
AccountNumber = bank.AccountNumber,
BankedKey = bank.BankedKey
})
.ToArrayAsync();
return Ok(accounts);
}
[HttpPost("bank-info")]
public async Task<IActionResult> AddBankInfo([FromBody] BankAccountInfoRequest request)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
if (!IsValidBankAccountRequest(request))
{
return BadRequest(new { error = "Provide an IBAN or BIC and ensure each bank field fits its allowed length." });
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
return NotFound();
}
var bank = new Yavsc.Models.Bank.BankIdentity
{
UserId = uid,
User = user,
IBAN = request.IBAN?.Trim() ?? string.Empty,
BIC = request.BIC?.Trim() ?? string.Empty,
BankCode = request.BankCode?.Trim() ?? string.Empty,
WicketCode = request.WicketCode?.Trim() ?? string.Empty,
AccountNumber = request.AccountNumber?.Trim() ?? string.Empty,
BankedKey = request.BankedKey
};
_dbContext.BankIdentity.Add(bank);
await _dbContext.SaveChangesAsync(uid);
return Ok(new BankAccountInfo
{
Id = bank.Id,
IBAN = bank.IBAN,
BIC = bank.BIC,
BankCode = bank.BankCode,
WicketCode = bank.WicketCode,
AccountNumber = bank.AccountNumber,
BankedKey = bank.BankedKey
});
}
[HttpPut("bank-info/{id:long}")]
public async Task<IActionResult> UpdateBankInfo(long id, [FromBody] BankAccountInfoRequest request)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
if (!IsValidBankAccountRequest(request))
{
return BadRequest(new { error = "Provide an IBAN or BIC and ensure each bank field fits its allowed length." });
}
var bank = await _dbContext.BankIdentity
.FirstOrDefaultAsync(item => item.Id == id && item.UserId == uid);
if (bank is null)
{
return NotFound();
}
bank.IBAN = request.IBAN?.Trim() ?? string.Empty;
bank.BIC = request.BIC?.Trim() ?? string.Empty;
bank.BankCode = request.BankCode?.Trim() ?? string.Empty;
bank.WicketCode = request.WicketCode?.Trim() ?? string.Empty;
bank.AccountNumber = request.AccountNumber?.Trim() ?? string.Empty;
bank.BankedKey = request.BankedKey;
await _dbContext.SaveChangesAsync(uid);
return NoContent();
}
private static bool IsValidBankAccountRequest(BankAccountInfoRequest? request) =>
request is not null
&& (!string.IsNullOrWhiteSpace(request.IBAN) || !string.IsNullOrWhiteSpace(request.BIC))
&& (request.IBAN?.Length ?? 0) <= 33
&& (request.BIC?.Length ?? 0) <= 15
&& (request.BankCode?.Length ?? 0) <= 5
&& (request.WicketCode?.Length ?? 0) <= 5
&& (request.AccountNumber?.Length ?? 0) <= 15
&& request.BankedKey >= 0;
[HttpDelete("bank-info/{id:long}")]
public async Task<IActionResult> DeleteBankInfo(long id)
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var bank = await _dbContext.BankIdentity
.FirstOrDefaultAsync(item => item.Id == id && item.UserId == uid);
if (bank is null)
{
return NotFound();
}
_dbContext.BankIdentity.Remove(bank);
await _dbContext.SaveChangesAsync(uid);
return NoContent();
}
[HttpGet("performer-profile")]
public async Task<IActionResult> GetPerformerProfile()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var profile = await _dbContext.Performers
.AsNoTracking()
.FirstOrDefaultAsync(p => p.PerformerId == uid);
var selectedActivityCodes = await _dbContext.UserActivities
.AsNoTracking()
.Where(x => x.UserId == uid)
.Select(x => x.DoesCode)
.ToListAsync();
return Ok(new PerformerProfileSettings
{
PerformerId = uid,
UserName = User.Identity?.Name ?? string.Empty,
ExerciseCountryCode = profile?.ExerciseCountryCode ?? "fr",
SIREN = profile?.SIREN ?? string.Empty,
WebSite = profile?.WebSite ?? string.Empty,
Active = profile?.Active ?? false,
AcceptNotifications = profile?.AcceptNotifications ?? false,
AcceptPublicContact = profile?.AcceptPublicContact ?? false,
UseGeoLocalizationToReduceDistanceWithClients = profile?.UseGeoLocalizationToReduceDistanceWithClients ?? false,
SelectedActivityCodes = selectedActivityCodes
});
}
[HttpPut("performer-profile")]
public async Task<IActionResult> SavePerformerProfile([FromBody] PerformerProfileSettings model)
{
if (model is null)
{
return BadRequest(new { error = "Payload is required." });
}
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
if (string.IsNullOrWhiteSpace(model.PerformerId))
{
model.PerformerId = uid;
}
if (!string.Equals(model.PerformerId, uid, StringComparison.Ordinal))
{
return Forbid();
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(model.ExerciseCountryCode))
{
model.ExerciseCountryCode = "fr";
}
var profile = await _dbContext.Performers.FirstOrDefaultAsync(p => p.PerformerId == uid);
if (profile is null)
{
profile = new Yavsc.Models.Workflow.PerformerProfile
{
PerformerId = uid,
Performer = user,
ExerciseCountryCode = model.ExerciseCountryCode,
SIREN = model.SIREN ?? string.Empty,
WebSite = model.WebSite ?? string.Empty,
Active = model.Active,
AcceptNotifications = model.AcceptNotifications,
AcceptPublicContact = model.AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
OrganizationAddress = new Yavsc.Models.Relationship.Location()
};
_dbContext.Performers.Add(profile);
}
else
{
profile.ExerciseCountryCode = model.ExerciseCountryCode;
profile.SIREN = model.SIREN ?? string.Empty;
profile.WebSite = model.WebSite ?? string.Empty;
profile.Active = model.Active;
profile.AcceptNotifications = model.AcceptNotifications;
profile.AcceptPublicContact = model.AcceptPublicContact;
profile.UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients;
}
var desiredCodes = (model.SelectedActivityCodes ?? [])
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existingLinks = await _dbContext.UserActivities
.Where(x => x.UserId == uid)
.ToListAsync();
foreach (var link in existingLinks)
{
if (!desiredCodes.Contains(link.DoesCode, StringComparer.OrdinalIgnoreCase))
{
_dbContext.UserActivities.Remove(link);
}
}
foreach (var code in desiredCodes)
{
if (existingLinks.Any(x => string.Equals(x.DoesCode, code, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var activity = await _dbContext.Activities
.FirstOrDefaultAsync(a => a.Code == code);
if (activity is not null)
{
_dbContext.UserActivities.Add(new Yavsc.Models.Workflow.UserActivity
{
UserId = uid,
DoesCode = activity.Code,
Weight = 100
});
}
}
await _dbContext.SaveChangesAsync(uid);
if (!User.IsInRole("Performer"))
{
var roleResult = await _userManager.AddToRoleAsync(user, "Performer");
if (!roleResult.Succeeded)
{
return BadRequest(new { error = "Performer role could not be assigned.", details = roleResult.Errors.Select(e => e.Description) });
}
}
return Ok(new { status = "saved", performer = model });
}
[HttpGet("myhost")]
public IActionResult MyHost ()
{

View file

@ -22,22 +22,49 @@ namespace Yavsc.Controllers
[HttpGet]
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
{
return _context.Users.Skip(skip).Take(take)
.Select(u=> new UserInfo{
UserId = u.Id,
UserName = u.UserName,
Avatar = u.Avatar});
return _context.Users
.Include(u => u.PostalAddress)
.Include(u => u.BankInfo)
.Skip(skip).Take(take)
.Select(u => new UserInfo
{
UserId = u.Id,
UserName = u.UserName,
FullName = u.FullName,
Email = u.Email,
Address = u.PostalAddress != null ? u.PostalAddress.Address : string.Empty,
Avatar = u.Avatar,
DedicatedGoogleCalendar = u.DedicatedGoogleCalendar,
BankInfoSummary = u.BankInfo != null && u.BankInfo.Count > 0
? string.Join(" | ", u.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
: string.Empty
});
}
[HttpGet("search/{pattern}")]
public IEnumerable<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
{
return _context.Users.Where(u => u.UserName.Contains(pattern))
.Skip(skip).Take(take)
.Select(u=> new UserInfo {
UserId = u.Id,
UserName = u.UserName,
Avatar = u.Avatar });
var normalizedPattern = pattern ?? string.Empty;
return _context.Users
.Include(u => u.PostalAddress)
.Include(u => u.BankInfo)
.Where(u => u.UserName.Contains(normalizedPattern)
|| (u.FullName != null && u.FullName.Contains(normalizedPattern))
|| (u.Email != null && u.Email.Contains(normalizedPattern)))
.Skip(skip).Take(take)
.Select(u => new UserInfo
{
UserId = u.Id,
UserName = u.UserName,
FullName = u.FullName,
Email = u.Email,
Address = u.PostalAddress != null ? u.PostalAddress.Address : string.Empty,
Avatar = u.Avatar,
DedicatedGoogleCalendar = u.DedicatedGoogleCalendar,
BankInfoSummary = u.BankInfo != null && u.BankInfo.Count > 0
? string.Join(" | ", u.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
: string.Empty
});
}
// GET: api/ApplicationUserApi/5

View file

@ -1,6 +1,7 @@
using Anthropic.SDK;
using IdentityModel;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Yavsc.Abstract.Interfaces;
@ -54,6 +55,13 @@ internal class Program
.RequireAuthenticatedUser()
.RequireClaim(JwtClaimTypes.Scope, new string[] { "api" });
});
options.AddPolicy("AdministratorOnly", policy =>
{
policy
.RequireAuthenticatedUser()
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
});
})
.AddYavscCors(builder.Configuration)
.AddControllersWithViews();
@ -79,6 +87,16 @@ internal class Program
options.UseNpgsql(builder.Configuration.GetConnectionString(
Yavsc.Constants.YavscConnectionStringName)));
services.AddIdentityCore<ApplicationUser>(options =>
{
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.PreferredUserName;
options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddSignInManager();
services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
@ -120,6 +138,7 @@ internal class Program
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default");
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute();

View file

@ -1,8 +0,0 @@
namespace Yavsc.Blogs;
public static class BlogConstants
{
public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator";
public const string UserRole = "User";
}

View file

@ -99,7 +99,7 @@ namespace Yavsc.Blogs.Controllers
}
[HttpPost("{*subdir}")]
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
public async Task<IActionResult> Post([ValidRemoteUserFilePath] string subdir="")
{
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
string destDir = null;
@ -159,6 +159,14 @@ namespace Yavsc.Blogs.Controllers
item.Length = f.Length;
item.ContentType = f.ContentType;
}
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
user.UserName,
storageBytes: item.Length,
actorUserId: uid);
received.Add(item);
_logger.LogInformation($"Received '{item.FileName}'.");
if (item.QuotaOffense)
@ -192,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });

View file

@ -9,7 +9,6 @@ using Yavsc.Models;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Yavsc.Blogs;
internal class Program
@ -83,11 +82,11 @@ internal class Program
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
})
.Configure<SiteSettings>(settings =>
.Configure<SiteSettings>(settings =>
{
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
settings.Blog = "blog";
settings.UserFilesRoot = "blog";
}
});
@ -102,6 +101,7 @@ internal class Program
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default")
;
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")

View file

@ -9,7 +9,7 @@
"https://localhost:5005",
"https://api.pschneider.fr"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -421,9 +421,9 @@ namespace Yavsc.Controllers
{
// Renames the blog files
var userdirinfo = new DirectoryInfo(
Path.Combine(_siteSettings.Blog,
Path.Combine(_siteSettings.UserFilesRoot,
oldUserName));
var newdir = Path.Combine(_siteSettings.Blog,
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
model.UserName);
if (userdirinfo.Exists)
userdirinfo.MoveTo(newdir);

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Identity;
using Yavsc.Models;
using Yavsc.Server.Helpers;
@ -19,14 +20,20 @@ namespace Yavsc.Controllers
private readonly RoleManager<IdentityRole> _roleManager;
private readonly ApplicationDbContext _dbContext;
private readonly SiteSettings _siteSettings;
private readonly ILogger<AdministrationController> _logger;
public AdministrationController(UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
ApplicationDbContext context)
ApplicationDbContext context,
IOptions<SiteSettings> siteSettings,
ILoggerFactory loggerFactory)
{
_userManager = userManager;
_roleManager = roleManager;
this._dbContext = context;
this._siteSettings = siteSettings.Value;
this._logger = loggerFactory.CreateLogger<AdministrationController>();
}
@ -92,6 +99,16 @@ namespace Yavsc.Controllers
}
var user = await _userManager.FindByIdAsync(User.GetUserId());
var email = user.Email;
var isOk = email == _siteSettings.Owner?.EMail || _siteSettings.Admin?.EMail == email;
if (!isOk)
{
if (_siteSettings.Owner==null && _siteSettings.Admin==null)
_logger.LogError("No owner or admin is set in the site settings.");
return Forbid();
}
// check all user groups exist
if (!await EnsureRoleList())
{

View file

@ -1046,8 +1046,8 @@ public static class HostingExtensions
app.UseSwagger();
app.UseSwaggerUI();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
app.UseCors("default");
@ -1266,7 +1266,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
{
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
}
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
}
@ -1283,7 +1283,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
}
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();

View file

@ -11,28 +11,6 @@ namespace Yavsc.Helpers
public static class EventHelpers
{
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
{
var yaev = new RdvQueryEvent(subtopic)
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
ActivityCode = query.ActivityCode,
BillingCode = BillingCodes.Rdv
};
return yaev;
}
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
IStringLocalizer<HairCutQuery> SR)
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class ResourceUsageRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ResourceUsageRecords",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
RecordedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ApiCalls = table.Column<decimal>(type: "numeric", nullable: false),
CpuSeconds = table.Column<decimal>(type: "numeric", nullable: false),
BandwidthMb = table.Column<decimal>(type: "numeric", nullable: false),
StorageMb = table.Column<decimal>(type: "numeric", nullable: false),
EstimatedCompensation = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false, defaultValue: "EUR"),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserCreated = table.Column<string>(type: "text", nullable: true),
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserModified = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ResourceUsageRecords", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ResourceUsageRecords_UserId_RecordedAtUtc",
table: "ResourceUsageRecords",
columns: new[] { "UserId", "RecordedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ResourceUsageRecords");
}
}
}

View file

@ -2821,6 +2821,68 @@ namespace Yavsc.Migrations
b.ToTable("Tags");
});
modelBuilder.Entity("Yavsc.Models.ResourceUsageRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("ApiCalls")
.HasColumnType("numeric");
b.Property<decimal>("BandwidthMb")
.HasColumnType("numeric");
b.Property<decimal>("CpuSeconds")
.HasColumnType("numeric");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasDefaultValue("EUR");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("EstimatedCompensation")
.HasColumnType("numeric");
b.Property<DateTime>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("StorageMb")
.HasColumnType("numeric");
b.Property<string>("UserCreated")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserModified")
.HasColumnType("text");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("UserId", "RecordedAtUtc");
b.ToTable("ResourceUsageRecords");
});
modelBuilder.Entity("Yavsc.Models.Skill", b =>
{
b.Property<long>("Id")

View file

@ -23,10 +23,10 @@ public class Startup
ResourcesHelpers.GlobalLocalizer = localizer;
Config.SmtpSetup = smtpSettings.Value;
Config.Authority = siteSettings.Value.Authority;
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
Config.PayPalSettings = payPalSettings.Value;
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

View file

@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
{
string uid = ViewContext.HttpContext.User.GetUserId();
return View(new UserDirectoryInfo(
siteSettings.Blog,
siteSettings.UserFilesRoot,
uid, dirname));
}
}

View file

@ -3,7 +3,7 @@
background-position: 0%;
background-attachment: fixed;">
<a class="navbar-brand logo" href="/">
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-left:8px; display:inline-block; vertical-align:middle;" />
@SiteSettings.Value.Title
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"

View file

@ -31,7 +31,7 @@
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"UserFilesRoot": "files",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"

View file

@ -1,4 +1 @@
.user-icon {
max-width: 1.5em;
max-height: 1.5em;
}
.user-icon{max-width:1.5em;max-height:1.5em}.card{padding:.5em}

View file

@ -1,4 +1,7 @@
user-icon {
.user-icon {
max-width: 1.5em;
max-height: 1.5em;
}
.card {
padding: .5em;
}

View file

@ -14229,10 +14229,6 @@ body {
--bs-secondary-rgb: transparent
}
.card a {
color: #fff
}
.card .text-muted {
color: rgba(255, 255, 255, .7) !important
}

File diff suppressed because one or more lines are too long

View file

@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
@ -80,7 +80,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(dirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
@ -110,7 +110,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(fromDirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
@ -136,7 +136,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
string dest;
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
var fo = new FileInfo(Path.Combine(root, fileNameDest));
@ -318,7 +318,7 @@ namespace Yavsc.Server.Helpers
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
{
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
return di;
}

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