Compare commits

...
Author SHA1 Message Date
nicolas cbcc0b4f44 Documentation : phase 6 (administrateurs et journal d'audit) 2026-11-01 14:45:25 +01:00
nicolas d0db02d7f3 Tests : pages Mes appareils, Administrateurs et Journal d'audit
tests/GameLauncher.Tests/Admin/AccountsPanelTests.cs (nouveau, 4 tests)
tests/GameLauncher.Tests/Admin/AuditPanelTests.cs (nouveau, 2 tests)
2026-11-01 14:45:03 +01:00
nicolas 63c2e37dad Launcher : vues des pages Mes appareils, Administrateurs et Journal d'audit, enregistrement des modules
src/GameLauncher/Themes/Admin.xaml : modèles des trois pages, plus les liens du tableau de bord.
src/GameLauncher/Themes/Icons.xaml : icônes Laptop et AccountRemove.
src/GameLauncher/App.xaml.cs : AccountsAdminModule et AuditAdminModule.
2026-11-01 14:44:43 +01:00
nicolas 78780c20f1 Tableau de bord : liens vers « Administrateurs » et « Mes appareils » 2026-11-01 14:44:16 +01:00
nicolas 3b95317d4c Administration : page « Journal d'audit » (filtres, pages, export CSV) 2026-11-01 14:44:01 +01:00
nicolas bb165c4be0 Administration : pages « Mes appareils » et « Administrateurs » (ViewModels)
src/GameLauncher.Modules.Admin/Accounts/AccountItems.cs (nouveau) : base commune, appareils, invitations, code affiché une seule fois.
src/GameLauncher.Modules.Admin/Accounts/AdminsPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/MyDevicesPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/AccountsAdminModule.cs (nouveau)
2026-11-01 14:43:38 +01:00
nicolas d2cb48b901 Serveur : route « mon compte », suppression de ses propres invitations « nouvel appareil », audit détaillé
server/GameLauncher.Server/Modules/Admins/AdminsModule.cs
tests/GameLauncher.Tests/Server/AdminManagementTests.cs : nouveau test (son compte, ses invitations, droits de suppression, journal).
2026-11-01 14:43:01 +01:00
nicolas e06ddacf04 Contrats et client : compte de l'administrateur connecté (GET v1/admins/me), auteur des invitations
src/GameLauncher.Contracts/ApiRoutes.cs : route MyAccount.
src/GameLauncher.Contracts/Dtos/AdminDtos.cs : MyAccount, et InvitationSummary.CreatedById.
src/GameLauncher.ApiClient/AdminSession.cs : GetMyAccountAsync
2026-11-01 14:39:59 +01:00
20 changed files with 2071 additions and 36 deletions

No files matched your search

+15 -3
View File
@@ -488,8 +488,8 @@ Chantier en cours, découpé en phases : architecture, sécurité et planning da
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Les pages « Administrateurs »
et « Journal d'audit » arrivent à la phase 6.
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
@@ -537,6 +537,18 @@ et « Journal d'audit » arrivent à la phase 6.
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
étape 3 quater.
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
* **Page « Administrateurs »** (permission `admins.manage` : propriétaires) : inviter (nom, rôle → code
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
@@ -556,5 +568,5 @@ et « Journal d'audit » arrivent à la phase 6.
|---|---|
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
+35 -4
View File
@@ -268,7 +268,7 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|---|:-:|:-:|:-:|---|
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
| `audit.read` | ✔ | ✔ | | Journal d'audit |
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
@@ -288,8 +288,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
@@ -476,6 +476,37 @@ de Windows et durée maximale de session toujours appliqués).
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
actif : restauration sur le VPS (`add-key`).
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
@@ -501,7 +532,7 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
@@ -33,10 +33,12 @@ public sealed class AdminsModule : IApiModule
manage.MapGet(ApiRoutes.Admins, List);
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
@@ -49,6 +51,24 @@ public sealed class AdminsModule : IApiModule
private static IResult List(AdminStore store) =>
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
/// <summary>
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
/// </summary>
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var db = store.Read();
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
return Results.Json(new MyAccount
{
Admin = ToSummary(admin),
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(ToSummary).ToList()
}, ApiJson.Options);
}
private static IResult ListInvitations(AdminStore store, TimeProvider time)
{
var now = time.GetUtcNow();
@@ -107,20 +127,24 @@ public sealed class AdminsModule : IApiModule
return record;
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
}
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
{
var actor = context.Admin();
store.Update(db =>
var deleted = store.Update(db =>
{
EnsureActor(db, actor);
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
db.Invitations.Remove(invitation);
return invitation;
});
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
return Results.NoContent();
}
@@ -285,6 +309,9 @@ public sealed class AdminsModule : IApiModule
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
}
private static string InvitationDetails(InvitationRecord invitation) =>
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
string result = AuditResults.Ok)
{
@@ -319,6 +346,7 @@ public sealed class AdminsModule : IApiModule
ForAdminId = invitation.ForAdminId,
CreatedAt = invitation.CreatedAt,
CreatedBy = invitation.CreatedBy,
CreatedById = invitation.CreatedById,
ExpiresAt = invitation.ExpiresAt
};
}
+17 -14
View File
@@ -270,26 +270,29 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
## 7. Ajouter un administrateur
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
2. **Paul** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; il te communique
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
24 h, usage unique, affiché une seule fois).
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; il te communique
**par téléphone ou message** l'empreinte affichée.
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
--fingerprint "<empreinte>"`.
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
utiliser le code. Révoque (`revoke-admin`) et recommence.
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
commande : `invite --for <son id>`, `enroll`, `approve`). Personne d'autre ne peut approuver une clé à
son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
## 8. Mettre à jour le serveur
@@ -82,6 +82,9 @@ public sealed class AdminSession : IAsyncDisposable
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, ct);
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
+3
View File
@@ -28,6 +28,9 @@ public static class ApiRoutes
public const string Admins = "v1/admins";
public const string Invitations = "v1/admins/invitations";
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public const string MyAccount = "v1/admins/me";
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
@@ -26,6 +26,13 @@ public sealed record AdminSummary
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
}
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
public sealed record MyAccount
{
public AdminSummary Admin { get; init; } = new();
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
}
public sealed record CreateInvitationRequest
{
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
@@ -46,6 +53,10 @@ public sealed record InvitationSummary
public Guid? ForAdminId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
public Guid? CreatedById { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
@@ -0,0 +1,299 @@
using System.Globalization;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
public static class AccountTexts
{
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
public static string Date(DateTimeOffset value) =>
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Status(string status) => status switch
{
AdminStatus.Active => "Actif",
AdminStatus.Pending => "En attente d'approbation",
AdminStatus.Revoked => "Révoqué",
_ => status
};
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
public static string RoleHint(string role) => role switch
{
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
AdminRoles.Editor => "informations des jeux et notes de version",
_ => ""
};
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
}
/// <summary>
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
/// </summary>
public abstract class AccountsPanelBase : AdminPanelViewModel
{
private InvitationCodeViewModel? _createdInvitation;
private string? _statusMessage;
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
public bool CanEdit => !IsBusy;
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
public string? StatusMessage
{
get => _statusMessage;
protected set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
public InvitationCodeViewModel? CreatedInvitation
{
get => _createdInvitation;
private set
{
if (!SetProperty(ref _createdInvitation, value)) return;
OnPropertyChanged(nameof(HasCreatedInvitation));
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
}
}
public bool HasCreatedInvitation => _createdInvitation is not null;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
RefreshCommand.RaiseCanExecuteChanged();
OnCommandsChanged();
}
/// <summary>Réévalue les commandes propres à la page.</summary>
protected virtual void OnCommandsChanged()
{
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
/// <summary>Relit les données de la page sur le serveur.</summary>
protected abstract Task ReloadAsync();
/// <summary>
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
/// </summary>
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
{
StatusMessage = null;
if (!await RunAsync(change)) return false;
StatusMessage = success;
await RunAsync(ReloadAsync);
return true;
}
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
{
StatusMessage = null;
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
Context.Dialogs, () => CreatedInvitation = null);
}
internal async Task ApproveAsync(AdminKeyItem key)
{
StatusMessage = null;
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
if (typed is null)
{
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
return;
}
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
"Approuver"))
return;
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
}
internal async Task RevokeKeyAsync(AdminKeyItem key)
{
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
}
internal async Task DeleteInvitationAsync(InvitationItem invitation)
{
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
return;
await ApplyAsync(async () =>
{
try
{
await Context.Session.DeleteInvitationAsync(invitation.Id);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà utilisée ou expirée entre-temps
}
}, "Invitation supprimée.");
}
}
/// <summary>Appareil (clé) d'un administrateur.</summary>
public sealed class AdminKeyItem : ObservableObject
{
private string _fingerprintInput = "";
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
{
FingerprintText = key.Fingerprint ?? pendingText;
AdminId = adminId;
AdminName = adminName;
KeyId = key.KeyId;
Label = AccountTexts.KeyLabel(key.Label);
Status = key.Status;
IsCurrentSession = isCurrentSession;
Fingerprint = key.Fingerprint;
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
CanApprove = canApprove && IsPending;
ApproveHint = IsPending ? approveHint : null;
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
}
public Guid AdminId { get; }
public string AdminName { get; }
public string KeyId { get; }
public string Label { get; }
public string Status { get; }
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsActive => Status == AdminStatus.Active;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsCurrentSession { get; }
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
public string? Fingerprint { get; }
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
public string FingerprintText { get; }
public string AddedText { get; }
public string LastUsedText { get; }
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}";
public bool CanApprove { get; }
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
public string? ApproveHint { get; }
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
public bool CanRevoke { get; }
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
public string FingerprintInput
{
get => _fingerprintInput;
set => SetProperty(ref _fingerprintInput, value ?? "");
}
public AsyncRelayCommand ApproveCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
}
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
public sealed class InvitationItem
{
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
{
Id = invitation.Id;
IsDevice = invitation.ForAdminId is not null;
Title = IsDevice
? $"Nouvel appareil de {invitation.DisplayName}"
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
CanDelete = canDelete;
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
}
public Guid Id { get; }
public bool IsDevice { get; }
public string Title { get; }
public string DetailText { get; }
public bool CanDelete { get; }
public AsyncRelayCommand DeleteCommand { get; }
}
/// <summary>
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
/// </summary>
public sealed class InvitationCodeViewModel : ObservableObject
{
private readonly IModuleDialogs _dialogs;
private bool _copied;
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
IModuleDialogs dialogs, Action dismiss)
{
Code = code;
Title = title;
Instructions = instructions;
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
_dialogs = dialogs;
CopyCommand = new RelayCommand(Copy);
DismissCommand = new RelayCommand(dismiss);
}
public string Code { get; }
public string Title { get; }
public string Instructions { get; }
public string ExpiresText { get; }
public bool Copied
{
get => _copied;
private set => SetProperty(ref _copied, value);
}
public RelayCommand CopyCommand { get; }
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
public RelayCommand DismissCommand { get; }
private void Copy()
{
_dialogs.CopyToClipboard(Code);
Copied = true;
}
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
/// (gestion des administrateurs : propriétaires).
/// </summary>
public sealed class AccountsAdminModule : ILauncherModule
{
public string Id => "accounts";
public string DisplayName => "Comptes";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
MyDevicesPanelViewModel.PanelId,
"Mes appareils",
"Les PC depuis lesquels vous pouvez vous connecter : ajoutez-en un (invitation « nouvel appareil »), puis approuvez-le " +
"ici avec l'empreinte qu'il affiche.",
"KeyVariant",
RequiredPermission: null,
Order: 40,
context => new MyDevicesPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
AdminsPanelViewModel.PanelId,
"Administrateurs",
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
"AccountGroup",
AdminPermissions.AdminsManage,
Order: 50,
context => new AdminsPanelViewModel(context)));
}
}
@@ -0,0 +1,263 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
/// administrateur actif…) et affiche ses refus tels quels.
/// </summary>
public sealed class AdminsPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.manage";
private List<AdminSummary> _summaries = new();
private IReadOnlyList<AdminAccountItem> _admins = Array.Empty<AdminAccountItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
private bool _showRevoked;
private string _newName = "";
private Choice _newRole;
public AdminsPanelViewModel(IAdminContext context) : base(context)
{
RoleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
_newRole = RoleChoices.First(c => c.Value == AdminRoles.Editor);
InviteCommand = new AsyncRelayCommand(InviteAsync, () => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName));
}
public IReadOnlyList<Choice> RoleChoices { get; }
public IReadOnlyList<AdminAccountItem> Admins
{
get => _admins;
private set => SetProperty(ref _admins, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
/// <summary>Afficher aussi les comptes révoqués (historique).</summary>
public bool ShowRevoked
{
get => _showRevoked;
set
{
if (SetProperty(ref _showRevoked, value)) BuildAdmins();
}
}
public string RevokedText
{
get
{
var count = _summaries.Count(a => a.Status == AdminStatus.Revoked);
return count == 0 ? "Aucun compte révoqué" : $"Afficher les comptes révoqués ({count})";
}
}
public string SummaryText
{
get
{
var active = _summaries.Count(a => a.Status == AdminStatus.Active);
var pending = _summaries.Where(a => a.Status != AdminStatus.Revoked).SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
return $"{active} compte(s) actif(s)" + (pending == 0 ? "" : $" · {pending} appareil(s) en attente d'approbation");
}
}
// ------------------------------------------------------------------ Nouvelle invitation
public string NewName
{
get => _newName;
set
{
if (SetProperty(ref _newName, value ?? "")) InviteCommand.RaiseCanExecuteChanged();
}
}
public Choice NewRole
{
get => _newRole;
set
{
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
}
}
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
public AsyncRelayCommand InviteCommand { get; }
protected override void OnCommandsChanged() => InviteCommand.RaiseCanExecuteChanged();
private async Task InviteAsync()
{
var name = _newName.Trim();
var role = _newRole.Value!;
if (role == AdminRoles.Owner &&
!Context.Dialogs.Confirm("Inviter un propriétaire ?",
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
"Inviter", danger: true))
return;
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
NewName = "";
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role)})",
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
"ci-dessous en la saisissant.");
await RunAsync(ReloadAsync);
}
// ------------------------------------------------------------------ Comptes
internal async Task ChangeRoleAsync(AdminAccountItem admin)
{
var role = admin.SelectedRole.Value!;
if (role == admin.Role) return;
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
"immédiatement, y compris à ses sessions ouvertes.";
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
{
admin.ResetRole();
return;
}
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
$"{admin.DisplayName} est maintenant {AdminTexts.Role(role).ToLowerInvariant()}."))
admin.ResetRole();
}
internal async Task RevokeAdminAsync(AdminAccountItem admin)
{
if (!Context.Dialogs.Confirm($"Révoquer le compte de {admin.DisplayName} ?",
"Ses sessions sont fermées et tous ses appareils révoqués immédiatement ; ses invitations « nouvel appareil » sont " +
"supprimées. Un compte révoqué ne peut pas être réactivé : il faudrait l'inviter de nouveau.",
"Révoquer le compte", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeAdminAsync(admin.Id), $"Compte de {admin.DisplayName} révoqué.");
}
// ------------------------------------------------------------------ Chargement
protected override async Task ReloadAsync()
{
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
_summaries = admins;
BuildAdmins();
Invitations = invitations.Select(i => new InvitationItem(this, i, canDelete: true)).ToList();
OnPropertyChanged(nameof(RevokedText));
OnPropertyChanged(nameof(SummaryText));
}
private void BuildAdmins()
{
var me = Context.Session.Profile;
// À traiter d'abord : comptes en attente, puis comptes actifs (vous en premier), puis révoqués.
Admins = _summaries
.Where(a => _showRevoked || a.Status != AdminStatus.Revoked)
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenBy(a => a.Id == me.Id ? 0 : 1)
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId, RoleChoices))
.ToList();
}
}
/// <summary>Compte d'un administrateur et ses appareils.</summary>
public sealed class AdminAccountItem : ObservableObject
{
private readonly IReadOnlyList<Choice> _roles;
private Choice _selectedRole;
internal AdminAccountItem(AdminsPanelViewModel page, AdminSummary admin, Guid myId, string myKeyId, IReadOnlyList<Choice> roles)
{
_roles = roles;
Id = admin.Id;
DisplayName = admin.DisplayName;
Initials = AdminTexts.Initials(admin.DisplayName);
Role = admin.Role;
Status = admin.Status;
IsYou = admin.Id == myId;
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
_selectedRole = roles.FirstOrDefault(r => r.Value == admin.Role) ?? roles[^1];
var hasActiveKey = admin.Keys.Any(k => k.Status == AdminStatus.Active);
// Premier appareil (compte en attente) : approuvé par un propriétaire. Nouvel appareil d'un compte
// actif : par son titulaire seulement, depuis un de ses appareils actifs (règle du serveur).
string? hint = null;
var canApprove = !IsRevoked && (Status == AdminStatus.Pending || IsYou);
if (!canApprove && Status == AdminStatus.Active)
hint = hasActiveKey
? $"Nouvel appareil : {DisplayName} l'approuve lui-même depuis un de ses appareils actifs (page « Mes appareils »)."
: $"{DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), ou révoquez ce compte et invitez-le de nouveau.";
Keys = admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(page, admin.Id, admin.DisplayName, k, IsYou && k.KeyId == myKeyId,
canApprove, hint, canRevoke: !IsRevoked,
IsYou ? AccountTexts.OwnPendingText : $"Empreinte affichée sur le nouvel appareil : faites-la-vous dicter par {admin.DisplayName}."))
.ToList();
ChangeRoleCommand = new AsyncRelayCommand(() => page.ChangeRoleAsync(this), () => CanChangeRole && RoleChanged);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeAdminAsync(this), () => CanRevoke);
}
public Guid Id { get; }
public string DisplayName { get; }
public string Initials { get; }
public string Role { get; }
public string RoleText => AdminTexts.Role(Role);
public string Status { get; }
public string StatusText => AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsYou { get; }
public string CreatedText { get; }
public IReadOnlyList<AdminKeyItem> Keys { get; }
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
public bool CanChangeRole => !IsYou && !IsRevoked;
public bool CanRevoke => !IsYou && !IsRevoked;
public IReadOnlyList<Choice> RoleChoices => _roles;
public Choice SelectedRole
{
get => _selectedRole;
set
{
if (value is null || !SetProperty(ref _selectedRole, value)) return;
OnPropertyChanged(nameof(RoleChanged));
ChangeRoleCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
public bool RoleChanged => _selectedRole.Value != Role;
public AsyncRelayCommand ChangeRoleCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
internal void ResetRole() => SelectedRole = _roles.FirstOrDefault(r => r.Value == Role) ?? _selectedRole;
}
@@ -0,0 +1,81 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Mes appareils » (tout administrateur connecté) : ses clés, l'ajout d'un nouveau PC (invitation
/// « nouvel appareil », puis approbation ici avec l'empreinte affichée sur le nouveau PC), ses invitations.
/// Personne d'autre ne peut approuver un appareil à son nom (règle du serveur).
/// </summary>
public sealed class MyDevicesPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.devices";
private IReadOnlyList<AdminKeyItem> _devices = Array.Empty<AdminKeyItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
public MyDevicesPanelViewModel(IAdminContext context) : base(context)
{
AddDeviceCommand = new AsyncRelayCommand(AddDeviceAsync, () => !IsBusy && !HasCreatedInvitation);
}
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
public IReadOnlyList<AdminKeyItem> Devices
{
get => _devices;
private set => SetProperty(ref _devices, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
public AsyncRelayCommand AddDeviceCommand { get; }
protected override void OnCommandsChanged() => AddDeviceCommand.RaiseCanExecuteChanged();
private async Task AddDeviceAsync()
{
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Context.Session.Profile.Id })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
ShowInvitation(created!, "Invitation pour votre nouvel appareil",
"Sur le nouveau PC : launcher › menu d'administration › « J'ai une invitation », puis collez ce code. Le nouveau PC " +
"affiche ensuite une empreinte : revenez ici, saisissez-la sous l'appareil en attente et approuvez-le.");
await RunAsync(ReloadAsync);
}
protected override async Task ReloadAsync()
{
var account = await Context.Session.GetMyAccountAsync();
var me = Context.Session.Profile;
Devices = account.Admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(this, account.Admin.Id, account.Admin.DisplayName, k, k.KeyId == me.KeyId,
canApprove: true, approveHint: null, canRevoke: CanRevokeDevices, AccountTexts.OwnPendingText))
.ToList();
Invitations = account.Invitations
.Select(i => new InvitationItem(this, i, canDelete: i.CreatedById == me.Id || CanRevokeDevices))
.ToList();
}
}
@@ -0,0 +1,21 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>Module « Audit » : page « Journal d'audit » (permission audit.read : propriétaires, administrateurs).</summary>
public sealed class AuditAdminModule : ILauncherModule
{
public string Id => "audit";
public string DisplayName => "Audit";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
AuditPanelViewModel.PanelId,
"Journal d'audit",
"Connexions, publications, modifications et refus enregistrés par le serveur, les plus récents d'abord.",
"History",
AdminPermissions.AuditRead,
Order: 60,
context => new AuditPanelViewModel(context)));
}
@@ -0,0 +1,332 @@
using System.Globalization;
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
/// d'abord, chargées par pages de 200 ; filtres (texte, catégorie, résultat) appliqués aux entrées
/// chargées ; export CSV de ce qui est affiché.
/// </summary>
public sealed class AuditPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "audit.read";
/// <summary>Entrées par page (maximum accepté par le serveur).</summary>
public const int PageSize = 200;
/// <summary>Au-delà, « Charger plus » s'arrête (mémoire du launcher) : utiliser journalctl ou les fichiers du VPS.</summary>
public const int MaxLoaded = 5000;
/// <summary>Entrées affichées au plus (la page n'est pas virtualisée) ; l'export reprend toutes celles filtrées.</summary>
public const int MaxDisplayed = 500;
private readonly List<AuditEntry> _loaded = new();
private IReadOnlyList<AuditItem> _filtered = Array.Empty<AuditItem>();
private string? _nextCursor;
private IReadOnlyList<AuditItem> _entries = Array.Empty<AuditItem>();
private string _searchText = "";
private Choice _category;
private Choice _result;
private string? _lastRefresh;
private bool _hasLoaded;
public AuditPanelViewModel(IAdminContext context) : base(context)
{
Categories = new List<Choice>
{
new(null, "Toutes les actions"),
new("auth.", "Connexions"),
new("admins.", "Administrateurs"),
new("cli.", "Outil du VPS (ligne de commande)"),
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
{
new(null, "Tous les résultats"),
new(AuditResults.Ok, "Réussis"),
new(AuditResults.Denied, "Refusés")
};
_category = Categories[0];
_result = Results[0];
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
}
public IReadOnlyList<Choice> Categories { get; }
public IReadOnlyList<Choice> Results { get; }
public IReadOnlyList<AuditItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(EmptyText));
OnPropertyChanged(nameof(TruncatedText));
OnPropertyChanged(nameof(IsTruncated));
ExportCommand.RaiseCanExecuteChanged();
}
}
public bool HasEntries => _entries.Count > 0;
public bool IsTruncated => _filtered.Count > _entries.Count;
public string TruncatedText => IsTruncated
? $"Seules les {MaxDisplayed} premières entrées sont affichées : affinez la recherche, ou exportez les {_filtered.Count} entrées filtrées."
: "";
/// <summary>Message quand rien n'est affiché (vide tant que rien n'a été chargé : l'erreur éventuelle suffit).</summary>
public string EmptyText => !_hasLoaded || HasEntries ? ""
: _loaded.Count == 0 ? "Le journal est vide."
: "Aucune entrée chargée ne correspond aux filtres" + (CanLoadMore ? " : chargez des entrées plus anciennes." : ".");
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplyFilters();
}
}
public Choice Category
{
get => _category;
set
{
if (value is not null && SetProperty(ref _category, value)) ApplyFilters();
}
}
public Choice Result
{
get => _result;
set
{
if (value is not null && SetProperty(ref _result, value)) ApplyFilters();
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
public string SummaryText
{
get
{
if (_loaded.Count == 0) return "";
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
: ".");
}
}
public bool CanLoadMore => _nextCursor is not null && _loaded.Count < MaxLoaded;
public string? LastRefresh
{
get => _lastRefresh;
private set => SetProperty(ref _lastRefresh, value);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public RelayCommand ClearFiltersCommand { get; }
/// <summary>Exporte les entrées filtrées (toutes, pas seulement celles affichées).</summary>
public AsyncRelayCommand ExportCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
ExportCommand.RaiseCanExecuteChanged();
}
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync(reset: true)) : Task.CompletedTask;
private async Task LoadAsync(bool reset)
{
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
if (reset) _loaded.Clear();
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
_hasLoaded = true;
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
OnPropertyChanged(nameof(CanLoadMore));
LoadMoreCommand.RaiseCanExecuteChanged();
ApplyFilters();
}
private void ClearFilters()
{
_searchText = "";
_category = Categories[0];
_result = Results[0];
OnPropertyChanged(nameof(SearchText));
OnPropertyChanged(nameof(Category));
OnPropertyChanged(nameof(Result));
ApplyFilters();
}
private void ApplyFilters()
{
var text = _searchText.Trim();
_filtered = _loaded
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
.Where(e => _result.Value is null || e.Result == _result.Value)
.Select(e => new AuditItem(e))
.Where(item => text.Length == 0 || item.Matches(text))
.ToList();
Entries = _filtered.Count > MaxDisplayed ? _filtered.Take(MaxDisplayed).ToList() : _filtered;
OnPropertyChanged(nameof(HasFilters));
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
ClearFiltersCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Export
private async Task ExportAsync()
{
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
$"journal-audit-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
ModuleMessageKind.Success);
}
}
/// <summary>Entrée du journal telle qu'affichée.</summary>
public sealed class AuditItem
{
internal AuditItem(AuditEntry entry)
{
Entry = entry;
TimeText = entry.Timestamp.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
ActionText = AuditTexts.Action(entry.Action);
ResultText = AuditTexts.Result(entry.Result);
ActorText = entry.ActorName ?? (entry.KeyId is null ? "Inconnu" : "Clé inconnue");
var parts = new List<string>();
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
var origin = new List<string>();
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
}
public AuditEntry Entry { get; }
public string TimeText { get; }
public string ActionText { get; }
/// <summary>Code technique (« admins.key_approve »), en infobulle.</summary>
public string ActionCode => Entry.Action;
public string Result => Entry.Result;
public string ResultText { get; }
public bool IsOk => Entry.Result == AuditResults.Ok;
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
/// <summary>Recherche dans tout ce qui est affiché (sans tenir compte de la casse).</summary>
internal bool Matches(string text) =>
new[] { TimeText, ActionText, Entry.Action, ResultText, ActorText, DetailText, OriginText }
.Any(value => value.Contains(text, StringComparison.CurrentCultureIgnoreCase));
}
/// <summary>Libellés du journal (français) et export CSV.</summary>
public static class AuditTexts
{
public static string Action(string action) => action switch
{
"auth.login" => "Connexion",
"auth.logout" => "Déconnexion",
"admins.enroll" => "Appareil enregistré avec une invitation",
"admins.invite" => "Invitation créée",
"admins.invite_deleted" => "Invitation supprimée",
"admins.key_approve" => "Appareil approuvé",
"admins.key_revoke" => "Appareil révoqué",
"admins.role" => "Rôle modifié",
"admins.revoke" => "Compte révoqué",
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
"cli.add_key" => "Clé ajoutée sur le VPS",
"cli.revoke_admin" => "Compte révoqué sur le VPS",
"cli.revoke_key" => "Clé révoquée sur le VPS",
"catalog.publish" => "Informations des jeux publiées",
"changelog.edit" => "Notes de version modifiées",
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"request.signature" => "Requête signée refusée",
"audit.suppressed" => "Tentatives refusées regroupées",
_ => action
};
public static string Result(string result) => result switch
{
AuditResults.Ok => "Réussi",
AuditResults.Denied => "Refusé",
AuditResults.Failed => "Échec",
_ => result
};
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
/// <summary>
/// CSV (séparateur « ; », pour Excel en français). Les valeurs viennent en partie de requêtes anonymes ou
/// de saisies (nom du client, de l'appareil…) : une cellule qui commencerait par = + - @ serait interprétée
/// comme une formule par le tableur ; elle est donc précédée d'une apostrophe, et toutes les cellules sont
/// entre guillemets (un tableur qui sépare par des virgules ne peut pas couper une valeur en deux).
/// </summary>
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
}
private static string Cell(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
value = value.Replace("\r", " ").Replace("\n", " ");
var first = value.TrimStart(' ');
if (value[0] == '\t' || (first.Length > 0 && first[0] is '=' or '+' or '-' or '@')) value = "'" + value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}
@@ -1,4 +1,5 @@
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -28,8 +29,18 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
}
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
public bool CanOpenAdmins => Context.Panels.Any(p => p.Id == AdminsPanelViewModel.PanelId);
public bool CanOpenDevices => Context.Panels.Any(p => p.Id == MyDevicesPanelViewModel.PanelId);
public RelayCommand OpenAdminsCommand { get; }
public RelayCommand OpenDevicesCommand { get; }
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
+5 -1
View File
@@ -15,6 +15,8 @@ using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Platform;
using GameLauncher.Services;
@@ -508,7 +510,9 @@ public partial class App : Application, ILauncherRestartHost
new AdminModule(),
new CatalogAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule()
new PublishAdminModule(),
new AccountsAdminModule(),
new AuditAdminModule()
});
/// <summary>
+473 -6
View File
@@ -4,7 +4,9 @@
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin"
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin"
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin">
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin"
xmlns:aacc="clr-namespace:GameLauncher.Modules.Admin.Accounts;assembly=GameLauncher.Modules.Admin"
xmlns:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;assembly=GameLauncher.Modules.Admin">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
@@ -303,7 +305,10 @@
</Border>
</StackPanel>
<TextBlock Text="{Binding KeyText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,12" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
<Button Style="{StaticResource LinkButtonStyle}" Content="Mes appareils" HorizontalAlignment="Left" Margin="0,6,0,12"
FontSize="12" Command="{Binding OpenDevicesCommand}"
Visibility="{Binding CanOpenDevices, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Permissions}" Focusable="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -336,10 +341,15 @@
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path x:Name="PendingIcon" Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.AccountKey}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0" VerticalAlignment="Center">
<TextBlock Text="Administrateurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock x:Name="PendingText" Text="{Binding PendingText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
<DockPanel Grid.Column="1" Margin="14,0,0,0" VerticalAlignment="Center">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.AccountGroup}" Command="{Binding OpenAdminsCommand}"
Visibility="{Binding CanOpenAdmins, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="Administrateurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock x:Name="PendingText" Text="{Binding PendingText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
</Grid>
</Border>
@@ -1158,4 +1168,461 @@
</StackPanel>
</DataTemplate>
<!-- ============================================================ Pages « Mes appareils » et « Administrateurs » -->
<!-- Code d'une invitation tout juste créée : affiché une seule fois (le serveur n'en garde que l'empreinte) -->
<DataTemplate DataType="{x:Type aacc:InvitationCodeViewModel}">
<Border Style="{StaticResource AdminCardStyle}" BorderBrush="{DynamicResource Accent2Brush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.TicketAccount}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="{Binding Title}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding Instructions}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,12" />
<Border CornerRadius="12" Padding="16,12" BorderThickness="1" Background="{DynamicResource SurfaceAltBrush}"
BorderBrush="{DynamicResource BorderStrongBrush}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Copier" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyCommand}" />
<TextBlock Text="{Binding Code}" Style="{StaticResource FingerprintTextStyle}" FontSize="19" TextWrapping="Wrap"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0" Visibility="{Binding Copied, Converter={StaticResource BoolToVisibility}}">
<Path Width="12" Height="12" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Check}"
VerticalAlignment="Center" />
<TextBlock Text="Copié dans le presse-papiers." FontSize="12" Margin="6,0,0,0" Foreground="{DynamicResource SuccessBrush}" />
</StackPanel>
<DockPanel Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="J'ai transmis le code" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding DismissCommand}"
ToolTip="Le code disparaît de l'écran : il ne pourra plus être affiché" />
<TextBlock Text="{Binding ExpiresText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
<!-- Appareil (clé) d'un administrateur -->
<DataTemplate DataType="{x:Type aacc:AdminKeyItem}">
<Border x:Name="Bd" CornerRadius="12" Padding="14,10" Margin="0,0,0,8" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Révoquer" Margin="12,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.Delete}"
Command="{Binding RevokeCommand}" ToolTip="Cet appareil ne pourra plus se connecter (définitif)"
Visibility="{Binding CanRevoke, Converter={StaticResource BoolToVisibility}}" />
<Border Width="32" Height="32" CornerRadius="10" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path x:Name="DeviceIcon" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.Laptop}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,10,0" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="8,1" VerticalAlignment="Center">
<TextBlock x:Name="StatusText" Text="{Binding StatusText}" FontSize="10.5" FontWeight="SemiBold"
Foreground="{DynamicResource SuccessBrush}" />
</Border>
</WrapPanel>
<TextBlock Text="{Binding Fingerprint}" FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="13" Margin="0,4,0,0"
ToolTip="Empreinte de la clé de cet appareil"
Visibility="{Binding Fingerprint, Converter={StaticResource StringToVisibility}}" />
<TextBlock Text="{Binding FingerprintText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Visibility="{Binding IsPending, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,3,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
<!-- Approbation : empreinte dictée par la personne -->
<StackPanel Margin="44,12,0,0" Visibility="{Binding CanApprove, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Empreinte dictée par la personne (5 groupes de 4 caractères)" Style="{StaticResource AdminFieldLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Approuver" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.ShieldCheck}" Command="{Binding ApproveCommand}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="64" FontFamily="Cascadia Mono, Consolas, Courier New"
Text="{Binding FingerprintInput, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Exemple : AB12 CD34 EF56 7890 1A2B (espaces facultatifs)" />
</DockPanel>
</StackPanel>
<TextBlock Text="{Binding ApproveHint}" Style="{StaticResource AdminHintStyle}" Margin="44,8,0,0"
Visibility="{Binding HasApproveHint, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsPending}" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRevoked}" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.6" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsCurrentSession}" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Invitation en cours -->
<DataTemplate DataType="{x:Type aacc:InvitationItem}">
<DockPanel Margin="0,0,0,10">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding DeleteCommand}"
ToolTip="Supprimer l'invitation : son code ne pourra plus être utilisé"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}" />
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.TicketAccount}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<StackPanel Margin="12,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="12.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</DataTemplate>
<!-- Page « Mes appareils » -->
<DataTemplate DataType="{x:Type aacc:MyDevicesPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
Visibility="{Binding HasCreatedInvitation, Converter={StaticResource BoolToVisibility}}" />
<!-- Appareils -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel Margin="0,0,0,14">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Ajouter un appareil" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.KeyPlus}" Command="{Binding AddDeviceCommand}"
ToolTip="Crée une invitation « nouvel appareil » à utiliser sur l'autre PC" />
<Border Style="{StaticResource AdminAvatarStyle}">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock FontSize="14" FontWeight="SemiBold">
<Run Text="{Binding DisplayName, Mode=OneWay}" />
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
<Run Text="{Binding RoleText, Mode=OneWay}" Foreground="{DynamicResource Accent2Brush}" />
</TextBlock>
<TextBlock Text="Chaque PC a sa propre clé : en révoquer un n'affecte pas les autres."
Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" IsEnabled="{Binding CanEdit}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="Appareil perdu ou volé : demandez à un propriétaire de le révoquer (page « Administrateurs »)."
Visibility="{Binding CanRevokeDevices, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
</Border>
<!-- Invitations « nouvel appareil » -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasInvitations, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Invitations en cours" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Codes pas encore utilisés sur un nouvel appareil (valables 24 h)." Style="{StaticResource AdminHintStyle}"
Margin="0,3,0,12" />
<ItemsControl ItemsSource="{Binding Invitations}" Focusable="False" IsEnabled="{Binding CanEdit}" />
</StackPanel>
</Border>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Nouveau PC : « Ajouter un appareil », code saisi sur le nouveau PC (« J'ai une invitation »), puis approbation ici avec l'empreinte qu'il affiche." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- Compte d'un administrateur (page « Administrateurs ») -->
<DataTemplate DataType="{x:Type aacc:AdminAccountItem}">
<Border x:Name="Card" Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel>
<!-- Rôle et révocation -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0" VerticalAlignment="Center"
Visibility="{Binding CanChangeRole, Converter={StaticResource BoolToVisibility}}">
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="170" ItemsSource="{Binding RoleChoices}"
SelectedItem="{Binding SelectedRole, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Rôle" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Appliquer" Margin="8,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding ChangeRoleCommand}"
Visibility="{Binding RoleChanged, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" Margin="8,0,0,0"
Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}"
Command="{Binding RevokeCommand}" ToolTip="Révoquer ce compte (sessions fermées, appareils révoqués ; définitif)" />
</StackPanel>
<Border Style="{StaticResource AdminAvatarStyle}" VerticalAlignment="Top">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding DisplayName}" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,10,0" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="8,1" VerticalAlignment="Center">
<TextBlock Text="{Binding RoleText}" FontSize="10.5" FontWeight="SemiBold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
<Border x:Name="StatusChip" Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="8,1" VerticalAlignment="Center"
Visibility="Collapsed">
<TextBlock x:Name="StatusText" Text="{Binding StatusText}" FontSize="10.5" FontWeight="SemiBold" />
</Border>
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="8,1" VerticalAlignment="Center"
Visibility="{Binding IsYou, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Vous" FontSize="10.5" FontWeight="SemiBold" />
</Border>
</WrapPanel>
<TextBlock Text="{Binding CreatedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" Margin="0,14,0,0" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsPending}" Value="True">
<Setter TargetName="Card" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StatusChip" Property="Visibility" Value="Visible" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRevoked}" Value="True">
<Setter TargetName="Card" Property="Opacity" Value="0.6" />
<Setter TargetName="StatusChip" Property="Visibility" Value="Visible" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Page « Administrateurs » -->
<DataTemplate DataType="{x:Type aacc:AdminsPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
Visibility="{Binding HasCreatedInvitation, Converter={StaticResource BoolToVisibility}}" />
<!-- Nouvelle invitation -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.EmailOutline}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Inviter un administrateur" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Le code créé est à transmettre à la personne ; son appareil apparaîtra ensuite ci-dessous, à approuver avec l'empreinte qu'elle vous dictera." />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="190" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Nom" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="64" IsEnabled="{Binding CanEdit}"
Text="{Binding NewName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Rôle" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding RoleChoices}" IsEnabled="{Binding CanEdit}"
SelectedItem="{Binding NewRole, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource AccentButtonStyle}" Content="Créer l'invitation" Margin="10,0,0,0"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.TicketAccount}" Command="{Binding InviteCommand}" />
</Grid>
<TextBlock Text="{Binding NewRoleHint}" Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0" />
</StackPanel>
</Grid>
</Border>
<!-- Invitations en cours -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasInvitations, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Invitations en cours" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Codes pas encore utilisés (valables 24 h). Supprimez celles qui n'ont plus lieu d'être."
Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12" />
<ItemsControl ItemsSource="{Binding Invitations}" Focusable="False" IsEnabled="{Binding CanEdit}" />
</StackPanel>
</Border>
<!-- Comptes -->
<DockPanel Margin="2,4,0,12">
<CheckBox DockPanel.Dock="Right" Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding ShowRevoked, Mode=TwoWay}"
FontSize="12.5" VerticalAlignment="Center">
<TextBlock Text="{Binding RevokedText}" />
</CheckBox>
<TextBlock VerticalAlignment="Center">
<Run Text="Comptes" FontSize="15" FontWeight="SemiBold" />
<Run Text="{Binding SummaryText, Mode=OneWay}" FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Admins}" Focusable="False" IsEnabled="{Binding CanEdit}" />
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Toutes ces actions sont signées par votre clé et inscrites au journal d'audit. Il reste toujours au moins un propriétaire actif." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Journal d'audit » -->
<DataTemplate DataType="{x:Type aaud:AuditItem}">
<Border x:Name="Row" CornerRadius="10" Padding="12,8" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Path x:Name="ResultIcon" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,0,0"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<StackPanel Grid.Column="1" Margin="12,0,12,0">
<TextBlock TextWrapping="Wrap">
<Run Text="{Binding ActionText, Mode=OneWay}" FontSize="12.5" FontWeight="SemiBold" />
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
<Run Text="{Binding ActorText, Mode=OneWay}" FontSize="12.5" />
</TextBlock>
<TextBlock Text="{Binding DetailText}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" Visibility="{Binding HasDetail, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding OriginText}" FontSize="11" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" Visibility="{Binding OriginText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Right">
<TextBlock Text="{Binding TimeText}" FontSize="11.5" HorizontalAlignment="Right" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock x:Name="ResultText" Text="{Binding ResultText}" FontSize="11" FontWeight="SemiBold" HorizontalAlignment="Right"
Margin="0,2,0,0" Foreground="{DynamicResource SuccessBrush}" ToolTip="{Binding ActionCode}" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsDenied}" Value="True">
<Setter TargetName="ResultIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ResultIcon" Property="Data" Value="{DynamicResource Icon.Alert}" />
<Setter TargetName="ResultText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsFailed}" Value="True">
<Setter TargetName="ResultIcon" Property="Fill" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="ResultIcon" Property="Data" Value="{DynamicResource Icon.AlertCircle}" />
<Setter TargetName="ResultText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type aaud:AuditPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Filtres -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="200" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (nom, adresse IP, clé, version…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="100"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Catégorie" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Categories}"
SelectedItem="{Binding Category, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Résultat" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Results}"
SelectedItem="{Binding Result, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<Button Grid.Column="3" Style="{StaticResource IconButtonStyle}" Width="34" Height="34" Padding="9" Margin="8,0,0,0"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.Close}" ToolTip="Effacer les filtres"
Command="{Binding ClearFiltersCommand}" />
</Grid>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportCommand}"
ToolTip="Enregistre les entrées filtrées dans un fichier CSV (Excel)" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
</StackPanel>
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- Entrées -->
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
<ItemsControl ItemsSource="{Binding Entries}" Focusable="False" />
<Border Style="{StaticResource AdminInfoBannerStyle}" Margin="0,8,0,8" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding IsTruncated, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding TruncatedText}" TextWrapping="Wrap" FontSize="12.5" />
</Border>
<DockPanel Margin="0,8,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Charger des entrées plus anciennes" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.History}" Command="{Binding LoadMoreCommand}"
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
</ResourceDictionary>
+2
View File
@@ -90,5 +90,7 @@
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
</ResourceDictionary>
@@ -0,0 +1,264 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Pages « Administrateurs » et « Mes appareils » du menu d'administration.</summary>
public class AccountsPanelTests
{
private static readonly ModuleCatalog Modules =
new(new ILauncherModule[] { new AdminModule(), new AccountsAdminModule(), new AuditAdminModule() });
private static async Task<(AdminShellViewModel Shell, AdminWorkspaceViewModel Workspace)> OpenAsync(TestApi api, string folder, FakeDialogs dialogs)
{
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
return (shell, Assert.IsType<AdminWorkspaceViewModel>(shell.Screen));
}
private static async Task<T> PageAsync<T>(AdminWorkspaceViewModel workspace, string panelId) where T : AdminPanelViewModel
{
workspace.Navigate(panelId);
await workspace.Activation;
return Assert.IsType<T>(workspace.SelectedPanel!.ViewModel);
}
[Fact]
public async Task An_owner_invites_approves_by_fingerprint_changes_roles_and_revokes()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<AdminsPanelViewModel>(workspace, AdminsPanelViewModel.PanelId);
// Son propre compte : ni rôle à changer, ni révocation, ni révocation de la clé de la session en cours.
var me = Assert.Single(page.Admins);
Assert.True(me.IsYou);
Assert.False(me.CanChangeRole);
Assert.False(me.CanRevoke);
Assert.True(Assert.Single(me.Keys).IsCurrentSession);
Assert.False(me.Keys[0].CanRevoke);
// Invitation : code affiché une seule fois, copiable, puis oublié.
Assert.False(page.InviteCommand.CanExecute(null));
page.NewName = " Paul ";
page.NewRole = page.RoleChoices.First(r => r.Value == AdminRoles.Admin);
await page.InviteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var code = page.CreatedInvitation!.Code;
Assert.Contains("Paul", page.CreatedInvitation.Title);
Assert.Equal("", page.NewName);
Assert.Equal("Paul · Administrateur", Assert.Single(page.Invitations).Title);
page.CreatedInvitation.CopyCommand.Execute(null);
Assert.Equal(code, dialogs.Clipboard);
Assert.True(page.CreatedInvitation.Copied);
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.False(page.HasCreatedInvitation);
// Paul enregistre sa clé ; son compte apparaît en premier, à approuver.
using var paulKey = TestApi.NewKey();
var enrolled = await api.CreateClient().EnrollAsync(code, paulKey, "PC de Paul");
await page.RefreshCommand.ExecuteAsync();
var paul = page.Admins[0];
Assert.Equal("Paul", paul.DisplayName);
Assert.True(paul.IsPending);
var paulDevice = Assert.Single(paul.Keys);
Assert.True(paulDevice.CanApprove);
Assert.Null(paulDevice.Fingerprint); // masquée : à se faire dicter
Assert.Empty(page.Invitations); // code utilisé
// Empreinte incomplète : refusée avant tout envoi ; empreinte fausse : refusée par le serveur.
var confirmations = dialogs.Confirmations.Count;
paulDevice.FingerprintInput = "AB12 CD34";
await paulDevice.ApproveCommand.ExecuteAsync();
Assert.Contains("incomplète", page.ErrorMessage);
Assert.Equal(confirmations, dialogs.Confirmations.Count);
paulDevice.FingerprintInput = "0000 0000 0000 0000 0000";
await paulDevice.ApproveCommand.ExecuteAsync();
Assert.Contains("ne correspond pas", page.ErrorMessage);
Assert.True(page.Admins[0].IsPending);
page.Admins[0].Keys[0].FingerprintInput = enrolled.Fingerprint.ToLowerInvariant().Replace(" ", "");
await page.Admins[0].Keys[0].ApproveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("approuvé", page.StatusMessage);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.False(paul.IsPending);
Assert.NotNull(paul.Keys[0].Fingerprint);
// Nouvel appareil de Paul (compte actif) : c'est à lui de l'approuver, pas au propriétaire.
await using (var paulSession = await api.LoginAsync(paulKey))
{
var device = await paulSession.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paul.Id });
await api.CreateClient().EnrollAsync(device.Code, TestApi.NewKey(), "PC fixe de Paul");
}
await page.RefreshCommand.ExecuteAsync();
var paulPending = page.Admins.Single(a => a.DisplayName == "Paul").Keys[0];
Assert.True(paulPending.IsPending);
Assert.False(paulPending.CanApprove);
Assert.True(paulPending.HasApproveHint);
Assert.Contains("lui-même", paulPending.ApproveHint);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
// Rôle : confirmation refusée → rien ne change ; acceptée → appliqué.
paul.SelectedRole = paul.RoleChoices.First(r => r.Value == AdminRoles.Owner);
Assert.True(paul.RoleChanged);
dialogs.ConfirmAnswer = false;
await paul.ChangeRoleCommand.ExecuteAsync();
Assert.Equal(AdminRoles.Admin, paul.SelectedRole.Value);
dialogs.ConfirmAnswer = true;
paul.SelectedRole = paul.RoleChoices.First(r => r.Value == AdminRoles.Editor);
await paul.ChangeRoleCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.Equal("Éditeur", paul.RoleText);
// Révocation du compte : masqué par défaut, visible avec « comptes révoqués », plus aucune action.
await paul.RevokeCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.DoesNotContain(page.Admins, a => a.DisplayName == "Paul");
Assert.Contains("(1)", page.RevokedText);
page.ShowRevoked = true;
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.True(paul.IsRevoked);
Assert.False(paul.CanRevoke);
Assert.False(paul.CanChangeRole);
Assert.False(paul.Keys[0].CanRevoke);
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(paulKey));
// Invitation supprimée.
page.NewName = "Julie";
await page.InviteCommand.ExecuteAsync();
await Assert.Single(page.Invitations).DeleteCommand.ExecuteAsync();
Assert.Empty(page.Invitations);
await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().EnrollAsync(page.CreatedInvitation!.Code, TestApi.NewKey(), null));
}
[Fact]
public async Task An_editor_adds_and_approves_a_new_pc_from_their_devices_page()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path, "paul");
using var _ = signer;
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
var first = await api.CreateClient().EnrollAsync(invitation.Code, signer, "Portable");
await owner.ApproveKeyAsync(first.AdminId, signer.KeyId, first.Fingerprint);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
// Un éditeur : sa page « Mes appareils », ni la gestion des administrateurs ni le journal d'audit.
Assert.Contains(workspace.PanelItems, p => p.Descriptor.Id == MyDevicesPanelViewModel.PanelId);
Assert.DoesNotContain(workspace.PanelItems, p => p.Descriptor.Id == AdminsPanelViewModel.PanelId);
Assert.DoesNotContain(workspace.PanelItems, p => p.Descriptor.Id == AuditPanelViewModel.PanelId);
var page = await PageAsync<MyDevicesPanelViewModel>(workspace, MyDevicesPanelViewModel.PanelId);
Assert.Equal("Paul", page.DisplayName);
Assert.False(page.CanRevokeDevices);
var laptop = Assert.Single(page.Devices);
Assert.True(laptop.IsCurrentSession);
Assert.False(laptop.CanRevoke);
Assert.False(laptop.CanApprove);
// Nouvel appareil : invitation, code saisi sur le nouveau PC, approbation ici.
await page.AddDeviceCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var code = page.CreatedInvitation!.Code;
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.True(Assert.Single(page.Invitations).CanDelete);
using var desktopKey = TestApi.NewKey();
var desktop = await api.CreateClient().EnrollAsync(code, desktopKey, "PC fixe");
await page.RefreshCommand.ExecuteAsync();
var pending = page.Devices[0];
Assert.Equal("PC fixe", pending.Label);
Assert.True(pending.CanApprove);
Assert.Empty(page.Invitations);
pending.FingerprintInput = desktop.Fingerprint;
await pending.ApproveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.All(page.Devices, d => Assert.True(d.IsActive));
await (await api.LoginAsync(desktopKey)).DisposeAsync();
// Ses invitations se suppriment ; celle créée par un propriétaire pour lui, non (le serveur refuserait).
await page.AddDeviceCommand.ExecuteAsync();
await owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = first.AdminId });
await page.RefreshCommand.ExecuteAsync();
Assert.Equal(new[] { true, false }, page.Invitations.Select(i => i.CanDelete).ToArray());
await page.Invitations[0].DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(Assert.Single(page.Invitations).CanDelete);
}
[Fact]
public async Task An_owner_adds_then_revokes_one_of_their_own_pcs()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<MyDevicesPanelViewModel>(workspace, MyDevicesPanelViewModel.PanelId);
Assert.True(page.CanRevokeDevices);
await page.AddDeviceCommand.ExecuteAsync();
Assert.False(page.AddDeviceCommand.CanExecute(null)); // code affiché, pas encore transmis
var code = page.CreatedInvitation!.Code;
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.True(page.AddDeviceCommand.CanExecute(null));
using var spareKey = TestApi.NewKey();
var spare = await api.CreateClient().EnrollAsync(code, spareKey, "PC de secours");
await page.RefreshCommand.ExecuteAsync();
Assert.Contains("nouveau PC", page.Devices[0].FingerprintText);
page.Devices[0].FingerprintInput = spare.Fingerprint;
await page.Devices[0].ApproveCommand.ExecuteAsync();
Assert.Contains("approuvé", page.StatusMessage);
// Révoquer le PC de secours (pas celui de la session en cours) ; le message précédent disparaît.
var device = page.Devices.Single(d => d.Label == "PC de secours");
Assert.True(device.CanRevoke);
Assert.False(page.Devices.Single(d => d.IsCurrentSession).CanRevoke);
await device.RevokeCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("révoqué", page.StatusMessage);
Assert.True(page.Devices.Single(d => d.Label == "PC de secours").IsRevoked);
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(spareKey));
}
[Fact]
public async Task The_dashboard_leads_to_the_accounts_pages()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var (shell, workspace) = await OpenAsync(api, folder.Path, new FakeDialogs());
await using var __ = shell;
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.True(dashboard.CanOpenAdmins);
dashboard.OpenAdminsCommand.Execute(null);
await workspace.Activation;
Assert.IsType<AdminsPanelViewModel>(workspace.SelectedPanel!.ViewModel);
workspace.Navigate(DashboardPanelViewModel.PanelId);
dashboard.OpenDevicesCommand.Execute(null);
await workspace.Activation;
Assert.IsType<MyDevicesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
}
}
@@ -0,0 +1,112 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Journal d'audit » du menu d'administration.</summary>
public class AuditPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new AuditAdminModule() });
[Fact]
public async Task The_journal_is_filtered_paged_and_exported()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
// Une connexion refusée (clé inconnue) et 260 invitations créées puis supprimées (520 entrées).
using var stranger = TestApi.NewKey();
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger));
await using (var owner = await api.LoginAsync(signer))
{
for (var i = 0; i < 260; i++)
{
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = $"Invité {i}", Role = AdminRoles.Editor });
await owner.DeleteInvitationAsync(invitation.Invitation.Id);
}
}
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(AuditPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<AuditPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Première page : 200 entrées, la plus récente d'abord (la connexion de cette fenêtre).
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(AuditPanelViewModel.PageSize, page.Entries.Count);
Assert.Equal("Connexion", page.Entries[0].ActionText);
Assert.True(page.Entries[0].IsOk);
Assert.True(page.CanLoadMore);
// Tout charger : au plus 500 entrées affichées, le reste signalé.
while (page.CanLoadMore) await page.LoadMoreCommand.ExecuteAsync();
Assert.Contains("début du journal", page.SummaryText);
Assert.Equal(AuditPanelViewModel.MaxDisplayed, page.Entries.Count);
Assert.True(page.IsTruncated);
// Filtres : catégorie, résultat, texte.
page.Category = page.Categories.First(c => c.Value == "auth.");
Assert.All(page.Entries, e => Assert.StartsWith("auth.", e.ActionCode));
page.Result = page.Results.First(r => r.Value == AuditResults.Denied);
var refused = Assert.Single(page.Entries);
Assert.Equal("Refusé", refused.ResultText);
Assert.Contains(stranger.KeyId, refused.OriginText);
page.ClearFiltersCommand.Execute(null);
Assert.False(page.HasFilters);
page.SearchText = "invité 42";
Assert.Equal(2, page.Entries.Count); // créée puis supprimée
Assert.All(page.Entries, e => Assert.Contains("Invité 42", e.DetailText));
page.SearchText = "aucune entrée ne contient ceci";
Assert.False(page.HasEntries);
Assert.Contains("Aucune entrée", page.EmptyText);
// Export : toutes les entrées filtrées (pas seulement les 500 affichées).
page.SearchText = "";
dialogs.NextSaveFile = folder.File("journal.csv");
await page.ExportCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var lines = File.ReadAllLines(dialogs.NextSaveFile);
Assert.StartsWith("Date;Action;Code;Résultat", lines[0]);
Assert.True(lines.Length - 1 > AuditPanelViewModel.MaxDisplayed, $"{lines.Length - 1} lignes exportées");
Assert.Contains(dialogs.Messages, m => m.StartsWith("Journal exporté"));
}
[Fact]
public void Exported_cells_are_never_spreadsheet_formulas()
{
var entry = new AuditEntry
{
Timestamp = DateTimeOffset.UnixEpoch,
Action = "auth.login",
Result = AuditResults.Denied,
KeyId = "=HYPERLINK(\"http://x\")",
Ip = "@SUM(A1)",
Target = " -2+3",
ActorName = "Paul,=1+1",
Details = "nom ; avec \"guillemets\"\nsur deux lignes"
};
var line = AuditTexts.ToCsv(new[] { new AuditItem(entry) }).Split(Environment.NewLine)[1];
Assert.Contains(";\"'@SUM(A1)\";", line);
Assert.Contains(";\"'=HYPERLINK(\"\"http://x\"\")\";", line);
Assert.Contains(";\"' -2+3\";", line);
// Toutes les cellules entre guillemets : un tableur qui sépare par des virgules ne coupe pas « Paul,=1+1 ».
Assert.Contains(";\"Paul,=1+1\";", line);
Assert.EndsWith(";\"nom ; avec \"\"guillemets\"\" sur deux lignes\"", line);
Assert.Equal("Tentatives refusées regroupées", AuditTexts.Action("audit.suppressed"));
Assert.Equal("action.inconnue", AuditTexts.Action("action.inconnue"));
Assert.True(AuditTexts.InCategory("request.signature", "security"));
Assert.False(AuditTexts.InCategory("auth.login", "security"));
}
}
@@ -251,6 +251,59 @@ public class AdminManagementTests
Assert.Equal("Paul", paul.Profile.DisplayName);
}
[Fact]
public async Task Every_admin_sees_their_own_account_and_withdraws_only_their_own_device_invitations()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (laptopKey, paulId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
await using var paul = await api.LoginAsync(laptopKey);
// Son compte, sans le droit de lire celui des autres.
var me = await paul.GetMyAccountAsync();
Assert.Equal(paulId, me.Admin.Id);
var laptop = Assert.Single(me.Admin.Keys);
Assert.Equal(laptopKey.KeyId, laptop.KeyId);
Assert.NotNull(laptop.Fingerprint);
Assert.Empty(me.Invitations);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.ListAdminsAsync())).Status);
// Ses invitations « nouvel appareil » (y compris celle d'un propriétaire), pas celles des autres.
var own = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
var byOwner = await owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
var other = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Autre", Role = AdminRoles.Editor });
me = await paul.GetMyAccountAsync();
Assert.Equal(new[] { own.Invitation.Id, byOwner.Invitation.Id }, me.Invitations.Select(i => i.Id).ToArray());
Assert.Equal(paulId, me.Invitations[0].CreatedById);
Assert.Equal(owner.Profile.Id, me.Invitations[1].CreatedById);
// Nouvel appareil enrôlé : en attente, empreinte masquée (lue sur le nouvel appareil).
using var desktopKey = TestApi.NewKey();
await api.CreateClient().EnrollAsync(own.Code, desktopKey, "PC fixe");
me = await paul.GetMyAccountAsync();
var pending = Assert.Single(me.Admin.Keys, k => k.KeyId == desktopKey.KeyId);
Assert.Equal(AdminStatus.Pending, pending.Status);
Assert.Null(pending.Fingerprint);
// Suppression : la sienne seulement ; un propriétaire supprime les autres.
var again = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
await paul.DeleteInvitationAsync(again.Invitation.Id);
var deleted = (await owner.GetAuditAsync(10)).Entries.First(e => e.Action == "admins.invite_deleted");
Assert.Equal("Paul", deleted.ActorName);
Assert.Equal("nouvel appareil pour Paul", deleted.Details);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(byOwner.Invitation.Id))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(other.Invitation.Id))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(Guid.NewGuid()))).Status);
await owner.DeleteInvitationAsync(byOwner.Invitation.Id);
Assert.Empty((await paul.GetMyAccountAsync()).Invitations);
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteInvitationAsync(byOwner.Invitation.Id))).Status);
// Compte révoqué : plus rien.
await owner.RevokeAdminAsync(paulId);
Assert.Equal(401, (await Assert.ThrowsAsync<ApiException>(() => paul.GetMyAccountAsync())).Status);
}
[Fact]
public async Task Admins_without_management_rights_have_few_invitations()
{