Compare commits
110 changed files with 514 additions and 10619 deletions
|
|
@ -50,6 +50,10 @@ 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
3
.github/copilot-instructions.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
# 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.
|
||||
21
.github/workflows/docker-publish-backend.yml
vendored
21
.github/workflows/docker-publish-backend.yml
vendored
|
|
@ -8,7 +8,8 @@ on:
|
|||
|
||||
jobs:
|
||||
production-deploy:
|
||||
runs-on: ubuntu-26.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 1. Récupération du code
|
||||
- name: Check out the repo
|
||||
|
|
@ -24,24 +25,8 @@ 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
|
||||
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
|
||||
|
||||
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
|
||||
# 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
1
.gitignore
vendored
|
|
@ -48,4 +48,3 @@ DataDir/
|
|||
|
||||
# Exclude test reports (local test report, not for the repo)
|
||||
/test-reports
|
||||
/doc/**/.*.md
|
||||
|
|
|
|||
144
CHANGELOG.md
144
CHANGELOG.md
|
|
@ -1,149 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## [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,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,36 @@
|
|||
# Utilisation de l'image globale pour le build et l'exécution
|
||||
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
|
||||
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 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
|
||||
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -5,6 +5,8 @@ DESTDIR=/tmp/yavsc
|
|||
APP_PATH=srv/www/yavsc
|
||||
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
|
||||
|
||||
include .env
|
||||
|
||||
all:
|
||||
dotnet build --nologo
|
||||
|
||||
|
|
|
|||
|
|
@ -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-auteurs
|
||||
# 👥 Crédits & Co-autoraClaudt
|
||||
|
||||
Ce projet a été développé de manière hybride :
|
||||
- **Conception, architecture et revue de code :** Paul Schneider
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"CorsAllowedOrigins": [
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"UserFilesRoot": "files"
|
||||
"Blog": "blog"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Quota": 200000000,
|
||||
"UserFilesRoot": "files",
|
||||
"Blog": "blog",
|
||||
"Avatars": "avatars",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
|
|
|
|||
|
|
@ -126,20 +126,6 @@ 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"`
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<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.6-47" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
|
|
|||
|
|
@ -73,14 +73,6 @@ 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)!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,8 @@ namespace PostIt.Tests;
|
|||
/// </summary>
|
||||
public class BlogAttachmentLinkTests
|
||||
{
|
||||
[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)
|
||||
[Fact]
|
||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
|
||||
{
|
||||
const string authority = "https://idp.example.test";
|
||||
|
||||
|
|
@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests
|
|||
vm.DraftTitle = "Après";
|
||||
vm.DraftArticle = "Contenu modifié.";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile(
|
||||
originalName,
|
||||
"note.txt",
|
||||
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();
|
||||
|
||||
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
||||
// 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);
|
||||
Assert.NotEqual(default, linkPut);
|
||||
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
|
||||
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
||||
|
||||
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
|
||||
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
||||
var sentStringContent = multipartContent.First() as StringContent ;
|
||||
|
||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var document = System.Text.Json.JsonDocument.Parse(sentString);
|
||||
var article = document.RootElement.GetProperty("article").GetString();
|
||||
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
|
||||
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
|
||||
// The regression shape: a relative, authority-less link.
|
||||
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);
|
||||
Assert.DoesNotContain("](/files/", sentString);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using Yavsc.Abstract.Identity;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
|
@ -13,170 +12,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
|
||||
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)
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,11 +21,9 @@ 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)
|
||||
{
|
||||
|
|
@ -102,7 +100,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", "refresh_token" },
|
||||
["grant_types_supported"] = new[] { "authorization_code" },
|
||||
["code_challenge_methods_supported"] = new[] { "S256" },
|
||||
};
|
||||
|
||||
|
|
@ -134,16 +132,6 @@ 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();
|
||||
|
|
@ -154,13 +142,11 @@ 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,
|
||||
|
|
@ -207,10 +193,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
|||
return dict;
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200)
|
||||
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
|
||||
{
|
||||
response.ContentType = "application/json";
|
||||
response.StatusCode = statusCode;
|
||||
response.StatusCode = 200;
|
||||
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||
await response.OutputStream.WriteAsync(bytes);
|
||||
response.Close();
|
||||
|
|
|
|||
|
|
@ -1,132 +1,12 @@
|
|||
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()
|
||||
|
|
@ -181,22 +61,11 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
|
@ -22,36 +23,41 @@ namespace PostIt.Tests;
|
|||
/// <c>new Window().Show()</c> throws because the harness has not
|
||||
/// been initialised for that thread.</para>
|
||||
///
|
||||
/// <para>These tests set the DataContext directly on the banner
|
||||
/// mounted in the window. The production code path is exercised
|
||||
/// <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
|
||||
/// end-to-end by the manual launch, not here.</para>
|
||||
/// </summary>
|
||||
public class SessionStatusBannerTests : IDisposable
|
||||
public class SessionStatusBannerTests
|
||||
{
|
||||
private MainWindow? _window;
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_renders_four_buttons_in_the_visual_tree()
|
||||
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
|
||||
var buttons = banner.GetVisualDescendants()
|
||||
var buttons = window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(4, buttons.Count);
|
||||
// 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.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()
|
||||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
|
||||
var login = banner.GetVisualDescendants()
|
||||
var login = window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se connecter");
|
||||
|
||||
|
|
@ -63,9 +69,12 @@ public class SessionStatusBannerTests : IDisposable
|
|||
[AvaloniaFact]
|
||||
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||
{
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
SessionStatusBanner banner = CreateBanner();
|
||||
|
||||
Assert.False((banner.DataContext as SessionStatusViewModel)!
|
||||
.IsLoggedIn); // VM default
|
||||
|
||||
|
||||
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
|
||||
var logout = banner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se déconnecter");
|
||||
|
|
@ -82,23 +91,22 @@ public class SessionStatusBannerTests : IDisposable
|
|||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Paramètres");
|
||||
|
||||
// Paramètres has no IsVisible binding and is always shown.
|
||||
// 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.
|
||||
Assert.True(settings.IsVisible);
|
||||
}
|
||||
|
||||
private SessionStatusBanner CreateBanner()
|
||||
private static SessionStatusBanner CreateBanner()
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Show();
|
||||
var banner = _window.GetVisualDescendants()
|
||||
.OfType<SessionStatusBanner>()
|
||||
.Single();
|
||||
banner.DataContext = new SessionStatusViewModel();
|
||||
MainWindow window = new MainWindow();
|
||||
window.Show();
|
||||
var banner = new SessionStatusBanner();
|
||||
var status = new SessionStatusViewModel();
|
||||
banner.DataContext = status;
|
||||
return banner;
|
||||
}
|
||||
|
||||
public void Dispose() => _window?.Close();
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_session_label_reflects_DataContext()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,56 +17,6 @@ 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);
|
||||
|
|
@ -420,105 +370,20 @@ public class YavscApiClientTests
|
|||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
// 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() { ... }
|
||||
|
||||
private static T Last<T>(System.Collections.Generic.List<T> list)
|
||||
{
|
||||
|
|
@ -602,7 +467,6 @@ 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)
|
||||
{
|
||||
|
|
@ -635,8 +499,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
|
|||
if (!string.IsNullOrEmpty(auth))
|
||||
SeenBearers.Add(auth!);
|
||||
|
||||
if ((_forceFirstRequest && _requestCount == 1)
|
||||
|| (RejectedBearer is not null && auth == RejectedBearer))
|
||||
if (_forceFirstRequest && _requestCount == 1)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
ctx.Response.Close();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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;
|
||||
|
|
@ -44,6 +43,10 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
#if DEBUG
|
||||
this.AttachDeveloperTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
|
|
@ -197,8 +200,7 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
|
||||
public void ConfigureRootView(MainView rootView)
|
||||
{
|
||||
rootView.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
|
||||
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
|
||||
|
||||
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.LogoutCompleted += () =>
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +35,6 @@ 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();
|
||||
|
||||
|
|
@ -77,10 +75,6 @@ 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);
|
||||
|
|
@ -93,16 +87,11 @@ 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>();
|
||||
|
|
|
|||
|
|
@ -39,16 +39,6 @@ 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)
|
||||
{
|
||||
|
|
@ -324,7 +314,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, req.Headers.Authorization?.Parameter).ConfigureAwait(false);
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var retry = new HttpRequestMessage(method, path);
|
||||
if (contentFactory is not null)
|
||||
|
|
@ -389,46 +379,13 @@ 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
|
||||
{
|
||||
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))
|
||||
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
||||
return;
|
||||
|
||||
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);
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -436,6 +393,30 @@ 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
|
||||
|
|
@ -478,9 +459,8 @@ 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, rejectedAccessToken).ConfigureAwait(false);
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
response = await attemptUpload().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -534,9 +514,8 @@ 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, rejectedAccessToken).ConfigureAwait(false);
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -624,8 +603,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_owner._tokens is not null
|
||||
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
|
||||
if (_owner._tokens is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer", _owner._tokens.AccessToken);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ 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}" }
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
|
|
@ -11,8 +10,9 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Files;
|
||||
using Yavsc.Server.Helpers;
|
||||
using PostIt.Helpers;
|
||||
using AvaloniaEdit.Document;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -39,66 +39,18 @@ 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 ?? null;
|
||||
set
|
||||
{
|
||||
var currentText = DraftArticleDocument?.Text;
|
||||
if (string.Equals(currentText, value, StringComparison.Ordinal))
|
||||
{
|
||||
OnPropertyChanged(nameof(DraftArticle));
|
||||
return;
|
||||
}
|
||||
|
||||
get => DraftArticleDocument.Text;
|
||||
set {
|
||||
OnPropertyChanging(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));
|
||||
DraftArticleDocument = new TextDocument(value);
|
||||
this.OnPropertyChanged(nameof(DraftArticle));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial TextDocument DraftArticleDocument {get; set;}
|
||||
|
||||
/// <summary>Editor buffer for the post's publication state.
|
||||
/// Reflects the server-side <c>IsPublished</c> flag (the
|
||||
|
|
@ -111,14 +63,6 @@ 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; }
|
||||
|
||||
|
|
@ -168,7 +112,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
});
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
[RelayCommand]
|
||||
internal async Task SaveAsync()
|
||||
{
|
||||
// The button is already disabled when the title is empty
|
||||
|
|
@ -186,6 +130,16 @@ 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
|
||||
|
|
@ -196,22 +150,12 @@ 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)
|
||||
{
|
||||
_preserveDraft = true;
|
||||
try
|
||||
{
|
||||
SelectedPost = created;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_preserveDraft = false;
|
||||
}
|
||||
SelectedPost = created;
|
||||
|
||||
if (ResolvePendingAttachmentLinks(created))
|
||||
if (TryAppendAttachmentLinks(created, attachments))
|
||||
{
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
|
|
@ -223,22 +167,16 @@ 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,
|
||||
|
|
@ -250,11 +188,8 @@ 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();
|
||||
|
|
@ -450,7 +385,6 @@ 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.");
|
||||
|
|
@ -462,7 +396,6 @@ 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
|
||||
|
|
@ -523,7 +456,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||
{
|
||||
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
|
||||
|
|
@ -537,8 +469,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
// null selection so a fresh draft starts unpublished.
|
||||
DraftIsPublished = value?.IsPublished ?? false;
|
||||
DraftAttachments.Clear();
|
||||
_pendingAttachmentLinks.Clear();
|
||||
MarkDraftSaved();
|
||||
UpdateCommandStates();
|
||||
}
|
||||
|
||||
|
|
@ -547,28 +477,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
// 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();
|
||||
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();
|
||||
}
|
||||
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||
|
||||
|
||||
private async Task RefreshPostsAsync()
|
||||
|
|
@ -581,6 +490,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
ApplyFilter();
|
||||
|
||||
if (SelectedPost is not null)
|
||||
{
|
||||
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
|
|
@ -595,29 +508,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.OrderByDescending(p => p.DateModified);
|
||||
|
||||
var selection = SelectedPost;
|
||||
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray();
|
||||
_preserveDraft = true;
|
||||
try
|
||||
FilteredPosts.Clear();
|
||||
foreach (var post in filtered)
|
||||
{
|
||||
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;
|
||||
FilteredPosts.Add(post);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -656,53 +550,40 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
|
||||
{
|
||||
if (e.NewItems is not null)
|
||||
{
|
||||
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 (attachments.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 ownerSegment = post.Author?.UserName;
|
||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||
ownerSegment = post.AuthorId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||
return false;
|
||||
|
||||
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;
|
||||
var links = new List<string>();
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
if (links.Count == 0)
|
||||
return false;
|
||||
|
||||
var prefix = article.Length == 0
|
||||
? ""
|
||||
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||
|
||||
DraftArticle = article + prefix + string.Join("\n", links);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string ResolveUserFileUrl(string relativePath)
|
||||
|
|
@ -711,7 +592,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
if (!string.IsNullOrWhiteSpace(authority)
|
||||
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri;
|
||||
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
|
||||
}
|
||||
|
||||
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat
|
|||
});
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_connection.Reconnected += async _ =>
|
||||
_connection.Reconnected += _ =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
|
|
@ -254,11 +254,7 @@ namespace PostIt.ViewModels.Chat
|
|||
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
||||
});
|
||||
// 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();
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_connection.Closed += error =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
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
|
||||
|
|
@ -18,26 +13,6 @@ 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
|
||||
|
|
@ -49,16 +24,11 @@ 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,
|
||||
NotificationsApiClient notificationsClient)
|
||||
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
|
||||
{
|
||||
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);
|
||||
|
|
@ -66,78 +36,17 @@ 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()
|
||||
{
|
||||
|
|
@ -229,79 +138,6 @@ 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
|
||||
|
|
@ -311,7 +147,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(), null!)
|
||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,18 +17,10 @@
|
|||
<Design.DataContext>
|
||||
<vm:BlogsViewModel />
|
||||
</Design.DataContext>
|
||||
<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,*"
|
||||
Margin="5,5,10,5"
|
||||
HorizontalAlignment="Stretch"
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
|
||||
Margin="5,5,10,5" HorizontalAlignment="Stretch" BringIntoViewOnFocusChange="False"
|
||||
VerticalAlignment="Stretch">
|
||||
<StackPanel>
|
||||
|
||||
<WrapPanel x:Name="BlogsHeaderPanel"
|
||||
Margin="5"
|
||||
|
|
@ -43,7 +35,6 @@
|
|||
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">
|
||||
|
|
@ -53,9 +44,7 @@
|
|||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
x:Name="SaveButton"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes.modified="{Binding IsDraftModified}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Command="{Binding SaveAsync}"
|
||||
Label="Save">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||
|
|
@ -69,7 +58,6 @@
|
|||
<CommandBarButton
|
||||
x:Name="AddAttachmentButton"
|
||||
Click="AddAttachment_Click"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Ajouter fichiers">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource AddIcon}" />
|
||||
|
|
@ -99,7 +87,7 @@
|
|||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Markdown Viewer">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
|
|
@ -115,45 +103,26 @@
|
|||
</CommandBar.PrimaryCommands>
|
||||
</CommandBar>
|
||||
|
||||
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="4" />
|
||||
<RowDefinition Height="2*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Grid.Row="1"
|
||||
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">
|
||||
<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 FontWeight="SemiBold" Text="{Binding Title}" />
|
||||
<TextBlock
|
||||
FontSize="10"
|
||||
Foreground="Gray"
|
||||
|
|
@ -168,67 +137,91 @@
|
|||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<GridSplitter
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ResizeDirection="Rows" />
|
||||
|
||||
<Grid
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RowSpacing="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<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"
|
||||
Padding="8"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
|
||||
MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
|
||||
Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
|
||||
BorderBrush="Gray"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding IsMarkdownViewerVisible}">
|
||||
<md:MarkdownViewer Name="MarkdownViewer" MarkdownText="{Binding DraftArticle}" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="AttachmentsBorder"
|
||||
Grid.Row="3"
|
||||
Padding="8"
|
||||
BorderBrush="LightGray"
|
||||
BorderThickness="1"
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Padding="8"
|
||||
BorderBrush="Gray"
|
||||
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>
|
||||
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>
|
||||
|
||||
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
FontWeight="SemiBold"
|
||||
Text="Post detail" />
|
||||
<TextBox
|
||||
x:Name="DraftTitleTextBox"
|
||||
Grid.Row="1"
|
||||
PlaceholderText="Title"
|
||||
Text="{Binding DraftTitle, Mode=TwoWay}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,69 @@ using Avalonia.Controls;
|
|||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Controls.Platform;
|
||||
|
||||
namespace PostIt.Views.Blogs;
|
||||
|
||||
public partial class BlogsPage : ContentPage
|
||||
{
|
||||
private IInputPane? _inputPane;
|
||||
|
||||
public BlogsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
// Récupère l'accès au TopLevel de la fenêtre/application
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel != null)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
|
|
@ -50,6 +103,7 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
x:Class="PostIt.Views.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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class AdministrationPage : ContentPage
|
||||
{
|
||||
public AdministrationPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,50 +2,20 @@
|
|||
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>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||
HorizontalAlignment="Stretch">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<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"
|
||||
|
|
@ -74,22 +44,5 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -9,12 +8,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
x:Class="PostIt.Views.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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class PerformerConfigurationPage : ContentPage
|
||||
{
|
||||
public PerformerConfigurationPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class ResourceUsagePage : ContentPage
|
||||
{
|
||||
public ResourceUsagePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
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"
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
#nullable enable annotations
|
||||
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yavsc.Blogspot;
|
||||
|
||||
public class BlogPostDto : IBlogPost, INotifyPropertyChanged
|
||||
public class BlogPostDto : IBlogPost
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private bool _isPublished;
|
||||
|
||||
public string AuthorId { get; set; }
|
||||
|
||||
public BlogPostAuthorDto? Author { get; set; }
|
||||
|
|
@ -34,18 +30,7 @@ public class BlogPostDto : IBlogPost, INotifyPropertyChanged
|
|||
/// follow-up request. Toggled via
|
||||
/// <c>PUT /api/BlogApi/{id}/publish</c>.
|
||||
/// </summary>
|
||||
public bool IsPublished
|
||||
{
|
||||
get => _isPublished;
|
||||
set
|
||||
{
|
||||
if (_isPublished == value)
|
||||
return;
|
||||
|
||||
_isPublished = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished)));
|
||||
}
|
||||
}
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
public virtual bool AuthorizeCircle(long circleId)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -30,15 +30,6 @@ 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ 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;
|
||||
|
|
@ -58,7 +57,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -1,23 +1,9 @@
|
|||
#nullable enable
|
||||
|
||||
namespace Yavsc.Abstract.Identity {
|
||||
public class Me : UserInfo
|
||||
{
|
||||
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 {
|
||||
public AuthToken Token {
|
||||
get;
|
||||
set;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
#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; }
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
#nullable enable
|
||||
|
||||
namespace Yavsc.Abstract.Identity
|
||||
{
|
||||
public class UserInfo {
|
||||
|
||||
public UserInfo()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public UserInfo(string userId, string userName, string email, string avatar)
|
||||
|
|
@ -15,15 +14,10 @@ namespace Yavsc.Abstract.Identity
|
|||
Email = email;
|
||||
Avatar = avatar;
|
||||
}
|
||||
public string UserId { 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; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; }
|
||||
public string Avatar { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -42,10 +42,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -322,8 +322,6 @@ 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 });
|
||||
}
|
||||
|
||||
|
|
@ -384,9 +382,12 @@ 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();
|
||||
|
||||
|
|
@ -413,9 +414,6 @@ 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);
|
||||
|
|
@ -426,12 +424,6 @@ 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
|
||||
|
|
@ -449,8 +441,9 @@ 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 must match the authenticated caller and identifies
|
||||
/// which party of the estimate signed.
|
||||
/// field disambiguates which side of the estimate signed
|
||||
/// because the bearer token belongs to the PostIt OAuth
|
||||
/// client, not the end user.
|
||||
/// </summary>
|
||||
public class SignatureSubmission
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
#nullable enable annotations
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Razor;
|
||||
|
|
@ -12,7 +9,6 @@ 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;
|
||||
|
|
@ -29,7 +25,6 @@ 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
|
||||
|
|
@ -49,13 +44,11 @@ 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;
|
||||
|
|
@ -136,9 +129,6 @@ 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
|
||||
|
|
@ -151,28 +141,10 @@ 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.
|
||||
renderedPdf = model.GenerateEstimatePdf();
|
||||
if (renderedPdf is FileInfo fo && fo.Exists)
|
||||
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
|
||||
{
|
||||
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");
|
||||
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);
|
||||
|
|
@ -180,14 +152,6 @@ 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);
|
||||
}
|
||||
|
|
@ -239,7 +203,6 @@ 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
|
||||
|
|
@ -306,11 +269,7 @@ 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
|
||||
|
|
@ -334,9 +293,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ 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;
|
||||
|
||||
|
|
@ -21,12 +19,10 @@ namespace Yavsc.Controllers;
|
|||
public class RdvQueryApiController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IYavscMessageSender _messageSender;
|
||||
|
||||
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
||||
public RdvQueryApiController(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_messageSender = messageSender;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
|
@ -97,16 +93,6 @@ 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)
|
||||
{
|
||||
|
|
@ -138,9 +124,6 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
#nullable enable
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -11,10 +9,6 @@ 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
|
||||
{
|
||||
|
|
@ -36,100 +30,35 @@ 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, UserManager<ApplicationUser> userManager,
|
||||
ITrueEmailSender emailSender, ICalendarManager calendarManager)
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
{
|
||||
_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?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Challenge();
|
||||
}
|
||||
|
||||
if (User == null)
|
||||
return new BadRequestObjectResult(
|
||||
new { error = "user not found" });
|
||||
var uid = User.GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(uid))
|
||||
{
|
||||
return Challenge();
|
||||
}
|
||||
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);
|
||||
|
||||
var userData = await _dbContext.Users
|
||||
.Include(u => u.PostalAddress)
|
||||
.Include(u => u.AccountBalance)
|
||||
.Include(u => u.BankInfo)
|
||||
.FirstOrDefaultAsync(u => u.Id == uid);
|
||||
var userRoles = _dbContext.UserRoles.Where(u => u.UserId == uid).Select(r => r.RoleId).ToArray();
|
||||
|
||||
if (userData is null)
|
||||
{
|
||||
return NotFound(new { error = "user not found" });
|
||||
}
|
||||
IdentityRole[] roles = _dbContext.Roles.Where(r => userRoles.Contains(r.Id)).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();
|
||||
user.Roles = roles.Select(r => r.Name).ToArray();
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
|
|
@ -139,571 +68,9 @@ 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 ()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,49 +22,22 @@ namespace Yavsc.Controllers
|
|||
[HttpGet]
|
||||
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
|
||||
{
|
||||
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
|
||||
});
|
||||
return _context.Users.Skip(skip).Take(take)
|
||||
.Select(u=> new UserInfo{
|
||||
UserId = u.Id,
|
||||
UserName = u.UserName,
|
||||
Avatar = u.Avatar});
|
||||
}
|
||||
|
||||
[HttpGet("search/{pattern}")]
|
||||
public IEnumerable<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
|
||||
{
|
||||
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
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
// GET: api/ApplicationUserApi/5
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
using Anthropic.SDK;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Yavsc.Abstract.Interfaces;
|
||||
|
|
@ -55,13 +54,6 @@ 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();
|
||||
|
|
@ -87,16 +79,6 @@ 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";
|
||||
|
|
@ -138,7 +120,6 @@ internal class Program
|
|||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||
.UseCors("default");
|
||||
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
||||
app.MapDefaultControllerRoute();
|
||||
|
|
|
|||
8
src/Yavsc.Blogs/Constants.cs
Normal file
8
src/Yavsc.Blogs/Constants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Yavsc.Blogs;
|
||||
|
||||
public static class BlogConstants
|
||||
{
|
||||
public const string AdminRole = "Admin";
|
||||
public const string ModeratorRole = "Moderator";
|
||||
public const string UserRole = "User";
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
}
|
||||
|
||||
[HttpPost("{*subdir}")]
|
||||
public async Task<IActionResult> Post([ValidRemoteUserFilePath] string subdir="")
|
||||
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
||||
{
|
||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||
string destDir = null;
|
||||
|
|
@ -159,14 +159,6 @@ 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)
|
||||
|
|
@ -200,7 +192,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
if (string.IsNullOrEmpty(ownerUserName))
|
||||
return NotFound(new { error = "owner not found" });
|
||||
|
||||
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
|
||||
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||
var fi = new FileInfo(physicalPath);
|
||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Yavsc.Models;
|
|||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
namespace Yavsc.Blogs;
|
||||
|
||||
internal class Program
|
||||
|
|
@ -82,11 +83,11 @@ internal class Program
|
|||
.AddSupportedCultures(supportedCultures)
|
||||
.AddSupportedUICultures(supportedCultures);
|
||||
})
|
||||
.Configure<SiteSettings>(settings =>
|
||||
.Configure<SiteSettings>(settings =>
|
||||
{
|
||||
if (settings.UserFilesRoot == null)
|
||||
if (settings.Blog == null)
|
||||
{
|
||||
settings.UserFilesRoot = "blog";
|
||||
settings.Blog = "blog";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -101,7 +102,6 @@ internal class Program
|
|||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||
.UseCors("default")
|
||||
;
|
||||
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"https://localhost:5005",
|
||||
"https://api.pschneider.fr"
|
||||
],
|
||||
"UserFilesRoot": "files"
|
||||
"Blog": "blog"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -421,9 +421,9 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
// Renames the blog files
|
||||
var userdirinfo = new DirectoryInfo(
|
||||
Path.Combine(_siteSettings.UserFilesRoot,
|
||||
Path.Combine(_siteSettings.Blog,
|
||||
oldUserName));
|
||||
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
||||
var newdir = Path.Combine(_siteSettings.Blog,
|
||||
model.UserName);
|
||||
if (userdirinfo.Exists)
|
||||
userdirinfo.MoveTo(newdir);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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;
|
||||
|
|
@ -20,20 +19,14 @@ 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,
|
||||
IOptions<SiteSettings> siteSettings,
|
||||
ILoggerFactory loggerFactory)
|
||||
ApplicationDbContext context)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
this._dbContext = context;
|
||||
this._siteSettings = siteSettings.Value;
|
||||
this._logger = loggerFactory.CreateLogger<AdministrationController>();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -99,16 +92,6 @@ 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())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.UserFilesRoot == null)
|
||||
if (settings.Blog == 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.UserFilesRoot);
|
||||
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
||||
|
||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,28 @@ 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
|
|
@ -1,53 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2821,68 +2821,6 @@ 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")
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ public class Startup
|
|||
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||
Config.SmtpSetup = smtpSettings.Value;
|
||||
Config.Authority = siteSettings.Value.Authority;
|
||||
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
|
||||
string blogsDir = siteSettings.Value.Blog ?? 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);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
|
|||
{
|
||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||
return View(new UserDirectoryInfo(
|
||||
siteSettings.UserFilesRoot,
|
||||
siteSettings.Blog,
|
||||
uid, dirname));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-left:8px; display:inline-block; vertical-align:middle;" />
|
||||
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
|
||||
@SiteSettings.Value.Title
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
"Avatars": "avatars",
|
||||
"Quota": 200000000,
|
||||
"Bills": "bills",
|
||||
"UserFilesRoot": "files",
|
||||
"Blog": "blog",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
.user-icon{max-width:1.5em;max-height:1.5em}.card{padding:.5em}
|
||||
.user-icon {
|
||||
max-width: 1.5em;
|
||||
max-height: 1.5em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
.user-icon {
|
||||
user-icon {
|
||||
max-width: 1.5em;
|
||||
max-height: 1.5em;
|
||||
}
|
||||
.card {
|
||||
padding: .5em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14229,6 +14229,10 @@ 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
|
|
@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
|
|||
this ClaimsPrincipal user,
|
||||
string subpath, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, userId, subdir);
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
|
||||
return di;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Messaging;
|
||||
using Yavsc.Models.Workflow;
|
||||
|
||||
namespace Yavsc.Helpers;
|
||||
|
||||
public static class RdvQueryEventHelpers
|
||||
{
|
||||
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
|
||||
=> new(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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#nullable enable
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace Yavsc.Server.Helpers;
|
||||
|
||||
public sealed class ResourceUsageTelemetryMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public ResourceUsageTelemetryMiddleware(
|
||||
RequestDelegate next,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_next = next;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var userName = context.User.Identity?.Name ?? userId;
|
||||
var requestBytes = context.Request.ContentLength ?? 0L;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var responseBytes = context.Response.ContentLength ?? 0L;
|
||||
var totalBytes = requestBytes + responseBytes;
|
||||
var bandwidthMb = totalBytes <= 0 ? 0m : (decimal)totalBytes / (1024m * 1024m);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
await ResourceUsageTracker.RecordAsync(
|
||||
db,
|
||||
userId,
|
||||
userName,
|
||||
apiCalls: 1m,
|
||||
bandwidthMb: bandwidthMb,
|
||||
actorUserId: userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#nullable enable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace Yavsc.Server.Helpers;
|
||||
|
||||
public static class ResourceUsageTracker
|
||||
{
|
||||
public static async Task RecordAsync(
|
||||
ApplicationDbContext dbContext,
|
||||
string userId,
|
||||
string userName,
|
||||
decimal cpuSeconds = 0m,
|
||||
long storageBytes = 0,
|
||||
decimal apiCalls = 0m,
|
||||
decimal bandwidthMb = 0m,
|
||||
string? actorUserId = null,
|
||||
string currency = Constants.ResourceUsageDefaultCurrency)
|
||||
{
|
||||
if (dbContext is null || string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveActor = string.IsNullOrWhiteSpace(actorUserId) ? userId : actorUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var storageMb = storageBytes <= 0 ? 0m : (decimal)storageBytes / (1024m * 1024m);
|
||||
|
||||
var record = await dbContext.ResourceUsageRecords
|
||||
.Where(r => r.UserId == userId)
|
||||
.OrderByDescending(r => r.RecordedAtUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
record = new ResourceUsageRecord
|
||||
{
|
||||
UserId = userId,
|
||||
UserName = string.IsNullOrWhiteSpace(userName) ? userId : userName,
|
||||
RecordedAtUtc = now,
|
||||
ApiCalls = apiCalls,
|
||||
CpuSeconds = cpuSeconds,
|
||||
BandwidthMb = bandwidthMb,
|
||||
StorageMb = storageMb,
|
||||
EstimatedCompensation = 0m,
|
||||
Currency = currency,
|
||||
UserCreated = effectiveActor,
|
||||
UserModified = effectiveActor,
|
||||
DateCreated = now,
|
||||
DateModified = now,
|
||||
};
|
||||
dbContext.ResourceUsageRecords.Add(record);
|
||||
await dbContext.SaveChangesAsync(effectiveActor);
|
||||
return;
|
||||
}
|
||||
|
||||
record.UserName = string.IsNullOrWhiteSpace(userName) ? record.UserName : userName;
|
||||
record.RecordedAtUtc = now;
|
||||
record.ApiCalls += apiCalls;
|
||||
record.CpuSeconds += cpuSeconds;
|
||||
record.BandwidthMb += bandwidthMb;
|
||||
record.StorageMb += storageMb;
|
||||
record.Currency = string.IsNullOrWhiteSpace(currency) ? record.Currency : currency;
|
||||
record.DateModified = now;
|
||||
record.UserModified = effectiveActor;
|
||||
|
||||
await dbContext.SaveChangesAsync(effectiveActor);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,14 +58,6 @@ namespace Yavsc.Models
|
|||
}
|
||||
builder.UseIdentityByDefaultColumns();
|
||||
|
||||
builder.Entity<ResourceUsageRecord>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.HasIndex(r => new { r.UserId, r.RecordedAtUtc });
|
||||
entity.Property(r => r.Currency).HasMaxLength(8).HasDefaultValue(Constants.ResourceUsageDefaultCurrency);
|
||||
entity.Property(r => r.UserName).HasMaxLength(256);
|
||||
});
|
||||
|
||||
// Customize the ASP.NET Identity model and override the defaults if needed.
|
||||
// For example, you can rename the ASP.NET Identity table names and more.
|
||||
// Add your customizations after calling base.OnModelCreating(builder);
|
||||
|
|
@ -332,7 +324,6 @@ namespace Yavsc.Models
|
|||
|
||||
public DbSet<Estimate> Estimates { get; set; }
|
||||
public DbSet<Signature> Signatures { get; set; }
|
||||
public DbSet<ResourceUsageRecord> ResourceUsageRecords { get; set; }
|
||||
public DbSet<AccountBalance> BankStatus { get; set; }
|
||||
public DbSet<BalanceImpact> BalanceImpact { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
using Yavsc.Interfaces.Workflow;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Models.Messaging;
|
||||
|
||||
public sealed class ClientEstimateSignedEvent : IEvent
|
||||
{
|
||||
public ClientEstimateSignedEvent(Estimate estimate, string sender)
|
||||
{
|
||||
EstimateId = estimate.Id;
|
||||
EstimateTitle = estimate.Title;
|
||||
PerformerId = estimate.OwnerId;
|
||||
Sender = sender;
|
||||
}
|
||||
|
||||
public long EstimateId { get; }
|
||||
public string EstimateTitle { get; }
|
||||
public string PerformerId { get; }
|
||||
public string Topic => "ClientEstimateSigned";
|
||||
public string Sender { get; set; }
|
||||
public string Title => "Devis signé par le client";
|
||||
public string CreateBody() => $"{Sender} a signé le devis n°{EstimateId} : {EstimateTitle}.";
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Yavsc.Models;
|
||||
|
||||
public sealed class ResourceUsageRecord : ITrackedEntity
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
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; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(8)]
|
||||
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
|
||||
|
||||
public DateTime DateCreated { get; set; } = DateTime.UtcNow;
|
||||
public string UserCreated { get; set; } = string.Empty;
|
||||
public DateTime DateModified { get; set; } = DateTime.UtcNow;
|
||||
public string UserModified { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public class BlogSpotService
|
|||
_context = context;
|
||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
if (siteSettings.Value.UserFilesRoot == null)
|
||||
if (siteSettings.Value.Blog == null)
|
||||
{
|
||||
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public class BlogSpotService
|
|||
{
|
||||
string blogFilesSubdir = $"blogs/{postId}";
|
||||
string destDir = Path.Combine(
|
||||
siteSettings.UserFilesRoot,
|
||||
siteSettings.Blog,
|
||||
user.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ using Yavsc.Models.Google.Messaging;
|
|||
using Yavsc.Models.Haircut;
|
||||
using Yavsc.Models.Messaging;
|
||||
using Yavsc.Server.Hubs;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
|
|
@ -31,7 +30,7 @@ namespace Yavsc.Services
|
|||
IHubContext<ChatHub> hubContext
|
||||
)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
||||
_logger = loggerFactory.CreateLogger<MailSender>();
|
||||
_emailSender = emailSender;
|
||||
siteSettings = sitesOptions?.Value;
|
||||
this.hubContext = hubContext;
|
||||
|
|
@ -71,51 +70,34 @@ namespace Yavsc.Services
|
|||
};
|
||||
|
||||
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
||||
results.Add(result);
|
||||
if (user == null)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "no such user.";
|
||||
continue;
|
||||
}
|
||||
var body = ev.CreateBody();
|
||||
var subject = ev is ClientEstimateSignedEvent signed
|
||||
? signed.Title
|
||||
: $"{ev.Sender} (un client) vous demande un rendez-vous";
|
||||
|
||||
if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent)
|
||||
{
|
||||
_dbContext.Notification.Add(new Notification
|
||||
{
|
||||
title = subject.Length > 1024 ? subject[..1024] : subject,
|
||||
body = body.Length > 512 ? body[..512] : body,
|
||||
Target = $"user/{userId}",
|
||||
tag = ev switch
|
||||
{
|
||||
ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed",
|
||||
RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}",
|
||||
_ => throw new InvalidOperationException("Unsupported persistent notification event.")
|
||||
},
|
||||
click_action = "Fermer"
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ev.Sender);
|
||||
}
|
||||
|
||||
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = !user.EmailConfirmed
|
||||
? "user has not confirmed his email address."
|
||||
: "user has no legacy email address.";
|
||||
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
|
||||
result.error = "user has not confirmed his email address.";
|
||||
continue;
|
||||
}
|
||||
else
|
||||
if (user.Email == null)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "user has no legacy email address.";
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = ev.CreateBody();
|
||||
|
||||
|
||||
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
|
||||
|
||||
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||
subject,
|
||||
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
||||
body + Environment.NewLine);
|
||||
response.success++;
|
||||
}
|
||||
|
||||
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
||||
if (cxIds == null)
|
||||
|
|
@ -143,6 +125,7 @@ namespace Yavsc.Services
|
|||
|
||||
response.success++;
|
||||
}
|
||||
results.Add(result);
|
||||
}
|
||||
response.results = results.ToArray();
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ namespace Yavsc
|
|||
public string DataDir { get; set; }
|
||||
public string Avatars { get; set; }
|
||||
public long Quota { get; set; }
|
||||
public string UserFilesRoot { get; set; }
|
||||
public string Blog { get; set; }
|
||||
public string Bills { get; set; }
|
||||
public string GitRepository { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using Yavsc.Abstract.Identity;
|
|||
namespace Yavsc.Models.Auth
|
||||
{
|
||||
public class Me : IApplicationUser {
|
||||
public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId, string? fullName = null)
|
||||
public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId)
|
||||
{
|
||||
Id = userId;
|
||||
UserName = userName;
|
||||
|
|
@ -11,16 +11,10 @@ namespace Yavsc.Models.Auth
|
|||
Avatar = avatar;
|
||||
PostalAddress = address;
|
||||
DedicatedGoogleCalendar = gCalId;
|
||||
FullName = fullName ?? string.Empty;
|
||||
Address = address?.Address ?? string.Empty;
|
||||
BankInfoSummary = string.Empty;
|
||||
}
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string FullName { get; set; }
|
||||
public string EMail { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string? BankInfoSummary { get; set; }
|
||||
public string[] Roles { get; set; }
|
||||
/// <summary>
|
||||
/// Known as profile, could point to an avatar
|
||||
|
|
@ -30,18 +24,18 @@ namespace Yavsc.Models.Auth
|
|||
|
||||
public IAccountBalance AccountBalance
|
||||
{
|
||||
get; set;
|
||||
get; set;
|
||||
}
|
||||
|
||||
public string DedicatedGoogleCalendar
|
||||
{
|
||||
get; set;
|
||||
get; set;
|
||||
}
|
||||
|
||||
public ILocation PostalAddress
|
||||
{
|
||||
get; set;
|
||||
get; set;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.ApiControllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("bob", "bob", true)]
|
||||
[InlineData("alice", "alice", false)]
|
||||
[InlineData("alice", "bob", false)]
|
||||
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
|
||||
string caller, string signer, bool notifies)
|
||||
{
|
||||
fixture.ResetAndSeedActivityGraph();
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var estimate = new Estimate
|
||||
{
|
||||
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis notification", Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
|
||||
}
|
||||
};
|
||||
controller.Url = new SignatureUrlHelper();
|
||||
var result = await controller.Sign(estimate.Id, new SignatureSubmission
|
||||
{
|
||||
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
|
||||
}, TestContext.Current.CancellationToken);
|
||||
if (caller != signer)
|
||||
{
|
||||
Assert.IsType<ForbidResult>(result);
|
||||
Assert.Empty(db.Signatures);
|
||||
}
|
||||
else
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
|
||||
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
if (notifies)
|
||||
{
|
||||
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
|
||||
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||
Assert.NotEqual(default, estimate.ClientValidationDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(db.Notification);
|
||||
Assert.Empty(delivery.Emails);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SignatureUrlHelper : IUrlHelper
|
||||
{
|
||||
public ActionContext ActionContext => throw new NotSupportedException();
|
||||
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
|
||||
public string? Content(string? contentPath) => throw new NotSupportedException();
|
||||
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
|
||||
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
|
||||
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,6 @@ using Yavsc.Models.Relationship;
|
|||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
namespace Yavsc.Api.Test.Fixtures;
|
||||
|
||||
|
|
@ -67,21 +64,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName);
|
||||
});
|
||||
});
|
||||
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer("Bearer", options =>
|
||||
|
|
@ -212,9 +197,23 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed class NoopMessageSender : IYavscMessageSender
|
||||
{
|
||||
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
}
|
||||
|
||||
public void ResetAndSeedActivityGraph()
|
||||
{
|
||||
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
|
|
@ -328,8 +327,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
// CommandLine reference NominativeServiceCommand (CommandId) and
|
||||
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
||||
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
|
||||
db.DismissClicked.RemoveRange(db.DismissClicked);
|
||||
db.Notification.RemoveRange(db.Notification);
|
||||
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
|
||||
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
||||
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue