Compare commits

..
107 changed files with 428 additions and 10384 deletions

View file

@ -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 \

View file

@ -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.

View file

@ -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
View file

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

View file

@ -1,143 +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

View file

@ -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

View file

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

View file

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

View file

@ -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;"

View file

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

View file

@ -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"`

View file

@ -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" />

View file

@ -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)!);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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()
{

View file

@ -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();

View file

@ -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 += () =>

View file

@ -7,27 +7,35 @@
x:Name="Root"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="260">
<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"
Padding="0"
MinHeight="260">
<ScrollViewer
BringIntoViewOnFocusChange="False"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<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" />
</ScrollViewer>
</Border>
<Border
@ -35,21 +43,18 @@
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8">
Padding="8"
MinHeight="260">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
VerticalAlignment="Stretch">
<md:MarkdownViewer
x:Name="PreviewViewer"
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
MarkdownText="{Binding MarkdownText, ElementName=Root}"
ClipToBounds="False" />
</ScrollViewer>
</Border>
</Grid>

View file

@ -38,6 +38,8 @@ public partial class MarkdownEditorControl : UserControl
{
InitializeComponent();
PropertyChanged += OnPropertyChanged;
SizeChanged += OnSizeChanged;
UpdateEditorLimits();
}
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@ -60,4 +62,27 @@ public partial class MarkdownEditorControl : UserControl
}
}
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
UpdateEditorLimits();
}
private void UpdateEditorLimits()
{
var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
visibleHeight = Math.Max(visibleHeight, 80);
if (SourceEditor is not null)
{
SourceEditor.MaxHeight = visibleHeight;
SourceEditor.Height = double.NaN;
}
if (PreviewViewer is not null)
{
PreviewViewer.MaxHeight = visibleHeight;
PreviewViewer.Height = double.NaN;
}
}
}

View file

@ -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>();

View file

@ -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);

View file

@ -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}" }

View file

@ -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;
}
}
}

View file

@ -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;
@ -52,15 +52,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
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
}
DraftArticleDocument = value is not null ? new TextDocument(value) : null;
OnPropertyChanged(nameof(DraftArticle));
}
}
@ -90,14 +82,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
OnPropertyChanged(nameof(DraftArticleDocument));
UpdateDraftModified();
}
}
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(DraftArticle));
UpdateDraftModified();
}
/// <summary>Editor buffer for the post's publication state.
@ -111,14 +101,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 +150,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 +168,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 +188,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 +205,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 +226,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 +423,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 +434,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 +494,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 +507,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 +515,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 +528,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 +546,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 +588,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 +630,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}";

View file

@ -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 =>
{

View file

@ -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())
{
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}
}

View file

@ -17,18 +17,14 @@
<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,*"
<ScrollViewer
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
BringIntoViewOnFocusChange="False">
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5"
@ -43,7 +39,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 +48,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 +62,6 @@
<CommandBarButton
x:Name="AddAttachmentButton"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
@ -115,45 +107,26 @@
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid" MinHeight="500">
<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,11 +141,6 @@
</ListBox>
</Border>
<GridSplitter
Grid.Row="1"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid
Grid.Row="2"
HorizontalAlignment="Stretch"
@ -183,7 +151,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@ -230,5 +197,6 @@
</Grid>
</Grid>
</Grid>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -50,6 +50,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);
}

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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>

View file

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

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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"
};
}

View file

@ -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)
{

View file

@ -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";

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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;
}
}
}
}

View file

@ -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; }
}

View file

@ -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; }
}
}

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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
{

View file

@ -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,

View file

@ -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);
}

View file

@ -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 ()
{

View file

@ -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

View file

@ -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();

View 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";
}

View file

@ -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" });

View file

@ -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")

View file

@ -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;"

View file

@ -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);

View file

@ -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())
{

View file

@ -1046,8 +1046,8 @@ public static class HostingExtensions
app.UseSwagger();
app.UseSwaggerUI();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
app.UseCors("default");
@ -1266,7 +1266,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
{
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
}
if (settings.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();

View file

@ -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

View file

@ -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");
}
}
}

View file

@ -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")

View file

@ -23,10 +23,10 @@ public class Startup
ResourcesHelpers.GlobalLocalizer = localizer;
Config.SmtpSetup = smtpSettings.Value;
Config.Authority = siteSettings.Value.Authority;
string blogsDir = siteSettings.Value.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);

View file

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

View file

@ -3,7 +3,7 @@
background-position: 0%;
background-attachment: fixed;">
<a class="navbar-brand logo" href="/">
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-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"

View file

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

View file

@ -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

View file

@ -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;
}

View file

@ -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
};
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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; }

View file

@ -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}.";
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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;

View file

@ -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; }

View file

@ -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;
}
}
}
}

View file

@ -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();
}
}

View file

@ -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>());

View file

@ -1,47 +0,0 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
namespace Yavsc.Api.Test.Fixtures;
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
{
public record Email(string Recipient, string Subject, string Body);
public record Push(string ConnectionId, string Method, object?[] Arguments);
public ConcurrentQueue<Email> Emails { get; } = new();
public ConcurrentQueue<Push> Pushes { get; } = new();
IHubClients IHubContext<ChatHub>.Clients => this;
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IClientProxy Group(string groupName) => throw new NotSupportedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
public IClientProxy User(string userId) => throw new NotSupportedException();
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
Emails.Enqueue(new Email(email, subject, htmlMessage));
return Task.FromResult(Guid.NewGuid().ToString());
}
public void Clear()
{
Emails.Clear();
Pushes.Clear();
}
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
return Task.CompletedTask;
}
}
}

View file

@ -10,7 +10,6 @@ using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -139,8 +138,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
// Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -168,8 +165,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Pro, signature.Type);
Assert.Equal("alice", signature.SignerId);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -197,48 +192,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("bob", signature.SignerId);
var notification = Assert.Single(assertDb.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag);
Assert.Contains("bob", notification.body);
Assert.Contains("Devis prestation", notification.body);
var email = Assert.Single(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
Assert.Equal("alice@example.test", email.Recipient);
Assert.Equal("Devis signé par le client", email.Subject);
}
[Fact]
public async Task Client_signature_pushes_to_the_performer_even_without_confirmed_email()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Users.Single(u => u.Id == "alice").EmailConfirmed = false;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("signed-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Empty(delivery.Emails);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("signed-alice", push.ConnectionId);
Assert.Equal("ClientEstimateSigned", push.Arguments[0]);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag);
}
finally
{
manager.OnDisconnected("signed-alice");
}
}
[Fact]

View file

@ -5,9 +5,6 @@ using Yavsc;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -37,72 +34,6 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
return http;
}
[Fact]
public async Task PostQuery_notifies_performer_by_database_email_and_signalr()
{
_fixture.ResetAndSeedRdvQueryGraph();
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("rdv-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", ClientId = "spoofed",
Consent = true, EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Notification test", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande notifiée", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var query = await response.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notification = Assert.Single(db.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"rdv/{query!.Id}", notification.tag);
Assert.Contains("bob", notification.title);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("rdv-alice", push.ConnectionId);
Assert.Equal("push", push.Method);
Assert.Equal("/topic/RdvQuery", push.Arguments[0]);
}
finally
{
manager.OnDisconnected("rdv-alice");
}
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task PostQuery_respects_performer_contact_preferences(bool acceptsNotifications, bool acceptsPublicContact)
{
_fixture.ResetAndSeedRdvQueryGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var performer = db.Performers.Single(p => p.PerformerId == "alice");
performer.AcceptNotifications = acceptsNotifications;
performer.AcceptPublicContact = acceptsPublicContact;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Contact refused", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
public async Task Billing_rdv_route_supports_crud()
{

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