Files
DriverVault/Phone App/lib/screens/admin_users_screen.dart
T
tajniak81andClaude Opus 5 dc6febf815 Phone App: finish the admin screen; translate the last English strings
Three loose ends from the last two commits, each of which was named as
deliberately-not-done and none of which is worth carrying further.

The phone's create-user sheet had no organization picker. The endpoint has
taken an `organization` since orgs existed and the Web App has offered the
choice all along, so a superadmin on the phone could only ever create
accounts in their own org - a silent restriction rather than a stated one.
The sheet now loads the orgs and offers them to a superadmin, with the same
blank "no organization" option and the same hint as the web. An admin still
gets no picker, because the server forces its own org on their members and a
picker that cannot change the outcome is a lie. The listing is manager-only
and can fail, in which case the picker offers only "no organization" rather
than blocking the form.

A locked role picker or delete action was greyed out with no reason given.
The web has explained itself in a title attribute since those guards existed,
and the sentences - admin.cantChangeOwnRole and the rest - have been sitting
translated in the phone's own language files since the screen was translated.
Hover has no touch equivalent, so the two controls take different routes: a
long-press on the role picker shows the reason as a tooltip, and the overflow
menu carries it under the action, because a disabled menu item cannot be
long-pressed and silently greying it out is the thing being fixed.

settings.integrations.* and charging.control.* were English-only in *both*
apps - 70 keys, identical text, identical key sets - so they are translated
once and land in all four language files. OCPP and CSMS are protocol names
and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay;
everything else follows the wording already in each language's file.

The Web App's files are edited as text rather than round-tripped through a
JSON dump, because they keep a blank line before every nested block and a
dump flattens it - a 900-line translation file is hard enough to read
without losing its paragraphs. Both diffs are purely additive as a result.

Both apps now have every key in all three languages: 738 in the web, and the
phone reports zero fallbacks. A new test locks that in - every key en.json
carries must exist in pl.json and da.json - and it was checked by deleting a
key and watching it fail, because a guard that cannot fire is not a guard.

Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new -
flutter build apk --debug, and npm run build for the Web App. The key checker
reports 575 static t() keys in the phone and 738 in the web resolving with no
fallbacks in either language.

Not verified: still nothing run against a live API Server or on a device. In
particular the organization picker's happy path - a superadmin creating an
account into a chosen org - has not been exercised end to end; it is the one
piece here that touches the API rather than only the language files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:13:37 +02:00

440 lines
16 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"];
/// 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<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];
// 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<String>(
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<String>(
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<Organization> _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<String> get _assignableRoles => authService.user?.isSuperadmin == true
? const ["user", "admin", "superadmin"]
: const ["user", "admin"];
@override
void initState() {
super.initState();
if (_iamSuperadmin) _loadOrgs();
}
Future<void> _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<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,
// 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: 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"))),
],
),
],
),
if (_iamSuperadmin) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
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),
],
);
}
}