É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>
358 lines
14 KiB
Dart
358 lines
14 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:manager_app/l10n/app_localizations.dart';
|
|
import 'package:manager_app/Models/managerContext.dart';
|
|
import 'package:manager_app/app_context.dart';
|
|
import 'package:manager_app/Components/common_loader.dart';
|
|
import 'package:manager_app/constants.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class UsersScreen extends StatefulWidget {
|
|
const UsersScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
_UsersScreenState createState() => _UsersScreenState();
|
|
}
|
|
|
|
class _UsersScreenState extends State<UsersScreen> {
|
|
List<Map<String, dynamic>> _users = [];
|
|
bool _loading = true;
|
|
|
|
static const _roleNames = ['SuperAdmin', 'InstanceAdmin', 'ContentEditor', 'Viewer'];
|
|
|
|
static String _roleName(dynamic v) {
|
|
if (v is String) return v;
|
|
final i = v as int?;
|
|
if (i != null && i >= 0 && i < _roleNames.length) return _roleNames[i];
|
|
return '—';
|
|
}
|
|
|
|
static int _roleToInt(dynamic v) {
|
|
if (v is int) return v;
|
|
if (v is String) {
|
|
final i = _roleNames.indexOf(v);
|
|
return i >= 0 ? i : 3;
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
List<int> _allowedRoles(int callerRoleValue) =>
|
|
[0, 1, 2, 3].where((r) => r >= callerRoleValue).toList();
|
|
|
|
Future<void> _loadUsers(ManagerAppContext ctx) async {
|
|
try {
|
|
final response = await ctx.clientAPI!.userApi!.userGetWithHttpInfo();
|
|
if (response.statusCode == 200) {
|
|
final List<dynamic> json = jsonDecode(utf8.decode(response.bodyBytes));
|
|
setState(() {
|
|
_users = json.cast<Map<String, dynamic>>();
|
|
_loading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _createUser(ManagerAppContext ctx, String email,
|
|
String firstName, String lastName, int roleValue) async {
|
|
// No password sent: the backend generates an invitation token and emails
|
|
// a "set your password" link to the new user instead.
|
|
final body = {
|
|
'email': email,
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
'role': roleValue,
|
|
};
|
|
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);
|
|
}
|
|
|
|
Future<void> _updateUser(ManagerAppContext ctx, String id, String firstName,
|
|
String lastName, int roleValue) async {
|
|
final body = {
|
|
'id': id,
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
'role': roleValue,
|
|
};
|
|
await ctx.clientAPI!.apiApi!.invokeAPI(
|
|
'/api/User', 'PUT', [], body, {}, {}, 'application/json');
|
|
await _loadUsers(ctx);
|
|
}
|
|
|
|
Future<void> _deleteUser(ManagerAppContext ctx, String id) async {
|
|
await ctx.clientAPI!.userApi!.userDeleteUser(id);
|
|
await _loadUsers(ctx);
|
|
}
|
|
|
|
void _showCreateDialog(BuildContext context, ManagerAppContext ctx) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final callerRole = _roleToInt(ctx.role?.value);
|
|
final emailCtrl = TextEditingController();
|
|
final firstCtrl = TextEditingController();
|
|
final lastCtrl = TextEditingController();
|
|
int selectedRole = callerRole;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => StatefulBuilder(builder: (ctx2, setLocal) {
|
|
return AlertDialog(
|
|
title: Text(l.createUserTitle),
|
|
content: SingleChildScrollView(
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
TextField(controller: emailCtrl, decoration: InputDecoration(labelText: l.email)),
|
|
TextField(controller: firstCtrl, decoration: InputDecoration(labelText: l.firstName)),
|
|
TextField(controller: lastCtrl, decoration: InputDecoration(labelText: l.lastName)),
|
|
const SizedBox(height: 4),
|
|
Text(l.inviteUserHint, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
const SizedBox(height: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l.role, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
DropdownButton<int>(
|
|
value: selectedRole,
|
|
isExpanded: true,
|
|
items: _allowedRoles(callerRole)
|
|
.map((r) => DropdownMenuItem(value: r, child: Text(_roleName(r))))
|
|
.toList(),
|
|
onChanged: (v) => setLocal(() => selectedRole = v!),
|
|
),
|
|
],
|
|
),
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx2), child: Text(l.cancel)),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx2);
|
|
await _createUser(ctx, emailCtrl.text, firstCtrl.text,
|
|
lastCtrl.text, selectedRole);
|
|
},
|
|
child: Text(l.create),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _showEditDialog(BuildContext context, ManagerAppContext ctx, Map<String, dynamic> user) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final callerRole = _roleToInt(ctx.role?.value);
|
|
final firstCtrl = TextEditingController(text: user['firstName'] as String? ?? '');
|
|
final lastCtrl = TextEditingController(text: user['lastName'] as String? ?? '');
|
|
int selectedRole = _roleToInt(user['role']);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => StatefulBuilder(builder: (ctx2, setLocal) {
|
|
return AlertDialog(
|
|
title: Text(l.editUserTitle),
|
|
content: SingleChildScrollView(
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
TextField(controller: firstCtrl, decoration: InputDecoration(labelText: l.firstName)),
|
|
TextField(controller: lastCtrl, decoration: InputDecoration(labelText: l.lastName)),
|
|
const SizedBox(height: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l.role, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
DropdownButton<int>(
|
|
value: selectedRole,
|
|
isExpanded: true,
|
|
items: _allowedRoles(callerRole)
|
|
.map((r) => DropdownMenuItem(value: r, child: Text(_roleName(r))))
|
|
.toList(),
|
|
onChanged: (v) => setLocal(() => selectedRole = v!),
|
|
),
|
|
],
|
|
),
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx2), child: Text(l.cancel)),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx2);
|
|
await _updateUser(ctx, user['id'] as String, firstCtrl.text,
|
|
lastCtrl.text, selectedRole);
|
|
},
|
|
child: Text(l.save),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _confirmDelete(BuildContext context, ManagerAppContext ctx, Map<String, dynamic> user) {
|
|
final l = AppLocalizations.of(context)!;
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: Text(l.deleteUserTitle),
|
|
content: Text(l.deleteUserConfirm(user['email'] as String? ?? '')),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(l.cancel)),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
await _deleteUser(ctx, user['id'] as String);
|
|
},
|
|
child: Text(l.delete, style: const TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final ctx = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
|
|
_loadUsers(ctx);
|
|
});
|
|
}
|
|
|
|
static Color _roleColor(dynamic v) {
|
|
switch (_roleToInt(v)) {
|
|
case 0: return Colors.purple;
|
|
case 1: return Colors.blue;
|
|
case 2: return Colors.teal;
|
|
default: return Colors.grey;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
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: [
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_loading)
|
|
const CommonLoader()
|
|
else if (_users.isEmpty)
|
|
Center(child: Text(l.noUsers))
|
|
else
|
|
Expanded(
|
|
child: Card(
|
|
elevation: 0,
|
|
color: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
side: BorderSide(color: Colors.grey.shade200),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: SingleChildScrollView(
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: DataTable(
|
|
horizontalMargin: 16,
|
|
columnSpacing: 24,
|
|
headingRowColor: WidgetStateProperty.all(Colors.grey.shade50),
|
|
dividerThickness: 1,
|
|
columns: [
|
|
DataColumn(label: Text(l.email, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.firstName, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.lastName, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.role, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.actions, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
],
|
|
rows: _users.map((user) {
|
|
final roleColor = _roleColor(user['role']);
|
|
return DataRow(cells: [
|
|
DataCell(Text(user['email'] as String? ?? '')),
|
|
DataCell(Text(user['firstName'] as String? ?? '')),
|
|
DataCell(Text(user['lastName'] as String? ?? '')),
|
|
DataCell(Chip(
|
|
label: Text(
|
|
_roleName(user['role']),
|
|
style: TextStyle(color: roleColor, fontSize: 12),
|
|
),
|
|
backgroundColor: roleColor.withValues(alpha: 0.08),
|
|
side: BorderSide(color: roleColor.withValues(alpha: 0.3)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
visualDensity: VisualDensity.compact,
|
|
)),
|
|
DataCell(Row(children: [
|
|
IconButton(
|
|
icon: Icon(Icons.edit, color: kPrimaryColor, size: 20),
|
|
tooltip: l.tooltipEdit,
|
|
onPressed: () => _showEditDialog(context, managerCtx, user),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
|
|
tooltip: l.tooltipDelete,
|
|
onPressed: () => _confirmDelete(context, managerCtx, user),
|
|
),
|
|
])),
|
|
]);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|