import "package:flutter/material.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; import "../theme.dart"; /// Admin-only screen to manage user accounts: list, create, change role, /// reset password, delete. The API enforces the real access control — an admin /// is scoped to their own organization and cannot touch a superadmin, and /// nobody may change their own role or delete their own account. The UI mirrors /// those guards to avoid offering dead actions. class AdminUsersScreen extends StatefulWidget { const AdminUsersScreen({super.key}); @override State createState() => _AdminUsersScreenState(); } class _AdminUsersScreenState extends State { late Future> _future; @override void initState() { super.initState(); _future = apiClient.listUsers(); } void _reload() => setState(() => _future = apiClient.listUsers()); String? get _myId => authService.user?.id; bool get _iamSuperadmin => authService.user?.isSuperadmin == true; /// Roles this viewer may hand out. Only a superadmin can mint another one. List get _assignableRoles => _iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"]; void _snack(String msg) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); Future _changeRole(AdminUser u, String role) async { try { await apiClient.updateUser(u.id, role: role); _reload(); } catch (e) { _snack("$e"); } } Future _resetPassword(AdminUser u) async { final controller = TextEditingController(); final saved = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text("Reset password — ${u.email}"), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: "New password (min 8)", border: OutlineInputBorder(), ), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Set password")), ], ), ); if (saved != true) return; try { await apiClient.setUserPassword(u.id, controller.text); _snack("Password updated."); } catch (e) { _snack("$e"); } } Future _delete(AdminUser u) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text("Delete user?"), content: Text("Delete ${u.name.isEmpty ? u.email : u.name}? This cannot be undone."), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: () => Navigator.pop(ctx, true), child: const Text("Delete"), ), ], ), ); if (confirmed != true) return; try { await apiClient.deleteUser(u.id); _reload(); } catch (e) { _snack("$e"); } } Future _createUser() async { final created = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => const _CreateUserSheet(), ); if (created == true) _reload(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Users")), floatingActionButton: FloatingActionButton.extended( onPressed: _createUser, icon: const Icon(Icons.person_add_alt_1), label: const Text("Add user"), ), body: FutureBuilder>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { return Center(child: Text("${snap.error}")); } final users = snap.data ?? []; return ListView.separated( padding: const EdgeInsets.all(12), itemCount: users.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final u = users[i]; final isSelf = u.id == _myId; // An admin may not edit or delete a superadmin; only a superadmin may. final locked = u.isSuperadmin && !_iamSuperadmin; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4), title: Row( children: [ Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)), if (isSelf) const Padding( padding: EdgeInsets.only(left: 6), child: Text("(you)", style: TextStyle(color: Colors.grey, fontSize: 12)), ), ], ), subtitle: Text( "${u.name.isEmpty ? '—' : u.name}" "${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}" " · ${formatDate(DateTime.tryParse(u.created))}", style: const TextStyle(fontSize: 12), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ DropdownButton( value: u.role, underline: const SizedBox.shrink(), // Nobody may change their own role; only a superadmin may // touch a superadmin. onChanged: (isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v), items: [ for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)), // Keep the current role selectable even when this viewer // can't assign it, so the dropdown has a valid value. if (!_assignableRoles.contains(u.role)) DropdownMenuItem(value: u.role, child: Text(u.role)), ], ), PopupMenuButton( onSelected: (choice) { if (choice == "password") _resetPassword(u); if (choice == "delete") _delete(u); }, itemBuilder: (_) => [ PopupMenuItem( value: "password", enabled: !locked, child: const Text("Reset password"), ), PopupMenuItem( value: "delete", // Can't delete yourself, or a superadmin you don't outrank. enabled: !isSelf && !locked, child: const Text("Delete", style: TextStyle(color: DriverVault.danger)), ), ], ), ], ), ); }, ); }, ), ); } } class _CreateUserSheet extends StatefulWidget { const _CreateUserSheet(); @override State<_CreateUserSheet> createState() => _CreateUserSheetState(); } class _CreateUserSheetState extends State<_CreateUserSheet> { final _email = TextEditingController(); final _name = TextEditingController(); final _password = TextEditingController(); String _role = "user"; bool _saving = false; String? _error; /// Only a superadmin can create another one. An admin's new users are placed /// in the admin's own organization by the server. List get _assignableRoles => authService.user?.isSuperadmin == true ? const ["user", "admin", "superadmin"] : const ["user", "admin"]; @override void dispose() { _email.dispose(); _name.dispose(); _password.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); try { await apiClient.createUser( email: _email.text.trim(), password: _password.text, name: _name.text.trim(), role: _role, ); if (mounted) Navigator.pop(context, true); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("Add a user", style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 12), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), ), TextField( controller: _email, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()), ), const SizedBox(height: 8), TextField( controller: _name, decoration: const InputDecoration(labelText: "Name", border: OutlineInputBorder()), ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _password, decoration: const InputDecoration( labelText: "Password (min 8)", border: OutlineInputBorder(), ), ), ), const SizedBox(width: 8), DropdownButton( value: _role, onChanged: (v) => setState(() => _role = v ?? "user"), items: [ for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)), ], ), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: Text(_saving ? "Creating…" : "Create user"), ), ), ], ), ); } }