Écran « Activité » sur GET /api/Audit, réservé au SuperAdmin comme la policy de l'endpoint (menuId 13, conditionné à role.value == 0). Filtres instance, type d'entité, utilisateur et plage de dates, pagination 50, clic sur une ligne pour le détail avant/après en table plutôt que le JSON brut. Les ids d'instance et d'utilisateur sont résolus en noms, un id absent de l'annuaire restant affiché tel quel : le journal doit rester lisible après la suppression de ce qu'il décrit. manager_api_new n'est pas touché. L'écran passe par un http.get direct au Bearer, comme _loadKnowledge / _loadInsights / _reindex du Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main, et le déclencheur de génération n'existe plus depuis le lot A. Côté utilisateurs, compteur « X / 5 » et bouton d'ajout désactivé au plafond. Le SuperAdmin en est exclu : son GET /api/User renvoie toutes les instances, compter cette liste contre un plafond par instance n'aurait aucun sens. Le masquage du rôle SuperAdmin pour un InstanceAdmin, resté en question dans todo-features.md, était déjà fait — _allowedRoles filtre sur r >= callerRole. Trois pièges relevés en câblant : - AuditController renvoie les entités brutes, pas un DTO — les champs sont ceux d'AuditLog en camelCase. - invokeAPI ne lève pas sur un code d'erreur et son résultat était ignoré dans users_screen : un e-mail déjà utilisé (409) ou un rôle refusé (403) ne produisait aucun message. Le statut est désormais testé et le corps affiché, ce qui rendra visible sans retouche le futur 422 du plafond. - DropdownButtonFormField ne relit pas initialValue sur reconstruction (FormField.didUpdateWidget ne traite que forceErrorText) : « Réinitialiser les filtres » vidait la requête sans vider l'affichage. Remplacé par un DropdownButton piloté. 40 clés i18n FR/EN/NL, flutter gen-l10n relancé. flutter analyze propre sur les dossiers travaillés, flutter build web vert. Deux dettes serveur restent ouvertes, hors périmètre de ce commit : le plafond de 5 n'est pas appliqué par UserController (ce qui est livré ici est un garde-fou d'interface, un POST direct passe toujours), et aucune section n'est journalisée — AuditedTypes.Contains exige l'égalité exacte alors que Section est abstraite. Détail dans DOCS/todo-features.md et v1-plan.md lot F. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
3.8 KiB
Dart
118 lines
3.8 KiB
Dart
import 'dart:convert';
|
|
|
|
/// Une ligne d'`AuditLog` telle que la rend `GET /api/Audit`.
|
|
///
|
|
/// Le contrôleur renvoie les entités brutes (pas de DTO), donc les noms de champs
|
|
/// sont ceux de `ManagerService.Data.AuditLog` sérialisés en camelCase.
|
|
class AuditEntry {
|
|
final String id;
|
|
final String entityType;
|
|
final String entityId;
|
|
final String action;
|
|
final String? userId;
|
|
final String? instanceId;
|
|
final DateTime timestamp;
|
|
final String? oldValues;
|
|
final String? newValues;
|
|
|
|
const AuditEntry({
|
|
required this.id,
|
|
required this.entityType,
|
|
required this.entityId,
|
|
required this.action,
|
|
required this.userId,
|
|
required this.instanceId,
|
|
required this.timestamp,
|
|
required this.oldValues,
|
|
required this.newValues,
|
|
});
|
|
|
|
factory AuditEntry.fromJson(Map<String, dynamic> json) => AuditEntry(
|
|
id: (json['id'] ?? '').toString(),
|
|
entityType: (json['entityType'] ?? '').toString(),
|
|
entityId: (json['entityId'] ?? '').toString(),
|
|
action: (json['action'] ?? '').toString(),
|
|
userId: json['userId'] as String?,
|
|
instanceId: json['instanceId'] as String?,
|
|
timestamp: DateTime.tryParse((json['timestamp'] ?? '').toString())?.toLocal() ??
|
|
DateTime.fromMillisecondsSinceEpoch(0),
|
|
oldValues: json['oldValues'] as String?,
|
|
newValues: json['newValues'] as String?,
|
|
);
|
|
|
|
/// Les colonnes réellement touchées, dans l'ordre alphabétique.
|
|
///
|
|
/// `OldValues` est nul sur une création et `NewValues` sur une suppression :
|
|
/// l'union des deux clés est la seule façon d'obtenir la liste complète sans
|
|
/// supposer laquelle des deux est remplie.
|
|
List<AuditFieldChange> get changes {
|
|
final before = _decode(oldValues);
|
|
final after = _decode(newValues);
|
|
final keys = {...before.keys, ...after.keys}.toList()..sort();
|
|
return keys
|
|
.map((k) => AuditFieldChange(k, _render(before[k]), _render(after[k])))
|
|
.toList();
|
|
}
|
|
|
|
/// Un JSON illisible est traité comme absent : la ligne du journal reste
|
|
/// consultable même si la sérialisation d'une colonne exotique a échoué côté serveur.
|
|
static Map<String, dynamic> _decode(String? raw) {
|
|
if (raw == null || raw.isEmpty) return const {};
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
return decoded is Map<String, dynamic> ? decoded : const {};
|
|
} catch (_) {
|
|
return const {};
|
|
}
|
|
}
|
|
|
|
/// Les valeurs multilingues arrivent en listes d'objets `{language, value}` :
|
|
/// les rendre en JSON brut donnerait une ligne illisible dans le détail.
|
|
static String? _render(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is String) return value;
|
|
if (value is List) {
|
|
final translations = value
|
|
.whereType<Map>()
|
|
.where((m) => m.containsKey('language') && m.containsKey('value'))
|
|
.map((m) => '${m['language']} : ${m['value']}')
|
|
.toList();
|
|
if (translations.isNotEmpty) return translations.join('\n');
|
|
}
|
|
return jsonEncode(value);
|
|
}
|
|
}
|
|
|
|
class AuditFieldChange {
|
|
final String field;
|
|
final String? before;
|
|
final String? after;
|
|
|
|
const AuditFieldChange(this.field, this.before, this.after);
|
|
}
|
|
|
|
class AuditPage {
|
|
final int total;
|
|
final int page;
|
|
final int limit;
|
|
final List<AuditEntry> items;
|
|
|
|
const AuditPage({
|
|
required this.total,
|
|
required this.page,
|
|
required this.limit,
|
|
required this.items,
|
|
});
|
|
|
|
factory AuditPage.fromJson(Map<String, dynamic> json) => AuditPage(
|
|
total: (json['total'] as num?)?.toInt() ?? 0,
|
|
page: (json['page'] as num?)?.toInt() ?? 1,
|
|
limit: (json['limit'] as num?)?.toInt() ?? 50,
|
|
items: ((json['items'] as List?) ?? const [])
|
|
.map((e) => AuditEntry.fromJson(e as Map<String, dynamic>))
|
|
.toList(),
|
|
);
|
|
|
|
int get pageCount => limit == 0 ? 1 : (total + limit - 1) ~/ limit;
|
|
}
|