Bug fix - notifications audience

* Performers had their notifications publicaly published.
* refacto admin group name
This commit is contained in:
Paul Schneider 2026-10-23 13:32:42 +01:00
commit d476c08046
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
36 changed files with 421 additions and 74 deletions

View file

@ -1,5 +1,16 @@
# Changelog
## [1.0.8-rc32] - unstable
### Added
### Changed
### Fixed
* Performer notification audience
## [1.0.8-rc31] - unstable
### Added
@ -16,7 +27,6 @@
* avoid NRE in MailSender on rdv query notifications
* Android emulator smoke test skipped in CI because Calabash instrumentation signature mismatches the target APK on hosted runners
## [1.0.8-rc30] - unstable
### Added

View file

@ -16,6 +16,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter |
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/notifications.md](architecture/notifications.md) | Notifications persistées, messages Web et événements temps réel côté serveur, Web et PostIt |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests |

View file

@ -0,0 +1,229 @@
# Notifications Yavsc : serveur, Web et PostIt
Ce document décrit les mécanismes de notification actuellement présents
dans le dépôt. Le terme « notification » recouvre plusieurs flux distincts :
1. les **annonces persistées** en base, affichées dans le site Web et sur
l'accueil de PostIt ;
2. les **messages temporaires de l'interface Web**, attachés à une réponse
MVC par `ViewBag.Notify` ;
3. les **événements temps réel** du chat et les événements métier diffusés par
SignalR.
Ces flux n'ont ni le même stockage, ni le même cycle de vie, ni les mêmes
clients.
## Vue d'ensemble
| Flux | Serveur / transport | Web | PostIt |
|---|---|---|---|
| Annonce persistée | Entité `Notification` en base, API HTTP | Affichée dans le layout et masquable | Liste sur `HomePage`, chargée par HTTP et masquable |
| Message d'interface | Liste de portée requête dans `ViewBag.Notify` | Affichée comme alerte dans le layout | Non consommé |
| Événement de chat | Hub SignalR authentifié à `/chathub` | Le site Web n'en consomme pas dans ses vues MVC | `notifyUser` et `notifyRoom` alimentent les journaux du chat |
| Événement métier `push` | SignalR vers les connexions actives, plus e-mail | Pas de consommateur identifié dans les vues Web | Pas de gestionnaire `push` dans le client PostIt actuel |
Les notifications persistées sont donc différentes d'une notification système
native (notification du navigateur ou du système d'exploitation). Le client
PostIt affiche une liste dans sa page d'accueil ; il ne demande pas de
permission de notification native.
## 1. Annonces persistées
### Modèle et stockage
L'entité partagée [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs)
est enregistrée dans la table `Notification` via `ApplicationDbContext`.
Elle contient :
- `Id`, identifiant généré ;
- `title` et `body`, obligatoires, limités respectivement à 1 024 et 512
caractères ;
- `icon`, `sound`, `tag`, `color`, propriétés optionnelles ;
- `click_action`, obligatoire, utilisé comme libellé d'action dans les
interfaces ;
- `Target`, chaîne de ciblage. Les valeurs documentées par le modèle sont
`null` (tout le monde), `user/{UserId}`, `pub/{topic}` et
`administration`.
Le masquage d'une annonce pour un utilisateur connecté est stocké dans
`DismissClicked`, avec une clé composée de l'identifiant utilisateur et de
l'identifiant de notification. Le même avis peut ainsi être masqué
indépendamment par chaque utilisateur.
### API
[`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs)
expose les deux routes GET suivantes, sous le préfixe `/api/v1` :
| Route | Accès | Résultat |
|---|---|---|
| `GET /api/v1/notifications` | Anonyme ou authentifié | Notifications visibles à l'appelant, non masquées |
| `GET /api/v1/notifications/me` | Anonyme ou authentifié | Même action et mêmes règles que la route sans suffixe |
La réponse est une liste JSON triée par `Id` décroissant. L'API exclut les
notifications masquées par l'utilisateur authentifié, puis applique les
règles d'audience : cible vide, cible `pub/…`, cible correspondant à
`user/{identifiant courant}`, ou cible `administration` pour un administrateur.
Une requête anonyme n'a ni identifiant utilisateur ni rôle administrateur.
[`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs)
est réservé aux utilisateurs authentifiés :
| Route | Effet |
|---|---|
| `GET /api/v1/dismiss` | Liste les identifiants masqués par l'utilisateur |
| `POST /api/v1/dismiss/click/{notificationId}` | Masque l'avis pour l'utilisateur ; l'opération est idempotente |
| `DELETE /api/v1/dismiss/click/{notificationId}` | Rétablit l'avis s'il était masqué ; renvoie `404` sinon |
Le POST renvoie `404` si l'identifiant de notification n'existe pas. Le modèle
de réponse est [`DismissedNotification`](../../src/Yavsc.Abstract/Messaging/DismissedNotification.cs).
### Création et notifications métier
Le contrôleur MVC
[`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs)
permet la gestion CRUD des enregistrements de notification.
Le service [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs)
crée aussi une notification persistée, ciblée sur `user/{id}`, pour les
événements `RdvQueryEvent` et `ClientEstimateSignedEvent`. L'enregistrement
est effectué avant l'envoi d'e-mail. Le même service tente ensuite d'envoyer
un e-mail si l'adresse est disponible et confirmée, puis diffuse l'événement
`push` aux connexions SignalR actives de l'utilisateur.
Cette diffusion `push` est conditionnée par la présence de connexions actives ;
l'avis stocké en base reste, lui, disponible lors d'une récupération ultérieure
par l'API.
## 2. Site Web
### Annonces persistées sur l'accueil
[`HomeController.Index`](../../src/Yavsc.Org/Controllers/HomeController.cs)
charge les notifications non masquées et les place dans `ViewBag.Notify`.
[`_Layout.cshtml`](../../src/Yavsc.Org/Views/Shared/_Layout.cshtml) les rend
comme des alertes Bootstrap avec icône, titre, corps et action. Le titre et le
corps passent par les composants Markdown du site.
Le clic sur l'action appelle `POST /Home/DismissNotification/{id}` via
[`site.js`](../../src/Yavsc.Org/wwwroot/js/site.js) :
- pour un utilisateur connecté, le masquage est enregistré dans
`DismissClicked` ;
- pour un visiteur anonyme, les identifiants sont conservés dans la session
Web (`clicked`).
La route renvoie `404` si l'avis n'existe pas et `204` après le masquage.
Comme l'API, l'accueil Web filtre `Target` : les cibles `user/{id}` ne sont
visibles qu'à l'utilisateur correspondant, et la cible `administration`
uniquement aux administrateurs. Un visiteur anonyme ne reçoit que les
notifications publiques (`Target` vide ou préfixe `pub/`).
### Messages temporaires par requête
Les helpers [`NotifyInfo`, `NotifyWarning` et `Notify`](../../src/Yavsc.Org/Helpers/ControllerHelpers.cs)
ajoutent des objets à `ViewBag.Notify`. Ce mécanisme n'écrit pas en base :
il ne vit que dans la réponse MVC qui rend le layout. Les méthodes
`NotifyInfo` et `NotifyWarning` partagent actuellement le même rendu générique
`alert-info`.
## 3. PostIt
[`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs)
construit les routes à partir de la base d'API de l'hôte métier configuré.
Il fournit les méthodes de lecture publique et authentifiée, ainsi que les
opérations de lecture, masquage et restauration.
Sur [`HomePage`](../../src/PostIt/PostIt/Views/Layout/HomePage.axaml),
[`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs)
charge la liste :
- sans session valide, il appelle la route publique
`GET /api/v1/notifications` ;
- avec une session valide, il appelle
`GET /api/v1/notifications/me` avec le client HTTP authentifié.
Le chargement est lancé à l'ouverture de la page et relancé lorsque l'état de
connexion change. Un compteur de version ignore les réponses obsolètes si un
chargement plus récent a déjà commencé. L'interface distingue l'état de
chargement, la liste vide et l'erreur de chargement ; l'utilisateur peut aussi
actualiser manuellement.
Le bouton d'une notification utilise `click_action` comme libellé et masque
l'élément. En session authentifiée, PostIt appelle
`POST /api/v1/dismiss/click/{id}` avant de retirer l'élément de la liste. Si
l'appel échoue, l'élément reste affiché et une erreur est présentée. Sans
session, l'élément est retiré de la liste locale, sans persistance du
masquage côté serveur.
## 4. Événements temps réel
### Hub du chat
Le hub [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs) est publié par
Yavsc.Api à la racine sous `/chathub` (et non sous `/api/v1`) et porte
`[Authorize]`. Il s'appuie sur SignalR et sur `IConnexionManager` pour suivre
les utilisateurs et les salons.
Les événements de notification émis par le hub comprennent notamment :
| Événement SignalR | Données | Utilisation PostIt |
|---|---|---|
| `notifyUser` | `type`, identifiant ou nom cible, message | Ajout au journal système |
| `notifyRoom` | `type`, salon, utilisateur ou détail | Ajout au journal du salon ; met à jour la liste d'utilisateurs sur `UserJoin` |
| `joint` | `ChatRoomInfo` | Initialise la vue du salon rejoint |
| `ReceiveMessage` | message avec nom, salon et corps | Ajoute le message à l'historique du salon |
| `notifyUserInRoom` | `type`, salon, message | Pas de gestionnaire correspondant dans `ChatViewModel` actuellement |
Les constantes de types du chat sont déclarées dans
[`NotificationTypes`](../../src/Yavsc.Abstract/Messaging/NotificationTypes.cs).
Les messages `notifyUser` et `notifyRoom` ne sont pas enregistrés en base :
ils décrivent l'état temps réel de connexion, de salon ou les erreurs de
conversation.
Le [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs)
ouvre le hub avec le jeton d'accès PostIt, tente un renouvellement silencieux
du jeton, et active la reconnexion automatique. Après reconnexion, il rejoint
à nouveau le salon sélectionné, car la connexion SignalR précédente et ses
groupes ne sont plus actifs. Les callbacks mettent à jour les collections sur
le thread UI d'Avalonia.
### Événement métier `push`
En plus des événements du chat, `YavscMessageSender` utilise le même contexte
SignalR pour émettre `push` avec le topic et une charge utile sérialisée. Ce
nom d'événement est distinct de `notifyUser` et `notifyRoom`. Aucun
gestionnaire `push` n'est présent dans le client PostIt actuel : les annonces
métier persistées sont visibles dans PostIt lors du prochain chargement ou
rafraîchissement HTTP.
## 5. Points d'attention pour les évolutions
- Conserver les règles d'audience alignées entre l'API et le site Web ; en
particulier, appliquer un filtrage `Target` côté Web avant d'exposer les
avis dans le layout.
- Ne pas confondre masquage utilisateur (`DismissClicked`) et suppression
globale de la notification : le masquage API est par utilisateur.
- Les sessions anonymes du site Web et PostIt n'ont pas le même comportement :
le site Web conserve le masquage en session, tandis que PostIt ne le garde
que dans la liste affichée jusqu'au prochain rechargement.
- Ajouter un nouveau type de cible ou un nouvel événement temps réel implique
d'examiner séparément son filtrage, son transport et les clients qui
l'écoutent.
- Les références à GCM et le nom historique de `YavscMessageSender` ne
signifient pas que PostIt reçoit une notification native hors ligne : le
chemin observé ici est l'e-mail, l'événement SignalR temps réel et la
notification persistée récupérée par HTTP.
## Références d'implémentation
- [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs)
- [`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs)
- [`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs)
- [`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs)
- [`HomeController`](../../src/Yavsc.Org/Controllers/HomeController.cs)
- [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs)
- [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs)
- [`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs)
- [`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs)
- [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs)

View file

@ -103,7 +103,7 @@ public partial class SessionStatusViewModel : ViewModelBase
if (profile is null)
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
if (version == _accessVersion)
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminGroupName, StringComparer.Ordinal);
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminName, StringComparer.Ordinal);
}
catch (Exception ex)
{

View file

@ -14,6 +14,7 @@ namespace Yavsc
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
public const string ChatHubPath = "chathub";
public const string Administration = "administration";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
@ -52,7 +53,7 @@ namespace Yavsc
public const string DefaultFactor = "Default";
public const string MobileAppFactor = "Mobile Application";
public const string SMSFactor = "SMS";
public const string AdminGroupName = "Administrator";
public const string AdminName = "Administrator";
public const string PerformerGroupName = "Performer";
public const string StarGroupName = "Star";
public const string StarHunterGroupName = "StarHunter";

View file

@ -24,7 +24,7 @@ public sealed class NotificationsApiController : Controller
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
{
var currentUserId = User.GetUserId();
var isAdmin = User.IsInRole(Constants.AdminGroupName);
var isAdmin = User.IsInRole(Constants.AdminName);
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
? new HashSet<long>()

View file

@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
{
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
{
return Forbid();
}

View file

@ -7,7 +7,7 @@ using Yavsc.Api.Services;
namespace Yavsc.ApiControllers.Administration;
[ApiController]
[ApiExplorerSettings(GroupName = "administration")]
[ApiExplorerSettings(GroupName = Constants.Administration)]
[Authorize("AdministratorOnly")]
[Route(Constants.APIPrefix + "/administration")]
[Produces("application/json")]

View file

@ -107,7 +107,7 @@ namespace Yavsc.ApiControllers
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice)
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName))
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminName))
return Forbid();
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}

View file

@ -24,12 +24,12 @@ namespace Yavsc.Controllers
}
bool UserIsAdminOrThis(string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminName)) return true;
return uid == User.GetUserId();
}
bool UserIsAdminOrInThese(string oid, string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminName)) return true;
var cuid = User.GetUserId();
return cuid == uid || cuid == oid;
}
@ -79,7 +79,7 @@ namespace Yavsc.Controllers
return BadRequest();
}
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
{
if (uid != estimate.OwnerId)
{
@ -115,7 +115,7 @@ namespace Yavsc.Controllers
var uid = User.GetUserId();
if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
{
if (uid != estimate.OwnerId)
{
@ -185,7 +185,7 @@ namespace Yavsc.Controllers
return NotFound();
}
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
{
if (uid != estimate.OwnerId)
{
@ -285,7 +285,7 @@ namespace Yavsc.Controllers
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
@ -319,7 +319,7 @@ namespace Yavsc.Controllers
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = _context.EstimateAttachedFiles

View file

@ -63,7 +63,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.Entry(estimateTemplate).State = EntityState.Modified;
@ -133,7 +133,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.EstimateTemplates.Remove(estimateTemplate);

View file

@ -86,7 +86,7 @@ namespace Yavsc.ApiControllers
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName)
if (!User.IsInRole(Constants.AdminName)
&& uid != estimate.ClientId && uid != estimate.OwnerId)
return Forbid();
@ -117,7 +117,7 @@ namespace Yavsc.ApiControllers
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName)
if (!User.IsInRole(Constants.AdminName)
&& uid != estimate.ClientId && uid != estimate.OwnerId)
return Forbid();
@ -403,7 +403,7 @@ namespace Yavsc.ApiControllers
if (isProvider) return (query, ActorRole.Provider, null);
if (isClient) return (query, ActorRole.Client, null);
if (User.IsInRole(Constants.AdminGroupName))
if (User.IsInRole(Constants.AdminName))
return (query, ActorRole.Admin, null);
return (null, ActorRole.None, Forbid());
@ -453,7 +453,7 @@ namespace Yavsc.ApiControllers
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
@ -463,7 +463,7 @@ namespace Yavsc.ApiControllers
var file = await dbContext.UploadedFiles
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = await dbContext.QueryAttachedFiles
@ -490,7 +490,7 @@ namespace Yavsc.ApiControllers
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = await dbContext.QueryAttachedFiles

View file

@ -70,7 +70,7 @@ public class HairCutQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -141,7 +141,7 @@ public class HairCutQueryApiController : Controller
}
var uid = User.GetUserId();
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -185,7 +185,7 @@ public class HairCutQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -234,4 +234,4 @@ public class HairCutQueryApiController : Controller
Details = prestation.GetDisplayDetails(),
};
}
}
}

View file

@ -77,7 +77,7 @@ public class HairMultiCutQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -152,7 +152,7 @@ public class HairMultiCutQueryApiController : Controller
}
var uid = User.GetUserId();
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -200,7 +200,7 @@ public class HairMultiCutQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -285,4 +285,4 @@ public class HairMultiCutQueryApiController : Controller
return query;
}
}
}

View file

@ -39,7 +39,7 @@ public sealed class InvoicesController(IInvoicingService invoices, ILogger<Invoi
public Task<ActionResult<InvoiceResponse>> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct));
private bool IsAdmin => User.IsInRole(Constants.AdminGroupName);
private bool IsAdmin => User.IsInRole(Constants.AdminName);
private async Task<ActionResult<InvoiceResponse>> ExecuteAsync(Func<Task<InvoiceResponse>> action, bool created = false)
{

View file

@ -62,7 +62,7 @@ public class RdvQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -157,7 +157,7 @@ public class RdvQueryApiController : Controller
return NotFound();
}
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}
@ -222,7 +222,7 @@ public class RdvQueryApiController : Controller
return NotFound();
}
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
{
return Forbid();
}

View file

@ -35,7 +35,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (blogpost.AuthorId!=uid)
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
return BadRequest();
_context.SaveChanges(User.GetUserId());

View file

@ -49,7 +49,7 @@ namespace Yavsc.Controllers
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != blackListed.OwnerId)
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminName))
if (!User.IsInRole(Constants.FrontOfficeGroupName))
return false;
return true;

View file

@ -46,7 +46,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId
&& ! User.IsInMsRole(Constants.AdminGroupName))
&& ! User.IsInMsRole(Constants.AdminName))
{
ModelState.AddModelError("UserId","get refused");
@ -72,7 +72,7 @@ namespace Yavsc.Controllers
}
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName))
if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName))
{
ModelState.AddModelError("ChannelName", "access put refused");
return BadRequest(ModelState);
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName)))
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName)))
{
ModelState.AddModelError("ChannelName", "access post refused");
return BadRequest(ModelState);
@ -154,7 +154,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminGroupName)))
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminName)))
{
ModelState.AddModelError("UserId", "access drop refused");
return BadRequest(ModelState);

View file

@ -136,7 +136,7 @@ namespace Yavsc.Controllers
if (User.GetUserId() != chatRoom.OwnerId )
{
if (!User.IsInMsRole(Constants.AdminGroupName))
if (!User.IsInMsRole(Constants.AdminName))
return BadRequest(new {error = "OwnerId"});
}

View file

@ -63,7 +63,7 @@ internal class Program
{
policy
.RequireAuthenticatedUser()
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
.RequireClaim(Constants.RoleClaimType, Constants.AdminName);
});
})
.AddYavscCors(builder.Configuration)

View file

@ -32,7 +32,7 @@ public sealed partial class AdministrationService(
public async Task<AdministrationSummary> GetSummaryAsync(CancellationToken ct)
=> new(await db.Users.CountAsync(ct),
(await users.GetUsersInRoleAsync(Constants.AdminGroupName)).Count,
(await users.GetUsersInRoleAsync(Constants.AdminName)).Count,
typeof(AdministrationService).Assembly.GetName().ToString(), System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
public async Task<IReadOnlyList<AdministrationRole>> GetRolesAsync(CancellationToken ct)
@ -46,7 +46,7 @@ public sealed partial class AdministrationService(
public async Task EnsureRolesAsync(CancellationToken ct)
{
foreach (var name in new[] { Constants.AdminGroupName, Constants.StarGroupName, Constants.PerformerGroupName,
foreach (var name in new[] { Constants.AdminName, Constants.StarGroupName, Constants.PerformerGroupName,
Constants.FrontOfficeGroupName, Constants.StarHunterGroupName, Constants.BlogModeratorGroupName })
{
ct.ThrowIfCancellationRequested();
@ -73,7 +73,7 @@ public sealed partial class AdministrationService(
CheckIdentity(await users.AddToRoleAsync(user, role.Name!));
else if (!member && alreadyMember)
{
if (role.Name == Constants.AdminGroupName &&
if (role.Name == Constants.AdminName &&
(await users.GetUsersInRoleAsync(role.Name)).Count <= 1)
throw Conflict("Impossible de retirer le dernier administrateur.");
CheckIdentity(await users.RemoveFromRoleAsync(user, role.Name!));

View file

@ -9,8 +9,8 @@ public static class AdministrationServiceCollectionExtensions
services.AddHttpContextAccessor();
services.AddScoped<IAdministrationService, AdministrationService>();
services.AddScoped<AdministrationExceptionFilter>();
services.AddOpenApi("administration", options =>
options.ShouldInclude = description => description.GroupName == "administration");
services.AddOpenApi(Constants.Administration, options =>
options.ShouldInclude = description => description.GroupName == Constants.Administration);
return services;
}
}

View file

@ -216,7 +216,7 @@ namespace Yavsc.Blogs.Controllers
// autorisés comme pour le reste du workflow.
bool CanReadFile(UploadedFile file, string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminName)) return true;
if (file.OwnerId == uid) return true;
if (dbContext.EstimateAttachedFiles.Any(a =>

View file

@ -56,7 +56,7 @@ namespace Yavsc.Controllers
{
// ensure all roles existence
foreach (string roleName in new string[] {
Constants.AdminGroupName,
Constants.AdminName,
Constants.StarGroupName,
Constants.PerformerGroupName,
Constants.FrontOfficeGroupName,
@ -86,13 +86,12 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Take()
{
// If some amdin already exists, make this method disapear
var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminGroupName);
var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminName);
if (admins != null && admins.Count > 0)
{
// All is ok, nothing to do here.
if (User.IsInMsRole(Constants.AdminGroupName))
if (User.IsInMsRole(Constants.AdminName))
{
return Ok(new { message = "you already got it." });
}
return NotFound();
@ -116,7 +115,7 @@ namespace Yavsc.Controllers
return new BadRequestObjectResult(ModelState);
}
var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminGroupName);
var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminName);
if (!addToRoleResult.Succeeded)
{
AddErrors(addToRoleResult);
@ -130,11 +129,11 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Index()
{
var adminCount = await _userManager.GetUsersInRoleAsync(
Constants.AdminGroupName);
Constants.AdminName);
var userCount = await _dbContext.Users.CountAsync();
var youAreAdmin = await _userManager.IsInRoleAsync(
await _userManager.FindByIdAsync(User.GetUserId()),
Constants.AdminGroupName);
Constants.AdminName);
var roles = await _roleManager.Roles.Select(x => new RoleInfo
{

View file

@ -57,7 +57,7 @@ namespace Yavsc.Controllers
}
private async Task SetupView(Announce announce)
{
ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminGroupName);
ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminName);
ViewBag.IsPerformer = User.IsInMsRole(Constants.PerformerGroupName);
ViewBag.AllowEdit = announce==null || announce.Id<=0 || !_authorizationService.AuthorizeAsync(User,announce,new EditPermission()).IsFaulted;
List<SelectListItem> dl = new List<SelectListItem>();
@ -80,7 +80,7 @@ namespace Yavsc.Controllers
if (ModelState.IsValid)
{
// Only allow admin to create corporate annonces
if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminGroupName))
if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminName))
{
ModelState.AddModelError("For", _localizer["YourNotAdmin"]);
return View(announce);

View file

@ -50,9 +50,14 @@ namespace Yavsc.Controllers
if (clicked == null) clicked = new long[0];
}
else clicked = _dbContext.DismissClicked.Where(d => d.UserId == uid).Select(d => d.NotificationId).ToArray();
var isAdmin = User.IsInRole(Constants.AdminName);
var notes = _dbContext.Notification.Where(
n => !clicked.Contains(n.Id)
);
).Where(n =>
string.IsNullOrWhiteSpace(n.Target)
|| (n.Target != null && n.Target.StartsWith("pub/"))
|| (!string.IsNullOrWhiteSpace(uid) && n.Target == $"user/{uid}")
|| (isAdmin && n.Target == Constants.Administration));
if (notes.Any()) this.Notify(notes);
var toShow = _dbContext.Activities

View file

@ -63,7 +63,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (ModelState.IsValid)
{
if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
return new ChallengeResult();
_context.Instrumentation.Add(model);
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
{
return NotFound();
}
if (id != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
if (id != uid) if (!User.IsInMsRole(Constants.AdminName))
return new ChallengeResult();
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
if (musicianSettings == null)
@ -97,7 +97,7 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Edit(Instrumentation musicianSettings)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
return new ChallengeResult();
if (ModelState.IsValid)
{
@ -123,7 +123,7 @@ namespace Yavsc.Controllers
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
return new ChallengeResult();
return View(musicianSettings);
}
@ -136,7 +136,7 @@ namespace Yavsc.Controllers
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
return new ChallengeResult();

View file

@ -263,14 +263,14 @@ public static class HostingExtensions
policy
.RequireAuthenticatedUser()
.RequireClaim(Constants.RoleClaimType,
new string[] { Constants.PerformerGroupName, Constants.AdminGroupName })
new string[] { Constants.PerformerGroupName, Constants.AdminName })
;
});
options.AddPolicy("AdministratorOnly", policy =>
{
_ = policy
.RequireAuthenticatedUser()
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
.RequireClaim(Constants.RoleClaimType, Constants.AdminName);
});
options.AddPolicy("FrontOffice", policy => policy.RequireRole(Constants.FrontOfficeGroupName));

View file

@ -16,7 +16,7 @@
<li><a class="dropdown-item @PageHelpers.ActivePage(ViewContext, "Feature")" asp-controller="Feature" asp-action="Index">Features</a></li>
</ul>
</li>
@if (User.IsInMsRole(Constants.AdminGroupName)) {
@if (User.IsInMsRole(Constants.AdminName)) {
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @PageHelpers.ActivePageAny(ViewContext, administrationControllers)" href="#" id="dropdown05" data-bs-toggle="dropdown" aria-expanded="false">
Administration

View file

@ -80,7 +80,7 @@ namespace Yavsc.Server.Hubs
var userId = _dbContext.Users.First(u => u.UserName == userName).Id;
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
isCop = Context.User.IsInMsRole(Constants.AdminGroupName);
isCop = Context.User.IsInMsRole(Constants.AdminName);
if (isCop)
{
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
@ -232,7 +232,7 @@ namespace Yavsc.Server.Hubs
throw new NotImplementedException();
}
[Authorize(Constants.AdminGroupName)]
[Authorize(Constants.AdminName)]
public void GLine(string userName, string reason)
{
if (!InputValidator.ValidateUserName(userName)) return;
@ -330,7 +330,7 @@ namespace Yavsc.Server.Hubs
var identityUserName = Context.User.GetUserName();
if (userName[0] != '?' && Context.User != null)
if (!Context.User.IsInMsRole(Constants.AdminGroupName))
if (!Context.User.IsInMsRole(Constants.AdminName))
{
var bl = _dbContext.BlackListed

View file

@ -90,7 +90,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
options.AddPolicy("AdministratorOnly", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName);
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminName);
});
});

View file

@ -6,8 +6,10 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Controllers;
using Yavsc.Models;
using Yavsc.Models.Workflow;
namespace Yavsc.Api.Test;
@ -57,6 +59,106 @@ public sealed class HomeControllerTests : IClassFixture<ApiWebServerFixture>
Assert.DoesNotContain(model, a => a.Code == "ghost");
}
[Fact]
public async Task Index_does_not_show_performer_notification_to_anonymous_visitors()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
db.Notification.AddRange(
Note("Rendez-vous professionnel", "user/alice"),
Note("Annonce publique", "pub/all"));
await db.SaveChangesAsync();
var controller = CreateHomeController(db, new ClaimsPrincipal(new ClaimsIdentity()));
controller.ControllerContext.HttpContext.Session = new TestSession();
await controller.Index(id: null);
object notifyValue = controller.ViewBag.Notify;
var notifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(notifyValue);
Assert.Equal("Annonce publique", Assert.Single(notifications).title);
}
[Fact]
public async Task Index_shows_performer_notification_only_to_its_target_user()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
db.Notification.AddRange(
Note("Rendez-vous professionnel", "user/alice"),
Note("Autre rendez-vous", "user/bob"),
Note("Annonce publique", "pub/all"));
await db.SaveChangesAsync();
var controller = CreateHomeController(db, UserPrincipal("bob"));
await controller.Index(id: null);
object notifyValue = controller.ViewBag.Notify;
var notifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(notifyValue);
Assert.Equal(new[] { "Autre rendez-vous", "Annonce publique" },
notifications.Select(notification => notification.title));
var performerController = CreateHomeController(db, UserPrincipal("alice"));
await performerController.Index(id: null);
object performerNotifyValue = performerController.ViewBag.Notify;
var performerNotifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(performerNotifyValue);
Assert.Equal(new[] { "Rendez-vous professionnel", "Annonce publique" },
performerNotifications.Select(notification => notification.title));
}
private static HomeController CreateHomeController(
Yavsc.Models.ApplicationDbContext db,
ClaimsPrincipal user)
{
var controller = new HomeController(
NullLogger<HomeController>.Instance,
localizer: null!,
context: db,
settingsOptions: Options.Create(new SiteSettings()),
env: new TestEnvironment());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
return controller;
}
private static ClaimsPrincipal UserPrincipal(string userId)
=> new(new ClaimsIdentity(new[]
{
new Claim("sub", userId),
new Claim(ClaimTypes.NameIdentifier, userId)
}, "Bearer"));
private static Notification Note(string title, string target)
=> new()
{
title = title,
body = "Corps",
click_action = "Fermer",
Target = target
};
private sealed class TestSession : ISession
{
private readonly Dictionary<string, byte[]> _values = new();
public bool IsAvailable => true;
public string Id => "home-controller-test-session";
public IEnumerable<string> Keys => _values.Keys;
public void Clear() => _values.Clear();
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public void Remove(string key) => _values.Remove(key);
public void Set(string key, byte[] value) => _values[key] = value;
public bool TryGetValue(string key, out byte[] value) => _values.TryGetValue(key, out value!);
}
private sealed class TestEnvironment : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Yavsc.Api.Test";

View file

@ -414,7 +414,7 @@ public sealed class InvoiceApiTests(ApiWebServerFixture fixture) : IClassFixture
using var admin = Client();
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
TestTokenIssuer.Issue("alice", "api", extraClaims:
[new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminGroupName)]));
[new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminName)]));
var response = await admin.GetAsync("/openapi/invoicing.json", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync(Ct);

View file

@ -89,7 +89,7 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
using var admin = Client("podcast-admin");
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
TestTokenIssuer.Issue("podcast-admin", "api",
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminGroupName) }));
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminName) }));
using var response = await admin.GetAsync("/openapi/podcasts.json", _ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(_ct));

View file

@ -143,7 +143,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
title = "Admin",
body = "Restricted to administrators",
click_action = "/admin",
Target = "administration",
Target = Constants.Administration,
});
await db.SaveChangesAsync();
@ -172,7 +172,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
"api",
new[]
{
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName)
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminName)
});
var response = await adminHttp.GetAsync("/api/v1/resource-usage/admin", TestContext.Current.CancellationToken);