manager-app/lib/Screens/Audit/audit_screen.dart
Thomas Fransolet 762448c654 Lot F : écran d'audit log et compteur d'utilisateurs
É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>
2026-08-12 11:04:40 +02:00

511 lines
18 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/Screens/Audit/audit_entry.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
/// Les types journalisés par `MyInfoMateDbContext.AuditedTypes`. La liste est fixe :
/// l'endpoint ne rend pas les valeurs distinctes présentes en base, et un filtre
/// construit sur la page courante ne proposerait que ce qui y est déjà visible.
const _kAuditEntityTypes = ['Section', 'Resource', 'Configuration', 'Device', 'User', 'Instance'];
const _kAuditPageSize = 50;
class AuditScreen extends StatefulWidget {
const AuditScreen({super.key});
@override
State<AuditScreen> createState() => _AuditScreenState();
}
class _AuditScreenState extends State<AuditScreen> {
AuditPage? _page;
bool _loading = true;
bool _failed = false;
int _pageNumber = 1;
String? _instanceId;
String? _entityType;
String? _userId;
DateTime? _from;
DateTime? _to;
/// Les deux annuaires servent à afficher un nom là où le journal ne garde qu'un id.
/// Un id absent de l'annuaire (instance ou utilisateur supprimé depuis) reste affiché
/// tel quel : le journal doit rester lisible après la disparition de ce qu'il décrit.
Map<String, String> _instanceNames = {};
Map<String, String> _userNames = {};
ManagerAppContext get _managerContext =>
Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadDirectories();
_load();
});
}
Future<void> _loadDirectories() async {
final ctx = _managerContext;
if (ctx.clientAPI == null) return;
try {
final instances = await ctx.clientAPI!.instanceApi!.instanceGet();
final users = await ctx.clientAPI!.userApi!.userGet();
if (!mounted) return;
setState(() {
_instanceNames = {for (final i in instances ?? []) i.id: i.name};
_userNames = {
for (final u in users ?? [])
if (u.id != null) u.id!: _fullName(u.firstName, u.lastName, u.email),
};
});
} catch (_) {
// Les filtres retombent sur les identifiants bruts, la consultation reste possible.
}
}
static String _fullName(String? firstName, String? lastName, String? email) {
final name = [firstName, lastName].where((p) => (p ?? '').isNotEmpty).join(' ');
return name.isEmpty ? (email ?? '') : name;
}
Future<void> _load() async {
final ctx = _managerContext;
setState(() {
_loading = true;
_failed = false;
});
final query = <String, String>{
'page': '$_pageNumber',
'limit': '$_kAuditPageSize',
if (_instanceId != null) 'instanceId': _instanceId!,
if (_entityType != null) 'entityType': _entityType!,
if (_userId != null) 'userId': _userId!,
if (_from != null) 'from': _from!.toUtc().toIso8601String(),
// Borne haute prise à la fin de la journée choisie : un `to` au 12/08 doit
// inclure ce qui s'est passé le 12/08, pas s'arrêter à minuit.
if (_to != null) 'to': _endOfDay(_to!).toUtc().toIso8601String(),
};
try {
final response = await http.get(
Uri.parse('${ctx.host}/api/Audit').replace(queryParameters: query),
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
);
if (!mounted) return;
if (response.statusCode != 200) {
setState(() {
_loading = false;
_failed = true;
});
return;
}
setState(() {
_page = AuditPage.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>);
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
static DateTime _endOfDay(DateTime day) =>
DateTime(day.year, day.month, day.day, 23, 59, 59, 999);
/// Tout changement de filtre ramène à la première page : rester en page 4 d'un
/// résultat qui n'en compte plus qu'une afficherait une liste vide sans raison visible.
void _applyFilter(VoidCallback change) {
setState(() {
change();
_pageNumber = 1;
});
_load();
}
void _resetFilters() => _applyFilter(() {
_instanceId = null;
_entityType = null;
_userId = null;
_from = null;
_to = null;
});
bool get _hasFilters =>
_instanceId != null || _entityType != null || _userId != null || _from != null || _to != null;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.auditTitle, style: kTitleScreen),
const SizedBox(height: kSpace1),
Text(l.auditSubtitle, style: kSubtitleCard),
const SizedBox(height: kSpace5),
_filters(l),
const SizedBox(height: kSpace5),
Expanded(child: _body(l)),
],
);
}
Widget _filters(AppLocalizations l) {
return Wrap(
spacing: kSpace4,
runSpacing: kSpace4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_dropdown<String>(
label: l.auditFilterInstance,
value: _instanceId,
emptyLabel: l.auditAllInstances,
entries: _instanceNames.entries.map((e) => MapEntry(e.key, e.value)).toList(),
onChanged: (v) => _applyFilter(() => _instanceId = v),
),
_dropdown<String>(
label: l.auditFilterEntity,
value: _entityType,
emptyLabel: l.auditAllEntities,
entries: _kAuditEntityTypes.map((t) => MapEntry(t, _entityLabel(l, t))).toList(),
onChanged: (v) => _applyFilter(() => _entityType = v),
),
_dropdown<String>(
label: l.auditFilterUser,
value: _userId,
emptyLabel: l.auditAllUsers,
entries: _userNames.entries.map((e) => MapEntry(e.key, e.value)).toList(),
onChanged: (v) => _applyFilter(() => _userId = v),
),
_dateField(l.auditFilterFrom, _from, (d) => _applyFilter(() => _from = d)),
_dateField(l.auditFilterTo, _to, (d) => _applyFilter(() => _to = d)),
if (_hasFilters)
TextButton.icon(
onPressed: _resetFilters,
icon: const Icon(Icons.filter_alt_off_outlined, size: 18),
label: Text(l.auditReset),
),
],
);
}
Widget _dropdown<T>({
required String label,
required T? value,
required String emptyLabel,
required List<MapEntry<T, String>> entries,
required ValueChanged<T?> onChanged,
}) {
return SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: kLabelField),
const SizedBox(height: kSpace1),
// `DropdownButton` piloté plutôt que `DropdownButtonFormField` : ce dernier
// ne relit pas `initialValue` sur reconstruction, donc « Réinitialiser les
// filtres » aurait vidé la requête sans vider l'affichage.
Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
decoration: BoxDecoration(
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<T?>(
value: value,
isExpanded: true,
isDense: true,
padding: const EdgeInsets.symmetric(vertical: 11),
style: kTextSmall,
items: [
DropdownMenuItem<T?>(value: null, child: Text(emptyLabel, style: kTextSmall)),
for (final entry in entries)
DropdownMenuItem<T?>(
value: entry.key,
child: Text(entry.value, style: kTextSmall, overflow: TextOverflow.ellipsis),
),
],
onChanged: onChanged,
),
),
),
],
),
);
}
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> onChanged) {
return SizedBox(
width: 170,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: kLabelField),
const SizedBox(height: kSpace1),
InkWell(
borderRadius: BorderRadius.circular(kRadiusInput),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) onChanged(picked);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace4, vertical: 11),
decoration: BoxDecoration(
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Row(
children: [
Expanded(
child: Text(
value == null ? '' : DateFormat('dd/MM/yyyy').format(value),
style: kTextSmall,
),
),
if (value != null)
InkWell(
onTap: () => onChanged(null),
child: const Icon(Icons.close, size: 16, color: kInk3),
)
else
const Icon(Icons.calendar_today_outlined, size: 15, color: kInk3),
],
),
),
),
],
),
);
}
Widget _body(AppLocalizations l) {
if (_loading) return const CommonLoader();
if (_failed) return Center(child: Text(l.auditLoadError, style: kTextBody));
final page = _page;
if (page == null || page.items.isEmpty) {
return Center(child: Text(l.auditEmpty, style: kTextBody));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: kSurface,
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusCard),
),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
child: DataTable(
horizontalMargin: kSpace5,
columnSpacing: kSpace7,
headingRowColor: WidgetStateProperty.all(kSurface2),
dividerThickness: 1,
columns: [
DataColumn(label: Text(l.auditColDate, style: kLabelField)),
DataColumn(label: Text(l.auditColInstance, style: kLabelField)),
DataColumn(label: Text(l.auditColEntity, style: kLabelField)),
DataColumn(label: Text(l.auditColAction, style: kLabelField)),
DataColumn(label: Text(l.auditColUser, style: kLabelField)),
DataColumn(label: Text(l.auditColEntityId, style: kLabelField)),
],
rows: page.items
.map((entry) => DataRow(
onSelectChanged: (_) => _showDetail(entry),
cells: [
DataCell(Text(
DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp),
style: kTextSmall.copyWith(fontFamily: kMonoFamily),
)),
DataCell(Text(_instanceLabel(entry.instanceId), style: kTextSmall)),
DataCell(Text(_entityLabel(l, entry.entityType), style: kTextSmall)),
DataCell(_actionChip(l, entry.action)),
DataCell(Text(_userLabel(entry.userId), style: kTextSmall)),
DataCell(Text(
entry.entityId,
style: kOverlineMono.copyWith(color: kInk2),
)),
],
))
.toList(),
),
),
),
),
const SizedBox(height: kSpace4),
_pagination(l, page),
],
);
}
Widget _pagination(AppLocalizations l, AuditPage page) {
final first = (page.page - 1) * page.limit + 1;
final last = first + page.items.length - 1;
return Row(
children: [
Text(l.auditRange(first, last, page.total), style: kTextHint),
const Spacer(),
IconButton(
onPressed: page.page <= 1
? null
: () {
setState(() => _pageNumber = page.page - 1);
_load();
},
icon: const Icon(Icons.chevron_left),
tooltip: l.auditPrevious,
),
Text('${page.page} / ${page.pageCount}', style: kTextSmall),
IconButton(
onPressed: page.page >= page.pageCount
? null
: () {
setState(() => _pageNumber = page.page + 1);
_load();
},
icon: const Icon(Icons.chevron_right),
tooltip: l.auditNext,
),
],
);
}
Widget _actionChip(AppLocalizations l, String action) {
final color = switch (action) {
'Create' => kGood,
'Delete' => kSerious,
_ => kBrand,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
border: Border.all(color: color.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(kRadiusPill),
),
child: Text(_actionLabel(l, action), style: kTextHint.copyWith(color: color)),
);
}
String _actionLabel(AppLocalizations l, String action) => switch (action) {
'Create' => l.auditActionCreate,
'Update' => l.auditActionUpdate,
'Delete' => l.auditActionDelete,
_ => action,
};
String _entityLabel(AppLocalizations l, String entityType) => switch (entityType) {
'Section' => l.auditEntitySection,
'Resource' => l.auditEntityResource,
'Configuration' => l.auditEntityConfiguration,
'Device' => l.auditEntityDevice,
'User' => l.auditEntityUser,
'Instance' => l.auditEntityInstance,
_ => entityType,
};
String _instanceLabel(String? id) =>
id == null ? '' : (_instanceNames[id] ?? id);
String _userLabel(String? id) => id == null ? '' : (_userNames[id] ?? id);
void _showDetail(AuditEntry entry) {
final l = AppLocalizations.of(context)!;
final changes = entry.changes;
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l.auditDetailTitle, style: kTitleCard),
content: SizedBox(
width: 720,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_entityLabel(l, entry.entityType)} · ${_actionLabel(l, entry.action)} · '
'${DateFormat('dd/MM/yyyy HH:mm:ss').format(entry.timestamp)}',
style: kSubtitleCard,
),
const SizedBox(height: kSpace1),
Text('${_instanceLabel(entry.instanceId)} · ${_userLabel(entry.userId)}',
style: kSubtitleCard),
const SizedBox(height: kSpace5),
if (changes.isEmpty)
Text(l.auditDetailNoValues, style: kTextBody)
else
Table(
border: TableBorder.all(color: kLineSoft),
columnWidths: const {
0: FlexColumnWidth(2),
1: FlexColumnWidth(3),
2: FlexColumnWidth(3),
},
children: [
TableRow(
decoration: const BoxDecoration(color: kSurface2),
children: [
_detailCell(l.auditDetailField, kLabelField),
_detailCell(l.auditDetailBefore, kLabelField),
_detailCell(l.auditDetailAfter, kLabelField),
],
),
for (final change in changes)
TableRow(children: [
_detailCell(change.field, kTextSmall),
_detailCell(change.before ?? '', kTextSmall.copyWith(color: kInk3)),
_detailCell(change.after ?? '', kTextSmall),
]),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(l.close),
),
],
),
);
}
Widget _detailCell(String text, TextStyle style) => Padding(
padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: kSpace2),
child: SelectableText(text, style: style),
);
}