Files
DriverVault/Phone App/lib/api.dart
T
tajniak81andClaude Opus 4.8 ae6ed4ac1e 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>
2026-07-16 22:29:45 +02:00

273 lines
11 KiB
Dart

import "dart:convert";
import "package:http/http.dart" as http;
import "package:shared_preferences/shared_preferences.dart";
import "config.dart";
import "models.dart";
/// Thrown when the API Server returns a non-2xx response.
class ApiException implements Exception {
final int status;
final String message;
ApiException(this.status, this.message);
@override
String toString() => message;
}
/// The single client for the Car Control API Server. Holds the bearer token and
/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can
/// route back to login.
class ApiClient {
String? token;
void Function()? onUnauthorized;
static const _serverKey = "cc_server_url";
/// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override
/// (login screen "Server settings") replaces it via [loadServerUrl].
String baseUrl = kDefaultApiBase;
/// Loads a saved server-URL override, if any. Call before the first request.
Future<void> loadServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_serverKey);
if (saved != null && saved.isNotEmpty) baseUrl = saved;
}
/// The current override URL, or "" when using the default.
Future<String> serverOverride() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverKey) ?? "";
}
/// Persists a server-URL override. Blank clears it (reverts to the default).
/// Trailing slashes are trimmed.
Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final trimmed = url.trim().replaceAll(RegExp(r"/+$"), "");
if (trimmed.isEmpty) {
await prefs.remove(_serverKey);
baseUrl = kDefaultApiBase;
} else {
await prefs.setString(_serverKey, trimmed);
baseUrl = trimmed;
}
}
Map<String, String> get _headers => {
"Content-Type": "application/json",
if (token != null) "Authorization": "Bearer $token",
};
Uri _uri(String path) => Uri.parse("$baseUrl$path");
Future<dynamic> _send(String method, String path, {Object? body}) async {
final req = http.Request(method, _uri(path))..headers.addAll(_headers);
if (body != null) req.body = jsonEncode(body);
final streamed = await http.Client().send(req);
final res = await http.Response.fromStream(streamed);
if (res.statusCode == 401 && path != "/auth/login") {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
if (res.statusCode == 204 || res.body.isEmpty) return null;
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
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["record"])));
}
// --- cars ---
Future<List<Car>> listCars() async {
final data = await _send("GET", "/cars") as List;
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Car> getCar(String id) async {
final data = await _send("GET", "/cars/$id");
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> createCar(Map<String, dynamic> body) async {
final data = await _send("POST", "/cars", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/cars/$id", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
// --- sharing (owner-only) ---
Future<List<CarShare>> listCarShares(String carId) async {
final data = await _send("GET", "/cars/$carId/shares") as List;
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<CarShare> addCarShare(String carId, String email, String permission) async {
final data = await _send("POST", "/cars/$carId/shares",
body: {"email": email, "permission": permission});
return CarShare.fromJson(Map<String, dynamic>.from(data));
}
Future<void> removeCarShare(String carId, String userId) =>
_send("DELETE", "/cars/$carId/shares/$userId");
// --- 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", "/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", "/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
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", "/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("PATCH", "/users/$id", body: {"password": newPassword});
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
final data = await _send("GET", "/cars/$carId/service-records") as List;
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
final data = await _send("POST", "/service-records", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/service-records/$id", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
// --- parts ---
Future<List<Part>> listCarParts(String carId) async {
final data = await _send("GET", "/cars/$carId/parts") as List;
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Part> createPart(Map<String, dynamic> body) async {
final data = await _send("POST", "/parts", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/parts/$id", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
// --- settings: profile / account ---
Future<UserProfile> getMe() async {
final data = await _send("GET", "/me");
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
final data = await _send("PATCH", "/me", body: patch);
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<void> changePassword(String oldPassword, String newPassword) => _send(
"POST",
"/me/password",
body: {"oldPassword": oldPassword, "newPassword": newPassword},
);
Future<void> requestVerification() => _send("POST", "/me/verify/request");
// --- settings: avatar ---
Future<UserProfile> uploadAvatar(List<int> bytes, String filename) async {
final req = http.MultipartRequest("POST", _uri("/me/avatar"));
if (token != null) req.headers["Authorization"] = "Bearer $token";
req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename));
final res = await http.Response.fromStream(await req.send());
if (res.statusCode == 401) {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
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 ?? "Upload failed");
}
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<List<int>?> getAvatarBytes() async {
final res = await http.get(_uri("/me/avatar"),
headers: {if (token != null) "Authorization": "Bearer $token"});
if (res.statusCode == 200) return res.bodyBytes;
return null;
}
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
// --- settings: account deletion ---
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
final at = (data is Map) ? data["eligibleAt"] : null;
return at == null ? null : DateTime.tryParse(at.toString());
}
Future<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
}