É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>
511 lines
18 KiB
Dart
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),
|
|
);
|
|
}
|