The last screen the phone rendered in English regardless of the language
picker. Its strings are the web AdminUsers.vue's, which have been translated
since b6bb6b1, so the admin.* subtree is copied across the same way the rest
were and Polish and Danish arrive complete.
Two strings are the phone's own, and only because the two UIs confirm
differently: the web deletes behind a browser confirm(), which supplies its
own title, and reports a password reset by closing the modal. The phone has a
dialog title and a snackbar to fill, so admin.deleteTitle and
admin.passwordUpdated are new.
The role picker printed its values raw - Text(r) over "user"/"admin"/
"superadmin" - which happened to read as English because the API's enum is
English. It goes through admin.roles.* now, like the web's, and the new
lookup is covered by the catalogue test alongside the other dynamic ones.
The Web App needed nothing: AdminUsers.vue was already fully translated,
including the role options and the tooltips explaining why a locked row
cannot be edited. Checked rather than assumed - it has 31 t() calls and no
bare text nodes in the template.
Two differences from the web turned up while reading them side by side, both
feature gaps rather than translation ones, and both left alone here: the
phone's create-user sheet has no organization picker for a superadmin (the
server puts the account in the creator's org), and the phone disables a
locked role dropdown or delete action without saying why, where the web
explains it in a title attribute. The strings for that explanation are now
sitting in the phone's language files, so it is a small change if wanted.
Verified by flutter analyze (clean), flutter test - 20 pass, 1 of them new -
and flutter build apk --debug. The key checker reports all 568 static t()
keys resolving in en.json.
Not verified: still nothing run against a live API Server or on a device.
TRANSLATIONS.md and the Phone App README both listed this screen as
outstanding; the only gap either records now is settings.integrations.* /
charging.control.*, which is the web app's too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
11 KiB
Dart
332 lines
11 KiB
Dart
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<AdminUsersScreen> createState() => _AdminUsersScreenState();
|
|
}
|
|
|
|
class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
|
late Future<List<AdminUser>> _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<String> get _assignableRoles =>
|
|
_iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"];
|
|
|
|
void _snack(String msg) =>
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
|
|
|
Future<void> _changeRole(AdminUser u, String role) async {
|
|
try {
|
|
await apiClient.updateUser(u.id, role: role);
|
|
_reload();
|
|
} catch (e) {
|
|
_snack("$e");
|
|
}
|
|
}
|
|
|
|
Future<void> _resetPassword(AdminUser u) async {
|
|
final controller = TextEditingController();
|
|
final saved = await showDialog<bool>(
|
|
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<void> _delete(AdminUser u) async {
|
|
final confirmed = await showDialog<bool>(
|
|
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<void> _createUser() async {
|
|
final created = await showModalBottomSheet<bool>(
|
|
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<List<AdminUser>>(
|
|
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)
|
|
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: [
|
|
DropdownButton<String>(
|
|
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(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<String>(
|
|
onSelected: (choice) {
|
|
if (choice == "password") _resetPassword(u);
|
|
if (choice == "delete") _delete(u);
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
value: "password",
|
|
enabled: !locked,
|
|
child: Text(t("admin.resetPassword")),
|
|
),
|
|
PopupMenuItem(
|
|
value: "delete",
|
|
// Can't delete yourself, or a superadmin you don't outrank.
|
|
enabled: !isSelf && !locked,
|
|
child: Text(t("common.delete"),
|
|
style: const 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<String> 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<void> _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: [
|
|
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<String>(
|
|
value: _role,
|
|
onChanged: (v) => setState(() => _role = v ?? "user"),
|
|
items: [
|
|
for (final r in _assignableRoles)
|
|
DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: Text(t(_saving ? "admin.creating" : "admin.createUser")),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|