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>
This commit is contained in:
Thomas Fransolet 2026-08-12 11:04:40 +02:00
parent b39854afb0
commit 762448c654
12 changed files with 1533 additions and 9 deletions

View File

@ -0,0 +1,117 @@
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;
}

View File

@ -0,0 +1,510 @@
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 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),
);
}

View File

@ -9,6 +9,7 @@ import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/Models/menu.dart';
import 'package:manager_app/Models/menuSection.dart';
import 'package:manager_app/Screens/ApiKeys/api_keys_screen.dart';
import 'package:manager_app/Screens/Audit/audit_screen.dart';
import 'package:manager_app/Screens/Configurations/configurations_screen.dart';
import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart';
import 'package:manager_app/Screens/Resources/resources_screen.dart';
@ -280,6 +281,7 @@ class _MainScreenState extends State<MainScreen> {
case 'notifications': return l.menuNotifications;
case 'users': return l.menuUsers;
case 'apikeys': return l.menuApiKeys;
case 'audit': return l.menuAudit;
case 'subscription': return l.menuSubscription;
default: return type;
}
@ -295,6 +297,7 @@ class _MainScreenState extends State<MainScreen> {
case 'notifications': return Icons.notifications_none;
case 'users': return Icons.people_outline;
case 'apikeys': return Icons.vpn_key_outlined;
case 'audit': return Icons.history;
case 'subscription': return Icons.workspace_premium_outlined;
default: return Icons.circle_outlined;
}
@ -533,6 +536,14 @@ class _MainScreenState extends State<MainScreen> {
} else if ((role == null || role.value > 1) && hasAdminItems) {
menu.sections!.removeWhere((s) => s.menuId == 8 || s.menuId == 9);
}
// Le journal d'activité couvre toutes les instances et expose les valeurs avant/après :
// l'endpoint est sous policy SuperAdmin, l'entrée de menu suit la même règle.
final hasAuditItem = menu.sections!.any((s) => s.menuId == 13);
if (role != null && role.value == 0 && !hasAuditItem) {
menu.sections!.add(MenuSection(name: "Activité", type: "audit", menuId: 13, subMenu: []));
} else if ((role == null || role.value != 0) && hasAuditItem) {
menu.sections!.removeWhere((s) => s.menuId == 13);
}
Size size = MediaQuery.of(context).size;
bool isMobile = size.width < 850;
@ -623,6 +634,9 @@ class _MainScreenState extends State<MainScreen> {
case "guide-ia":
currentPosition = 12;
break;
case "audit":
currentPosition = 13;
break;
}
}
@ -697,6 +711,11 @@ class _MainScreenState extends State<MainScreen> {
padding: EdgeInsets.all(8.0),
child: ApiKeysScreen()
);
case 'audit':
return const Padding(
padding: EdgeInsets.all(8.0),
child: AuditScreen()
);
case 'notifications':
return const Padding(
padding: EdgeInsets.all(8.0),

View File

@ -65,8 +65,20 @@ class _UsersScreenState extends State<UsersScreen> {
'lastName': lastName,
'role': roleValue,
};
await ctx.clientAPI!.apiApi!.invokeAPI(
final response = await ctx.clientAPI!.apiApi!.invokeAPI(
'/api/User', 'POST', [], body, {}, {}, 'application/json');
// `invokeAPI` ne lève pas sur un code d'erreur : sans ce test, un e-mail déjà
// utilisé (409) ou un rôle refusé (403) laissaient la liste inchangée sans
// qu'aucun message n'explique pourquoi.
if (response.statusCode != 200 && mounted) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(l.userCreateError(utf8.decode(response.bodyBytes))),
backgroundColor: kError,
));
return;
}
await _loadUsers(ctx);
}
@ -236,17 +248,41 @@ class _UsersScreenState extends State<UsersScreen> {
final appContext = Provider.of<AppContext>(context);
final managerCtx = appContext.getContext() as ManagerAppContext;
// Le SuperAdmin voit les utilisateurs de toutes les instances : compter cette
// liste contre un plafond par instance n'aurait aucun sens, et le plafond ne
// lui est de toute façon pas opposable.
final isSuperAdmin = _roleToInt(managerCtx.role?.value) == 0;
final quotaReached = !isSuperAdmin && _users.length >= kMaxUsersPerInstance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l.usersTitle, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
ElevatedButton.icon(
onPressed: () => _showCreateDialog(context, managerCtx),
icon: const Icon(Icons.add),
label: Text(l.createUserBtn),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(l.usersTitle, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
if (!_loading && !isSuperAdmin) ...[
const SizedBox(width: kSpace4),
Text(
l.usersCount(_users.length, kMaxUsersPerInstance),
style: kTextHint.copyWith(color: quotaReached ? kWarning : kInk3),
),
],
],
),
Tooltip(
message: quotaReached
? '${l.usersQuotaReached(kMaxUsersPerInstance)}${l.usersQuotaHint}'
: '',
child: ElevatedButton.icon(
onPressed: quotaReached ? null : () => _showCreateDialog(context, managerCtx),
icon: const Icon(Icons.add),
label: Text(l.createUserBtn),
),
),
],
),

View File

@ -166,6 +166,11 @@ const List<String> languages = ["FR", "NL", "EN", "DE", "IT", "ES", "CN", "PL",
const List<String> map_types_mapBox = ["standard", "streets", "outdoors", "light", "dark", "satellite", "satellite_streets"];
const List<String> map_providers = ["Google", "MapBox"];
/// Plafond d'utilisateurs par instance, SuperAdmin exclu.
/// Contrôle d'interface uniquement : `UserController` ne compte rien côté serveur,
/// un POST direct sur l'API passe toujours.
const kMaxUsersPerInstance = 5;
const kTitleMaxLength = 165;
const kDescriptionMaxLength = 2500;

View File

@ -706,5 +706,69 @@
"saveStatusSaved": "Saved",
"saveStatusFailed": "Could not save",
"retry": "Retry",
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map."
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map.",
"menuAudit": "Activity",
"auditTitle": "Activity",
"auditSubtitle": "Who changed what, and when",
"auditFilterInstance": "Instance",
"auditFilterEntity": "Item type",
"auditFilterUser": "User",
"auditFilterFrom": "From",
"auditFilterTo": "To",
"auditAllInstances": "All instances",
"auditAllEntities": "All types",
"auditAllUsers": "All users",
"auditReset": "Clear filters",
"auditColDate": "Date",
"auditColInstance": "Instance",
"auditColEntity": "Item",
"auditColAction": "Action",
"auditColUser": "User",
"auditColEntityId": "Identifier",
"auditActionCreate": "Created",
"auditActionUpdate": "Updated",
"auditActionDelete": "Deleted",
"auditEntitySection": "Section",
"auditEntityResource": "Resource",
"auditEntityConfiguration": "Configuration",
"auditEntityDevice": "Device",
"auditEntityUser": "User",
"auditEntityInstance": "Instance",
"auditEmpty": "No activity for these filters",
"auditLoadError": "The activity log could not be loaded",
"auditRange": "{first} {last} of {total}",
"@auditRange": {
"placeholders": {
"first": { "type": "int" },
"last": { "type": "int" },
"total": { "type": "int" }
}
},
"auditPrevious": "Previous page",
"auditNext": "Next page",
"auditDetailTitle": "Change detail",
"auditDetailField": "Field",
"auditDetailBefore": "Before",
"auditDetailAfter": "After",
"auditDetailNoValues": "The log does not keep the detail of this operation.",
"usersCount": "{count} / {max} users",
"@usersCount": {
"placeholders": {
"count": { "type": "int" },
"max": { "type": "int" }
}
},
"usersQuotaReached": "Limit of {max} users reached",
"@usersQuotaReached": {
"placeholders": {
"max": { "type": "int" }
}
},
"usersQuotaHint": "Delete a user to invite another one.",
"userCreateError": "Creation failed: {message}",
"@userCreateError": {
"placeholders": {
"message": { "type": "String" }
}
}
}

View File

@ -706,5 +706,69 @@
"saveStatusSaved": "Enregistré",
"saveStatusFailed": "Échec de l'enregistrement",
"retry": "Réessayer",
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte."
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte.",
"menuAudit": "Activité",
"auditTitle": "Activité",
"auditSubtitle": "Qui a modifié quoi, et quand",
"auditFilterInstance": "Instance",
"auditFilterEntity": "Type d'élément",
"auditFilterUser": "Utilisateur",
"auditFilterFrom": "Depuis le",
"auditFilterTo": "Jusqu'au",
"auditAllInstances": "Toutes les instances",
"auditAllEntities": "Tous les types",
"auditAllUsers": "Tous les utilisateurs",
"auditReset": "Réinitialiser les filtres",
"auditColDate": "Date",
"auditColInstance": "Instance",
"auditColEntity": "Élément",
"auditColAction": "Action",
"auditColUser": "Utilisateur",
"auditColEntityId": "Identifiant",
"auditActionCreate": "Création",
"auditActionUpdate": "Modification",
"auditActionDelete": "Suppression",
"auditEntitySection": "Section",
"auditEntityResource": "Ressource",
"auditEntityConfiguration": "Configuration",
"auditEntityDevice": "Appareil",
"auditEntityUser": "Utilisateur",
"auditEntityInstance": "Instance",
"auditEmpty": "Aucune activité pour ces filtres",
"auditLoadError": "Le journal d'activité n'a pas pu être chargé",
"auditRange": "{first} {last} sur {total}",
"@auditRange": {
"placeholders": {
"first": { "type": "int" },
"last": { "type": "int" },
"total": { "type": "int" }
}
},
"auditPrevious": "Page précédente",
"auditNext": "Page suivante",
"auditDetailTitle": "Détail de la modification",
"auditDetailField": "Champ",
"auditDetailBefore": "Avant",
"auditDetailAfter": "Après",
"auditDetailNoValues": "Le journal ne conserve pas le détail de cette opération.",
"usersCount": "{count} / {max} utilisateurs",
"@usersCount": {
"placeholders": {
"count": { "type": "int" },
"max": { "type": "int" }
}
},
"usersQuotaReached": "Plafond de {max} utilisateurs atteint",
"@usersQuotaReached": {
"placeholders": {
"max": { "type": "int" }
}
},
"usersQuotaHint": "Supprimez un utilisateur pour en inviter un autre.",
"userCreateError": "La création a échoué : {message}",
"@userCreateError": {
"placeholders": {
"message": { "type": "String" }
}
}
}

View File

@ -3387,6 +3387,252 @@ abstract class AppLocalizations {
/// In fr, this message translates to:
/// **'Le fournisseur et le type de carte s\'appliquent aux applications mobile et tablette. L\'application web affiche toujours sa propre carte.'**
String get mapProviderMobileOnlyNote;
/// No description provided for @menuAudit.
///
/// In fr, this message translates to:
/// **'Activité'**
String get menuAudit;
/// No description provided for @auditTitle.
///
/// In fr, this message translates to:
/// **'Activité'**
String get auditTitle;
/// No description provided for @auditSubtitle.
///
/// In fr, this message translates to:
/// **'Qui a modifié quoi, et quand'**
String get auditSubtitle;
/// No description provided for @auditFilterInstance.
///
/// In fr, this message translates to:
/// **'Instance'**
String get auditFilterInstance;
/// No description provided for @auditFilterEntity.
///
/// In fr, this message translates to:
/// **'Type d\'élément'**
String get auditFilterEntity;
/// No description provided for @auditFilterUser.
///
/// In fr, this message translates to:
/// **'Utilisateur'**
String get auditFilterUser;
/// No description provided for @auditFilterFrom.
///
/// In fr, this message translates to:
/// **'Depuis le'**
String get auditFilterFrom;
/// No description provided for @auditFilterTo.
///
/// In fr, this message translates to:
/// **'Jusqu\'au'**
String get auditFilterTo;
/// No description provided for @auditAllInstances.
///
/// In fr, this message translates to:
/// **'Toutes les instances'**
String get auditAllInstances;
/// No description provided for @auditAllEntities.
///
/// In fr, this message translates to:
/// **'Tous les types'**
String get auditAllEntities;
/// No description provided for @auditAllUsers.
///
/// In fr, this message translates to:
/// **'Tous les utilisateurs'**
String get auditAllUsers;
/// No description provided for @auditReset.
///
/// In fr, this message translates to:
/// **'Réinitialiser les filtres'**
String get auditReset;
/// No description provided for @auditColDate.
///
/// In fr, this message translates to:
/// **'Date'**
String get auditColDate;
/// No description provided for @auditColInstance.
///
/// In fr, this message translates to:
/// **'Instance'**
String get auditColInstance;
/// No description provided for @auditColEntity.
///
/// In fr, this message translates to:
/// **'Élément'**
String get auditColEntity;
/// No description provided for @auditColAction.
///
/// In fr, this message translates to:
/// **'Action'**
String get auditColAction;
/// No description provided for @auditColUser.
///
/// In fr, this message translates to:
/// **'Utilisateur'**
String get auditColUser;
/// No description provided for @auditColEntityId.
///
/// In fr, this message translates to:
/// **'Identifiant'**
String get auditColEntityId;
/// No description provided for @auditActionCreate.
///
/// In fr, this message translates to:
/// **'Création'**
String get auditActionCreate;
/// No description provided for @auditActionUpdate.
///
/// In fr, this message translates to:
/// **'Modification'**
String get auditActionUpdate;
/// No description provided for @auditActionDelete.
///
/// In fr, this message translates to:
/// **'Suppression'**
String get auditActionDelete;
/// No description provided for @auditEntitySection.
///
/// In fr, this message translates to:
/// **'Section'**
String get auditEntitySection;
/// No description provided for @auditEntityResource.
///
/// In fr, this message translates to:
/// **'Ressource'**
String get auditEntityResource;
/// No description provided for @auditEntityConfiguration.
///
/// In fr, this message translates to:
/// **'Configuration'**
String get auditEntityConfiguration;
/// No description provided for @auditEntityDevice.
///
/// In fr, this message translates to:
/// **'Appareil'**
String get auditEntityDevice;
/// No description provided for @auditEntityUser.
///
/// In fr, this message translates to:
/// **'Utilisateur'**
String get auditEntityUser;
/// No description provided for @auditEntityInstance.
///
/// In fr, this message translates to:
/// **'Instance'**
String get auditEntityInstance;
/// No description provided for @auditEmpty.
///
/// In fr, this message translates to:
/// **'Aucune activité pour ces filtres'**
String get auditEmpty;
/// No description provided for @auditLoadError.
///
/// In fr, this message translates to:
/// **'Le journal d\'activité n\'a pas pu être chargé'**
String get auditLoadError;
/// No description provided for @auditRange.
///
/// In fr, this message translates to:
/// **'{first} {last} sur {total}'**
String auditRange(int first, int last, int total);
/// No description provided for @auditPrevious.
///
/// In fr, this message translates to:
/// **'Page précédente'**
String get auditPrevious;
/// No description provided for @auditNext.
///
/// In fr, this message translates to:
/// **'Page suivante'**
String get auditNext;
/// No description provided for @auditDetailTitle.
///
/// In fr, this message translates to:
/// **'Détail de la modification'**
String get auditDetailTitle;
/// No description provided for @auditDetailField.
///
/// In fr, this message translates to:
/// **'Champ'**
String get auditDetailField;
/// No description provided for @auditDetailBefore.
///
/// In fr, this message translates to:
/// **'Avant'**
String get auditDetailBefore;
/// No description provided for @auditDetailAfter.
///
/// In fr, this message translates to:
/// **'Après'**
String get auditDetailAfter;
/// No description provided for @auditDetailNoValues.
///
/// In fr, this message translates to:
/// **'Le journal ne conserve pas le détail de cette opération.'**
String get auditDetailNoValues;
/// No description provided for @usersCount.
///
/// In fr, this message translates to:
/// **'{count} / {max} utilisateurs'**
String usersCount(int count, int max);
/// No description provided for @usersQuotaReached.
///
/// In fr, this message translates to:
/// **'Plafond de {max} utilisateurs atteint'**
String usersQuotaReached(int max);
/// No description provided for @usersQuotaHint.
///
/// In fr, this message translates to:
/// **'Supprimez un utilisateur pour en inviter un autre.'**
String get usersQuotaHint;
/// No description provided for @userCreateError.
///
/// In fr, this message translates to:
/// **'La création a échoué : {message}'**
String userCreateError(String message);
}
class _AppLocalizationsDelegate

View File

@ -1794,4 +1794,136 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get mapProviderMobileOnlyNote =>
'The map provider and type apply to the mobile and tablet apps. The web app always renders its own map.';
@override
String get menuAudit => 'Activity';
@override
String get auditTitle => 'Activity';
@override
String get auditSubtitle => 'Who changed what, and when';
@override
String get auditFilterInstance => 'Instance';
@override
String get auditFilterEntity => 'Item type';
@override
String get auditFilterUser => 'User';
@override
String get auditFilterFrom => 'From';
@override
String get auditFilterTo => 'To';
@override
String get auditAllInstances => 'All instances';
@override
String get auditAllEntities => 'All types';
@override
String get auditAllUsers => 'All users';
@override
String get auditReset => 'Clear filters';
@override
String get auditColDate => 'Date';
@override
String get auditColInstance => 'Instance';
@override
String get auditColEntity => 'Item';
@override
String get auditColAction => 'Action';
@override
String get auditColUser => 'User';
@override
String get auditColEntityId => 'Identifier';
@override
String get auditActionCreate => 'Created';
@override
String get auditActionUpdate => 'Updated';
@override
String get auditActionDelete => 'Deleted';
@override
String get auditEntitySection => 'Section';
@override
String get auditEntityResource => 'Resource';
@override
String get auditEntityConfiguration => 'Configuration';
@override
String get auditEntityDevice => 'Device';
@override
String get auditEntityUser => 'User';
@override
String get auditEntityInstance => 'Instance';
@override
String get auditEmpty => 'No activity for these filters';
@override
String get auditLoadError => 'The activity log could not be loaded';
@override
String auditRange(int first, int last, int total) {
return '$first $last of $total';
}
@override
String get auditPrevious => 'Previous page';
@override
String get auditNext => 'Next page';
@override
String get auditDetailTitle => 'Change detail';
@override
String get auditDetailField => 'Field';
@override
String get auditDetailBefore => 'Before';
@override
String get auditDetailAfter => 'After';
@override
String get auditDetailNoValues =>
'The log does not keep the detail of this operation.';
@override
String usersCount(int count, int max) {
return '$count / $max users';
}
@override
String usersQuotaReached(int max) {
return 'Limit of $max users reached';
}
@override
String get usersQuotaHint => 'Delete a user to invite another one.';
@override
String userCreateError(String message) {
return 'Creation failed: $message';
}
}

View File

@ -1832,4 +1832,137 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get mapProviderMobileOnlyNote =>
'Le fournisseur et le type de carte s\'appliquent aux applications mobile et tablette. L\'application web affiche toujours sa propre carte.';
@override
String get menuAudit => 'Activité';
@override
String get auditTitle => 'Activité';
@override
String get auditSubtitle => 'Qui a modifié quoi, et quand';
@override
String get auditFilterInstance => 'Instance';
@override
String get auditFilterEntity => 'Type d\'élément';
@override
String get auditFilterUser => 'Utilisateur';
@override
String get auditFilterFrom => 'Depuis le';
@override
String get auditFilterTo => 'Jusqu\'au';
@override
String get auditAllInstances => 'Toutes les instances';
@override
String get auditAllEntities => 'Tous les types';
@override
String get auditAllUsers => 'Tous les utilisateurs';
@override
String get auditReset => 'Réinitialiser les filtres';
@override
String get auditColDate => 'Date';
@override
String get auditColInstance => 'Instance';
@override
String get auditColEntity => 'Élément';
@override
String get auditColAction => 'Action';
@override
String get auditColUser => 'Utilisateur';
@override
String get auditColEntityId => 'Identifiant';
@override
String get auditActionCreate => 'Création';
@override
String get auditActionUpdate => 'Modification';
@override
String get auditActionDelete => 'Suppression';
@override
String get auditEntitySection => 'Section';
@override
String get auditEntityResource => 'Ressource';
@override
String get auditEntityConfiguration => 'Configuration';
@override
String get auditEntityDevice => 'Appareil';
@override
String get auditEntityUser => 'Utilisateur';
@override
String get auditEntityInstance => 'Instance';
@override
String get auditEmpty => 'Aucune activité pour ces filtres';
@override
String get auditLoadError => 'Le journal d\'activité n\'a pas pu être chargé';
@override
String auditRange(int first, int last, int total) {
return '$first $last sur $total';
}
@override
String get auditPrevious => 'Page précédente';
@override
String get auditNext => 'Page suivante';
@override
String get auditDetailTitle => 'Détail de la modification';
@override
String get auditDetailField => 'Champ';
@override
String get auditDetailBefore => 'Avant';
@override
String get auditDetailAfter => 'Après';
@override
String get auditDetailNoValues =>
'Le journal ne conserve pas le détail de cette opération.';
@override
String usersCount(int count, int max) {
return '$count / $max utilisateurs';
}
@override
String usersQuotaReached(int max) {
return 'Plafond de $max utilisateurs atteint';
}
@override
String get usersQuotaHint =>
'Supprimez un utilisateur pour en inviter un autre.';
@override
String userCreateError(String message) {
return 'La création a échoué : $message';
}
}

View File

@ -1812,4 +1812,138 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get mapProviderMobileOnlyNote =>
'De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart.';
@override
String get menuAudit => 'Activiteit';
@override
String get auditTitle => 'Activiteit';
@override
String get auditSubtitle => 'Wie heeft wat gewijzigd, en wanneer';
@override
String get auditFilterInstance => 'Instantie';
@override
String get auditFilterEntity => 'Type element';
@override
String get auditFilterUser => 'Gebruiker';
@override
String get auditFilterFrom => 'Vanaf';
@override
String get auditFilterTo => 'Tot';
@override
String get auditAllInstances => 'Alle instanties';
@override
String get auditAllEntities => 'Alle types';
@override
String get auditAllUsers => 'Alle gebruikers';
@override
String get auditReset => 'Filters wissen';
@override
String get auditColDate => 'Datum';
@override
String get auditColInstance => 'Instantie';
@override
String get auditColEntity => 'Element';
@override
String get auditColAction => 'Actie';
@override
String get auditColUser => 'Gebruiker';
@override
String get auditColEntityId => 'Identificatie';
@override
String get auditActionCreate => 'Aangemaakt';
@override
String get auditActionUpdate => 'Gewijzigd';
@override
String get auditActionDelete => 'Verwijderd';
@override
String get auditEntitySection => 'Sectie';
@override
String get auditEntityResource => 'Bron';
@override
String get auditEntityConfiguration => 'Configuratie';
@override
String get auditEntityDevice => 'Toestel';
@override
String get auditEntityUser => 'Gebruiker';
@override
String get auditEntityInstance => 'Instantie';
@override
String get auditEmpty => 'Geen activiteit voor deze filters';
@override
String get auditLoadError =>
'Het activiteitenlogboek kon niet worden geladen';
@override
String auditRange(int first, int last, int total) {
return '$first $last van $total';
}
@override
String get auditPrevious => 'Vorige pagina';
@override
String get auditNext => 'Volgende pagina';
@override
String get auditDetailTitle => 'Detail van de wijziging';
@override
String get auditDetailField => 'Veld';
@override
String get auditDetailBefore => 'Voor';
@override
String get auditDetailAfter => 'Na';
@override
String get auditDetailNoValues =>
'Het logboek bewaart het detail van deze bewerking niet.';
@override
String usersCount(int count, int max) {
return '$count / $max gebruikers';
}
@override
String usersQuotaReached(int max) {
return 'Limiet van $max gebruikers bereikt';
}
@override
String get usersQuotaHint =>
'Verwijder een gebruiker om er een andere uit te nodigen.';
@override
String userCreateError(String message) {
return 'Aanmaken mislukt: $message';
}
}

View File

@ -706,5 +706,69 @@
"saveStatusSaved": "Opgeslagen",
"saveStatusFailed": "Opslaan mislukt",
"retry": "Opnieuw proberen",
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart."
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart.",
"menuAudit": "Activiteit",
"auditTitle": "Activiteit",
"auditSubtitle": "Wie heeft wat gewijzigd, en wanneer",
"auditFilterInstance": "Instantie",
"auditFilterEntity": "Type element",
"auditFilterUser": "Gebruiker",
"auditFilterFrom": "Vanaf",
"auditFilterTo": "Tot",
"auditAllInstances": "Alle instanties",
"auditAllEntities": "Alle types",
"auditAllUsers": "Alle gebruikers",
"auditReset": "Filters wissen",
"auditColDate": "Datum",
"auditColInstance": "Instantie",
"auditColEntity": "Element",
"auditColAction": "Actie",
"auditColUser": "Gebruiker",
"auditColEntityId": "Identificatie",
"auditActionCreate": "Aangemaakt",
"auditActionUpdate": "Gewijzigd",
"auditActionDelete": "Verwijderd",
"auditEntitySection": "Sectie",
"auditEntityResource": "Bron",
"auditEntityConfiguration": "Configuratie",
"auditEntityDevice": "Toestel",
"auditEntityUser": "Gebruiker",
"auditEntityInstance": "Instantie",
"auditEmpty": "Geen activiteit voor deze filters",
"auditLoadError": "Het activiteitenlogboek kon niet worden geladen",
"auditRange": "{first} {last} van {total}",
"@auditRange": {
"placeholders": {
"first": { "type": "int" },
"last": { "type": "int" },
"total": { "type": "int" }
}
},
"auditPrevious": "Vorige pagina",
"auditNext": "Volgende pagina",
"auditDetailTitle": "Detail van de wijziging",
"auditDetailField": "Veld",
"auditDetailBefore": "Voor",
"auditDetailAfter": "Na",
"auditDetailNoValues": "Het logboek bewaart het detail van deze bewerking niet.",
"usersCount": "{count} / {max} gebruikers",
"@usersCount": {
"placeholders": {
"count": { "type": "int" },
"max": { "type": "int" }
}
},
"usersQuotaReached": "Limiet van {max} gebruikers bereikt",
"@usersQuotaReached": {
"placeholders": {
"max": { "type": "int" }
}
},
"usersQuotaHint": "Verwijder een gebruiker om er een andere uit te nodigen.",
"userCreateError": "Aanmaken mislukt: {message}",
"@userCreateError": {
"placeholders": {
"message": { "type": "String" }
}
}
}