Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
321 lines
11 KiB
Dart
321 lines
11 KiB
Dart
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<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("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 ?? [];
|
|
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<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(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<String>(
|
|
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<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: [
|
|
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: [
|
|
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"),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|