Rebuild API Server on the PilotVault structure

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>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-16 22:29:45 +02:00
1 parent 7d55f0a4cd
commit ae6ed4ac1e
56 files changed
+4474 -1475

No files matched your search

+39 -21
View File
@@ -75,16 +75,39 @@ class ApiClient {
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
throw ApiException(res.statusCode, msg ?? "Request failed");
throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
}
return data;
}
/// Digs a human-readable message out of the error shapes in play: this
/// server's {error}, and PocketBase's {message, data:{field:{message}}} —
/// which the user endpoints relay verbatim, so a duplicate email arrives as a
/// per-field error rather than a flat string.
String _errorMessage(dynamic data, String? fallback) {
if (data is! Map) return fallback ?? "Request failed";
if (data["error"] != null) return data["error"].toString();
final fields = data["data"];
if (fields is Map && fields.isNotEmpty) {
final parts = fields.entries.map((e) {
final v = e.value;
final msg = v is Map && v["message"] != null ? v["message"] : v;
return "${e.key}: $msg";
});
return parts.join("; ");
}
if (data["message"] != null) return data["message"].toString();
return fallback ?? "Request failed";
}
// --- auth ---
/// The API Server proxies login to PocketBase and relays its response
/// verbatim, so the user arrives under `record` (PocketBase's name) and the
/// token is PocketBase's own — the server no longer mints its own JWT.
Future<(String, AuthUser)> login(String email, String password) async {
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["user"])));
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
}
// --- cars ---
@@ -125,31 +148,35 @@ class ApiClient {
Future<void> removeCarShare(String carId, String userId) =>
_send("DELETE", "/cars/$carId/shares/$userId");
// --- admin: user management (admin role only) ---
// --- user management (admin or superadmin) ---
// Admins are scoped by the server to their own organization; superadmins see
// everyone. Responses are enveloped ({users}/{user}).
Future<List<AdminUser>> listUsers() async {
final data = await _send("GET", "/admin/users") as List;
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
final data = await _send("GET", "/users");
final items = (data["users"] ?? []) as List;
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<AdminUser> createUser(
{required String email, required String password, String? name, String role = "user"}) async {
final data = await _send("POST", "/admin/users",
final data = await _send("POST", "/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
return AdminUser.fromJson(Map<String, dynamic>.from(data));
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
}
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
final body = <String, dynamic>{};
if (name != null) body["name"] = name;
if (role != null) body["role"] = role;
final data = await _send("PATCH", "/admin/users/$id", body: body);
return AdminUser.fromJson(Map<String, dynamic>.from(data));
final data = await _send("PATCH", "/users/$id", body: body);
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
}
/// Password resets are a field on the user PATCH now, not a separate endpoint.
Future<void> setUserPassword(String id, String newPassword) =>
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
_send("PATCH", "/users/$id", body: {"password": newPassword});
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
@@ -233,15 +260,6 @@ class ApiClient {
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
// --- settings: sessions ---
Future<List<Session>> listSessions() async {
final data = await _send("GET", "/sessions") as List;
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
// --- settings: account deletion ---
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
+19 -32
View File
@@ -176,21 +176,27 @@ class CarShare {
String get label => name.isNotEmpty ? name : email;
}
/// Roles allowed to manage users. A superadmin is an admin that also spans
/// every organization; the API Server enforces that difference.
const _managerRoles = {"admin", "superadmin"};
class AuthUser {
final String id;
final String email;
final String name;
final String role; // "user" | "admin"
final String role; // "user" | "admin" | "superadmin"
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
// An empty role is treated as "user", matching the server.
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
);
bool get isAdmin => role == "admin";
bool get isAdmin => _managerRoles.contains(role);
bool get isSuperadmin => role == "superadmin";
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
}
@@ -202,6 +208,7 @@ class AdminUser {
final String name;
final String role;
final String created;
final String organizationName; // "" when the user belongs to no organization
AdminUser({
required this.id,
@@ -209,15 +216,19 @@ class AdminUser {
required this.name,
required this.role,
required this.created,
this.organizationName = "",
});
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: _asStr(j["role"]),
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
created: _asStr(j["created"]),
organizationName: _asStr(j["organizationName"]),
);
bool get isSuperadmin => role == "superadmin";
}
/// The full authenticated profile (Settings panel), mirroring /api/me.
@@ -267,34 +278,10 @@ class UserProfile {
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
);
bool get isAdmin => role == "admin";
bool get isAdmin => _managerRoles.contains(role);
bool get isSuperadmin => role == "superadmin";
bool get deletionPending => deletionRequestedAt != null;
}
/// One active login session (device), mirroring /api/sessions.
class Session {
final String id;
final String deviceLabel;
final String ip;
final bool current;
final DateTime? created;
final DateTime? expiresAt;
Session({
required this.id,
required this.deviceLabel,
required this.ip,
required this.current,
required this.created,
required this.expiresAt,
});
factory Session.fromJson(Map<String, dynamic> j) => Session(
id: _asStr(j["id"]),
deviceLabel: _asStr(j["deviceLabel"]),
ip: _asStr(j["ip"]),
current: _asBool(j["current"]),
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
);
}
// The Session model is gone: auth now uses PocketBase's own stateless tokens,
// so there is no per-device session list to show or revoke.
+41 -17
View File
@@ -6,8 +6,10 @@ 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.
/// 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
@@ -26,6 +28,11 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
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)));
@@ -121,7 +128,6 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
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,
@@ -129,7 +135,8 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
itemBuilder: (context, i) {
final u = users[i];
final isSelf = u.id == _myId;
final lastAdmin = u.role == "admin" && adminCount <= 1;
// 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(
@@ -143,20 +150,28 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
],
),
subtitle: Text(
"${u.name.isEmpty ? '—' : u.name} · ${formatDate(DateTime.tryParse(u.created))}",
"${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 == "admin" ? "admin" : "user",
value: u.role,
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")),
// 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>(
@@ -165,11 +180,15 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
if (choice == "delete") _delete(u);
},
itemBuilder: (_) => [
const PopupMenuItem(value: "password", child: Text("Reset password")),
PopupMenuItem(
value: "password",
enabled: !locked,
child: const Text("Reset password"),
),
PopupMenuItem(
value: "delete",
// Can't delete yourself or the last admin.
enabled: !isSelf && !lastAdmin,
// Can't delete yourself, or a superadmin you don't outrank.
enabled: !isSelf && !locked,
child: const Text("Delete", style: TextStyle(color: DriverVault.danger)),
),
],
@@ -199,6 +218,12 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
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();
@@ -274,9 +299,8 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
DropdownButton<String>(
value: _role,
onChanged: (v) => setState(() => _role = v ?? "user"),
items: const [
DropdownMenuItem(value: "user", child: Text("user")),
DropdownMenuItem(value: "admin", child: Text("admin")),
items: [
for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)),
],
),
],
+16 -80
View File
@@ -20,7 +20,6 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
UserProfile? _profile;
List<Session> _sessions = [];
Uint8List? _avatar;
bool _loading = true;
String? _loadError;
@@ -49,7 +48,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
final p = await apiClient.getMe();
appSettings.applyFromProfile(p);
final sessions = await apiClient.listSessions();
Uint8List? avatar;
if (p.hasAvatar) {
final bytes = await apiClient.getAvatarBytes();
@@ -59,7 +57,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_bio.text = p.bio;
setState(() {
_profile = p;
_sessions = sessions;
_avatar = avatar;
});
} catch (e) {
@@ -102,7 +99,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 12),
_SecuritySection(email: _profile!.email, snack: _snack),
const SizedBox(height: 12),
_SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack),
const _PrivacySection(),
const SizedBox(height: 12),
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
const SizedBox(height: 24),
@@ -669,39 +666,12 @@ class _SecuritySectionState extends State<_SecuritySection> {
}
}
// --- Privacy & security (sessions) -----------------------------------------
// --- Privacy & security -----------------------------------------------------
class _SessionsSection extends StatefulWidget {
final List<Session> sessions;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _SessionsSection({required this.sessions, required this.onChanged, required this.snack});
@override
State<_SessionsSection> createState() => _SessionsSectionState();
}
class _SessionsSectionState extends State<_SessionsSection> {
Future<void> _revoke(Session s) async {
try {
await apiClient.revokeSession(s.id);
if (s.current) {
await authService.logout();
return;
}
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
Future<void> _revokeOthers() async {
try {
await apiClient.revokeOtherSessions();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
/// list to show or revoke — this card explains what "sign out" actually does.
class _PrivacySection extends StatelessWidget {
const _PrivacySection();
@override
Widget build(BuildContext context) {
@@ -709,53 +679,19 @@ class _SessionsSectionState extends State<_SessionsSection> {
title: "Privacy & security",
children: [
const Text(
"Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.",
"Two-factor authentication isn't available yet. Sessions are held as "
"server-issued tokens that expire on their own, so signing out ends "
"this device's session only. To lock out every device, change your "
"password above.",
style: TextStyle(color: Colors.grey, fontSize: 13),
),
if (widget.sessions.length > 1)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _revokeOthers,
child: const Text("Log out all other devices"),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => authService.logout(),
child: const Text("Sign out", style: TextStyle(color: DriverVault.danger)),
),
...widget.sessions.map((s) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(
child: Text(s.deviceLabel,
style: const TextStyle(fontWeight: FontWeight.w500))),
if (s.current)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: DriverVault.brandTint(context),
borderRadius: BorderRadius.circular(999),
),
child: Text("This device",
style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))),
),
]),
Text("${s.ip} · signed in ${formatDate(s.created)}",
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
TextButton(
onPressed: () => _revoke(s),
child: const Text("Log out", style: TextStyle(color: DriverVault.danger)),
),
],
),
)),
),
],
);
}