diff --git a/lib/Screens/Audit/audit_entry.dart b/lib/Screens/Audit/audit_entry.dart new file mode 100644 index 0000000..bae5900 --- /dev/null +++ b/lib/Screens/Audit/audit_entry.dart @@ -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 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 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 _decode(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? 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() + .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 items; + + const AuditPage({ + required this.total, + required this.page, + required this.limit, + required this.items, + }); + + factory AuditPage.fromJson(Map 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)) + .toList(), + ); + + int get pageCount => limit == 0 ? 1 : (total + limit - 1) ~/ limit; +} diff --git a/lib/Screens/Audit/audit_screen.dart b/lib/Screens/Audit/audit_screen.dart new file mode 100644 index 0000000..4cbf129 --- /dev/null +++ b/lib/Screens/Audit/audit_screen.dart @@ -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 createState() => _AuditScreenState(); +} + +class _AuditScreenState extends State { + 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 _instanceNames = {}; + Map _userNames = {}; + + ManagerAppContext get _managerContext => + Provider.of(context, listen: false).getContext() as ManagerAppContext; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadDirectories(); + _load(); + }); + } + + Future _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 _load() async { + final ctx = _managerContext; + setState(() { + _loading = true; + _failed = false; + }); + + final query = { + '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); + _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( + 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( + label: l.auditFilterEntity, + value: _entityType, + emptyLabel: l.auditAllEntities, + entries: _kAuditEntityTypes.map((t) => MapEntry(t, _entityLabel(l, t))).toList(), + onChanged: (v) => _applyFilter(() => _entityType = v), + ), + _dropdown( + 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({ + required String label, + required T? value, + required String emptyLabel, + required List> entries, + required ValueChanged 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( + value: value, + isExpanded: true, + isDense: true, + padding: const EdgeInsets.symmetric(vertical: 11), + style: kTextSmall, + items: [ + DropdownMenuItem(value: null, child: Text(emptyLabel, style: kTextSmall)), + for (final entry in entries) + DropdownMenuItem( + value: entry.key, + child: Text(entry.value, style: kTextSmall, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: onChanged, + ), + ), + ), + ], + ), + ); + } + + Widget _dateField(String label, DateTime? value, ValueChanged 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), + ); +} diff --git a/lib/Screens/Main/main_screen.dart b/lib/Screens/Main/main_screen.dart index 710bf2d..b54ba9e 100644 --- a/lib/Screens/Main/main_screen.dart +++ b/lib/Screens/Main/main_screen.dart @@ -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 { 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 { 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 { } 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 { case "guide-ia": currentPosition = 12; break; + case "audit": + currentPosition = 13; + break; } } @@ -697,6 +711,11 @@ class _MainScreenState extends State { 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), diff --git a/lib/Screens/Users/users_screen.dart b/lib/Screens/Users/users_screen.dart index 0a808ad..197d950 100644 --- a/lib/Screens/Users/users_screen.dart +++ b/lib/Screens/Users/users_screen.dart @@ -65,8 +65,20 @@ class _UsersScreenState extends State { '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 { final appContext = Provider.of(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), + ), ), ], ), diff --git a/lib/constants.dart b/lib/constants.dart index 0428292..43a50af 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -166,6 +166,11 @@ const List languages = ["FR", "NL", "EN", "DE", "IT", "ES", "CN", "PL", const List map_types_mapBox = ["standard", "streets", "outdoors", "light", "dark", "satellite", "satellite_streets"]; const List 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; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 49aab8c..182bf84 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fcb5a3c..9043128 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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" } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1cd92ea..8a333c9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7ea5a3d..8d9fec3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8f1abfa..450f2c8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 1b954e7..79a7880 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; + } } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d65a822..f518fd5 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -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" } + } + } }