Files
DriverVault/Phone App/lib/models.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

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.