Initial commit
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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 and the
|
||||
/// last-admin / self-delete guards; the UI mirrors them to avoid 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;
|
||||
|
||||
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("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<void> _delete(AdminUser u) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<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: const Text("Users")),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _createUser,
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
label: const Text("Add user"),
|
||||
),
|
||||
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 ?? [];
|
||||
final adminCount = users.where((u) => u.role == "admin").length;
|
||||
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;
|
||||
final lastAdmin = u.role == "admin" && adminCount <= 1;
|
||||
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} · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButton<String>(
|
||||
value: u.role == "admin" ? "admin" : "user",
|
||||
underline: const SizedBox.shrink(),
|
||||
// Disable demoting the last admin.
|
||||
onChanged: lastAdmin ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (choice) {
|
||||
if (choice == "password") _resetPassword(u);
|
||||
if (choice == "delete") _delete(u);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: "password", child: Text("Reset password")),
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
// Can't delete yourself or the last admin.
|
||||
enabled: !isSelf && !lastAdmin,
|
||||
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;
|
||||
|
||||
@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: [
|
||||
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<String>(
|
||||
value: _role,
|
||||
onChanged: (v) => setState(() => _role = v ?? "user"),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? "Creating…" : "Create user"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user