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>
288 lines
9.0 KiB
Dart
288 lines
9.0 KiB
Dart
// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx).
|
|
|
|
int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0);
|
|
String _asStr(dynamic v) => v == null ? "" : v.toString();
|
|
bool _asBool(dynamic v) => v == true;
|
|
|
|
class Car {
|
|
final String id;
|
|
final String name;
|
|
final String make;
|
|
final String model;
|
|
final int year;
|
|
final String registration;
|
|
final String registrationCountry;
|
|
final String vin;
|
|
final String oilSpec;
|
|
final String transmissionOilSpec;
|
|
final String differentialOilSpec;
|
|
final String brakeFluidSpec;
|
|
final String coolantSpec;
|
|
final String fuelType; // petrol | diesel | hybrid | electric
|
|
final String buildDate; // ISO YYYY-MM-DD (date-only)
|
|
final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
|
|
final int serviceIntervalDays;
|
|
final int serviceIntervalKm;
|
|
final int currentKm;
|
|
|
|
/// The requesting user's permission on this car: "owner", "write", or "read".
|
|
/// The API sets it on every car read. Defaults to "owner" so older responses
|
|
/// (or any code that constructs a Car without it) stay fully editable.
|
|
final String access;
|
|
|
|
Car({
|
|
required this.id,
|
|
required this.name,
|
|
required this.make,
|
|
required this.model,
|
|
required this.year,
|
|
required this.registration,
|
|
this.registrationCountry = "",
|
|
required this.vin,
|
|
required this.oilSpec,
|
|
required this.transmissionOilSpec,
|
|
required this.differentialOilSpec,
|
|
required this.brakeFluidSpec,
|
|
required this.coolantSpec,
|
|
this.fuelType = "",
|
|
this.buildDate = "",
|
|
this.firstRegistrationDate = "",
|
|
required this.serviceIntervalDays,
|
|
required this.serviceIntervalKm,
|
|
required this.currentKm,
|
|
this.access = "owner",
|
|
});
|
|
|
|
factory Car.fromJson(Map<String, dynamic> j) => Car(
|
|
id: _asStr(j["id"]),
|
|
name: _asStr(j["name"]),
|
|
make: _asStr(j["make"]),
|
|
model: _asStr(j["model"]),
|
|
year: _asInt(j["year"]),
|
|
registration: _asStr(j["registration"]),
|
|
registrationCountry: _asStr(j["registrationCountry"]),
|
|
vin: _asStr(j["vin"]),
|
|
oilSpec: _asStr(j["oilSpec"]),
|
|
transmissionOilSpec: _asStr(j["transmissionOilSpec"]),
|
|
differentialOilSpec: _asStr(j["differentialOilSpec"]),
|
|
brakeFluidSpec: _asStr(j["brakeFluidSpec"]),
|
|
coolantSpec: _asStr(j["coolantSpec"]),
|
|
fuelType: _asStr(j["fuelType"]),
|
|
buildDate: _asStr(j["buildDate"]),
|
|
firstRegistrationDate: _asStr(j["firstRegistrationDate"]),
|
|
serviceIntervalDays: _asInt(j["serviceIntervalDays"]),
|
|
serviceIntervalKm: _asInt(j["serviceIntervalKm"]),
|
|
currentKm: _asInt(j["currentKm"]),
|
|
access: j["access"] == null ? "owner" : _asStr(j["access"]),
|
|
);
|
|
|
|
bool get isOwner => access == "owner";
|
|
bool get canWrite => access == "owner" || access == "write";
|
|
bool get isReadOnly => access == "read";
|
|
|
|
String get subtitle =>
|
|
[make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
|
|
}
|
|
|
|
class ServiceRecord {
|
|
final String id;
|
|
final String car;
|
|
final DateTime? date;
|
|
final int km;
|
|
final bool changedOil;
|
|
final bool changedEngineAirFilter;
|
|
final bool changedCabinAirFilter;
|
|
final String notes;
|
|
final DateTime? nextServiceDate;
|
|
final int? nextServiceKm;
|
|
|
|
ServiceRecord({
|
|
required this.id,
|
|
required this.car,
|
|
required this.date,
|
|
required this.km,
|
|
required this.changedOil,
|
|
required this.changedEngineAirFilter,
|
|
required this.changedCabinAirFilter,
|
|
required this.notes,
|
|
required this.nextServiceDate,
|
|
required this.nextServiceKm,
|
|
});
|
|
|
|
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
|
|
id: _asStr(j["id"]),
|
|
car: _asStr(j["car"]),
|
|
date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(),
|
|
km: _asInt(j["km"]),
|
|
changedOil: _asBool(j["changedOil"]),
|
|
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
|
|
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
|
|
notes: _asStr(j["notes"]),
|
|
nextServiceDate: j["nextServiceDate"] != null
|
|
? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal()
|
|
: null,
|
|
nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]),
|
|
);
|
|
}
|
|
|
|
class Part {
|
|
final String id;
|
|
final String car;
|
|
final String name;
|
|
final String partNumber;
|
|
final String category;
|
|
|
|
Part({
|
|
required this.id,
|
|
required this.car,
|
|
required this.name,
|
|
required this.partNumber,
|
|
this.category = "",
|
|
});
|
|
|
|
factory Part.fromJson(Map<String, dynamic> j) => Part(
|
|
id: _asStr(j["id"]),
|
|
car: _asStr(j["car"]),
|
|
name: _asStr(j["name"]),
|
|
partNumber: _asStr(j["partNumber"]),
|
|
category: _asStr(j["category"]),
|
|
);
|
|
}
|
|
|
|
/// A sharing grant: another user's access to one of your cars.
|
|
class CarShare {
|
|
final String userId;
|
|
final String email;
|
|
final String name;
|
|
final String permission; // "read" | "write"
|
|
|
|
CarShare({
|
|
required this.userId,
|
|
required this.email,
|
|
required this.name,
|
|
required this.permission,
|
|
});
|
|
|
|
factory CarShare.fromJson(Map<String, dynamic> j) {
|
|
final user = Map<String, dynamic>.from(j["user"] ?? {});
|
|
return CarShare(
|
|
userId: _asStr(user["id"]),
|
|
email: _asStr(user["email"]),
|
|
name: _asStr(user["name"]),
|
|
permission: _asStr(j["permission"]),
|
|
);
|
|
}
|
|
|
|
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" | "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"]),
|
|
// An empty role is treated as "user", matching the server.
|
|
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
|
|
);
|
|
|
|
bool get isAdmin => _managerRoles.contains(role);
|
|
bool get isSuperadmin => role == "superadmin";
|
|
|
|
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
|
|
}
|
|
|
|
/// A user record as returned by the admin user-management endpoints.
|
|
class AdminUser {
|
|
final String id;
|
|
final String email;
|
|
final String name;
|
|
final String role;
|
|
final String created;
|
|
final String organizationName; // "" when the user belongs to no organization
|
|
|
|
AdminUser({
|
|
required this.id,
|
|
required this.email,
|
|
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"]).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.
|
|
class UserProfile {
|
|
final String id;
|
|
final String email;
|
|
final bool verified;
|
|
final String name;
|
|
final String bio;
|
|
final bool hasAvatar;
|
|
final String theme; // light | dark | system
|
|
final String locale;
|
|
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
|
|
final String fontSize; // small | medium | large
|
|
final String role; // user | admin
|
|
final DateTime? deletionRequestedAt;
|
|
|
|
UserProfile({
|
|
required this.id,
|
|
required this.email,
|
|
required this.verified,
|
|
required this.name,
|
|
required this.bio,
|
|
required this.hasAvatar,
|
|
required this.theme,
|
|
required this.locale,
|
|
required this.dateFormat,
|
|
required this.fontSize,
|
|
required this.role,
|
|
required this.deletionRequestedAt,
|
|
});
|
|
|
|
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
|
|
id: _asStr(j["id"]),
|
|
email: _asStr(j["email"]),
|
|
verified: _asBool(j["verified"]),
|
|
name: _asStr(j["name"]),
|
|
bio: _asStr(j["bio"]),
|
|
hasAvatar: _asBool(j["hasAvatar"]),
|
|
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
|
|
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
|
|
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
|
|
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
|
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
|
deletionRequestedAt: j["deletionRequestedAt"] == null
|
|
? null
|
|
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
|
|
);
|
|
|
|
bool get isAdmin => _managerRoles.contains(role);
|
|
bool get isSuperadmin => role == "superadmin";
|
|
bool get deletionPending => deletionRequestedAt != null;
|
|
}
|
|
|
|
// 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.
|