import "package:flutter/material.dart"; import "../i18n.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"]; /// Why a row's destructive and role controls are locked, or "" when they are /// not. These mirror the server's guards, so the UI never offers an action /// that would come back a 403 — and, unlike simply greying the control out, /// they say which guard it is. Same wording as the web app's tooltips. String _deleteBlockedReason(AdminUser u) { if (u.id == _myId) return t("admin.cantDeleteSelf"); if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminDeletes"); return ""; } String _roleLockReason(AdminUser u) { if (u.id == _myId) return t("admin.cantChangeOwnRole"); if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminEdits"); return ""; } 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(t("admin.resetTitle", params: {"email": u.email})), content: TextField( controller: controller, autofocus: true, decoration: InputDecoration( labelText: '${t("admin.newPassword")} ${t("admin.minChars")}', border: const OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t("admin.setPassword"))), ], ), ); if (saved != true) return; try { await apiClient.setUserPassword(u.id, controller.text); _snack(t("admin.passwordUpdated")); } catch (e) { _snack("$e"); } } Future _delete(AdminUser u) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t("admin.deleteTitle")), content: Text(t("admin.confirmDelete", params: {"name": u.name.isEmpty ? u.email : u.name})), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: () => Navigator.pop(ctx, true), child: Text(t("common.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: Text(t("admin.title"))), floatingActionButton: FloatingActionButton.extended( onPressed: _createUser, icon: const Icon(Icons.person_add_alt_1), label: Text(t("admin.addUser")), ), 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]; // An admin may not edit or delete a superadmin, and nobody may // touch their own role or account. Both guards carry the sentence // that explains them. final roleLock = _roleLockReason(u); final deleteLock = _deleteBlockedReason(u); final isSelf = u.id == _myId; 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) Padding( padding: const EdgeInsets.only(left: 6), child: Text(t("admin.you"), style: const TextStyle(color: Colors.grey, fontSize: 12)), ), ], ), subtitle: Text( "${u.name.isEmpty ? t("common.empty") : u.name}" "${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}" " · ${formatDate(DateTime.tryParse(u.created))}", style: const TextStyle(fontSize: 12), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // A long-press on the greyed-out picker says why it is // greyed out — the touch equivalent of the web's tooltip. Tooltip( message: roleLock, triggerMode: roleLock.isEmpty ? TooltipTriggerMode.manual : TooltipTriggerMode.longPress, child: DropdownButton( value: u.role, underline: const SizedBox.shrink(), onChanged: roleLock.isNotEmpty ? null : (v) => v == null ? null : _changeRole(u, v), items: [ for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(t("admin.roles.$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(t("admin.roles.${u.role}"))), ], ), ), PopupMenuButton( onSelected: (choice) { if (choice == "password") _resetPassword(u); if (choice == "delete") _delete(u); }, itemBuilder: (_) => [ PopupMenuItem( value: "password", enabled: !locked, child: _MenuEntry( label: t("admin.resetPassword"), reason: locked ? t("admin.onlySuperadminEdits") : "", ), ), PopupMenuItem( value: "delete", enabled: deleteLock.isEmpty, child: _MenuEntry( label: t("common.delete"), reason: deleteLock, 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; /// Organizations a superadmin can drop the new account into. An admin gets no /// picker: the server puts their members in their own org regardless, so /// offering a choice would be a lie. Empty until loaded, and left empty if the /// listing fails — a picker that cannot be filled must not block the form. List _orgs = const []; /// The chosen tenant; "" is a real choice, meaning an account belonging to no /// organization at all. String _organization = ""; bool get _iamSuperadmin => authService.user?.isSuperadmin == true; /// 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 initState() { super.initState(); if (_iamSuperadmin) _loadOrgs(); } Future _loadOrgs() async { try { final orgs = await apiClient.listOrgs(); if (mounted) setState(() => _orgs = orgs); } catch (_) { // Listing is manager-only and can fail; the picker then offers only "no // organization", which is still a valid account to create. } } @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, // Only a superadmin picks; for an admin the server forces its own org, // so sending anything here would be noise. organization: _iamSuperadmin ? _organization : null, ); 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: DriverVault.sheetBottomInset(context), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t("admin.createTitle"), style: const 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: InputDecoration( labelText: t("admin.emailRequired"), border: const OutlineInputBorder()), ), const SizedBox(height: 8), TextField( controller: _name, decoration: InputDecoration( labelText: t("admin.colName"), border: const OutlineInputBorder()), ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _password, decoration: InputDecoration( labelText: '${t("admin.passwordRequired")} ${t("admin.minChars")}', border: const 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(t("admin.roles.$r"))), ], ), ], ), if (_iamSuperadmin) ...[ const SizedBox(height: 8), DropdownButtonFormField( initialValue: _organization, isExpanded: true, decoration: InputDecoration( labelText: t("admin.colOrganization"), helperText: t("admin.organizationHint"), helperMaxLines: 3, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: "", child: Text(t("admin.noOrganization"))), for (final o in _orgs) DropdownMenuItem(value: o.id, child: Text(o.name)), ], onChanged: (v) => setState(() => _organization = v ?? ""), ), ], const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: Text(t(_saving ? "admin.creating" : "admin.createUser")), ), ), ], ), ); } } /// One entry of a row's overflow menu. A blocked action keeps its label and /// gains the sentence saying why it is blocked: the item is disabled, so it /// cannot be long-pressed for a tooltip the way the role picker can, and a /// silently greyed-out action is the thing this is meant to avoid. class _MenuEntry extends StatelessWidget { final String label; final String reason; final Color? color; const _MenuEntry({required this.label, required this.reason, this.color}); @override Widget build(BuildContext context) { if (reason.isEmpty) return Text(label, style: TextStyle(color: color)); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: color)), Text(reason, style: TextStyle(fontSize: 11, color: DriverVault.muted(context)), softWrap: true), ], ); } }