Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcc0b4f44 | ||
|
|
d0db02d7f3 | ||
|
|
63c2e37dad | ||
|
|
78780c20f1 | ||
|
|
3b95317d4c | ||
|
|
bb165c4be0 | ||
|
|
d2cb48b901 | ||
|
|
e06ddacf04 |
No files matched your search
@@ -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/` |
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user