Files
DriverVault/Phone App/lib/models.dart
T
tajniak81andClaude Opus 5 cd16d4383f Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed
an out-of-band superadmin. Creating one is now self-service, and an admin
manages the org they belong to.

- POST /api/orgs is open to any authenticated user. A creator who isn't a
  superadmin must have no organization yet (a single-valued membership
  relation means a second one would abandon the first), and is promoted to
  the new org's admin and first member in the same request. If that
  promotion fails the org is rolled back, so it is never left stranded
  with nobody able to administer it. Superadmins still create tenants
  without joining them.
- PATCH/DELETE are manager-gated and scope an admin to their own org. An
  admin deletes theirs only as its sole member: they are detached and
  demoted to a plain user before the record goes, so the org is empty when
  it is removed. Other members still block deletion with a 409.
- /api/me now carries organization + organizationName, which the clients
  need to tell "no org yet" from "org you administer".

The panel, Web App (new OrgManager.vue in Settings) and Phone App (new
_OrganizationSection) all mirror the server's gates rather than
re-deciding them. The Phone App cached its role at login and gates the
Users tab on it, so AuthService.adoptRole refreshes that from the profile
instead of making a freshly promoted admin sign in again.

Covered by orgs_test.go, which drives the real handler + middleware chain
against a stand-in PocketBase: promotion, the already-a-member refusal,
superadmin staying unattached, the rollback, own-org scoping, the
detach-and-demote, and the blocking-member 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:54:02 +02:00

894 lines
29 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;
double _asDouble(dynamic v) => v is num ? v.toDouble() : 0.0;
/// Nullable variants for the derived fields the server omits when it could not
/// compute them. A missing consumption figure is not zero — it means "unknown",
/// and must stay distinguishable so the UI can render "—" instead of a
/// misleading 0.0.
int? _asIntOrNull(dynamic v) => v is num ? v.toInt() : null;
double? _asDoubleOrNull(dynamic v) => v is num ? v.toDouble() : null;
DateTime? _asDate(dynamic v) =>
v == null ? null : DateTime.tryParse(v.toString())?.toLocal();
/// The single optional file a record carries — a receipt, a scan, a photo of a
/// part's box. Mirrors the API's embedded Attachment: the bytes are never in the
/// JSON, only whether there are any and what they were stored as. Fetch them
/// from GET /{records}/{id}/file, which re-checks access per request.
mixin HasAttachment {
String get fileName;
bool get hasFile;
}
/// The server-computed lifecycle state of a dated document or certificate.
/// [days] is null when there is no expiry date at all.
class ExpiryAssessment {
final String state; // no_expiry | valid | expiring_soon | expired
final int? days;
const ExpiryAssessment({this.state = "no_expiry", this.days});
factory ExpiryAssessment.fromJson(Map<String, dynamic>? j) {
if (j == null) return const ExpiryAssessment();
return ExpiryAssessment(
state: _asStr(j["state"]).isEmpty ? "no_expiry" : _asStr(j["state"]),
days: _asIntOrNull(j["daysUntilExpiry"]),
);
}
}
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 | petrol_lpg | diesel | diesel_lpg | hybrid | electric | hydrogen
final String buildDate; // ISO YYYY-MM-DD (date-only)
final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
final int serviceIntervalDays;
final int serviceIntervalKm;
/// The roadworthiness inspection cycle. Only prefills the next date — the
/// interval is set by law rather than by the car, so any check can override it
/// with the date its certificate actually carries.
final int technicalCheckIntervalDays;
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,
this.technicalCheckIntervalDays = 0,
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"]),
technicalCheckIntervalDays: _asInt(j["technicalCheckIntervalDays"]),
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 with HasAttachment {
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;
@override
final String fileName;
@override
final bool hasFile;
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,
this.fileName = "",
this.hasFile = false,
});
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
changedOil: _asBool(j["changedOil"]),
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
notes: _asStr(j["notes"]),
nextServiceDate: _asDate(j["nextServiceDate"]),
nextServiceKm: _asIntOrNull(j["nextServiceKm"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// One mandatory roadworthiness inspection — przegląd techniczny, MOT, TÜV,
/// contrôle technique, depending on where the car is registered. Shaped like a
/// [ServiceRecord] but recurring on time alone: an inspection falls due on a
/// date whatever the odometer says.
class TechnicalCheck with HasAttachment {
final String id;
final String car;
final DateTime? date;
final String result; // passed | failed
final double cost;
final String station;
final String notes;
/// The expiry printed on the certificate. When set it wins over the car's
/// interval, because it is the date that actually governs.
final DateTime? validUntil;
final DateTime? nextCheckDate;
final ExpiryAssessment expiry;
@override
final String fileName;
@override
final bool hasFile;
TechnicalCheck({
required this.id,
required this.car,
required this.date,
required this.result,
required this.cost,
this.station = "",
this.notes = "",
this.validUntil,
this.nextCheckDate,
this.expiry = const ExpiryAssessment(),
this.fileName = "",
this.hasFile = false,
});
factory TechnicalCheck.fromJson(Map<String, dynamic> j) => TechnicalCheck(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
result: _asStr(j["result"]).isEmpty ? "passed" : _asStr(j["result"]),
cost: _asDouble(j["cost"]),
station: _asStr(j["station"]),
notes: _asStr(j["notes"]),
validUntil: _asDate(j["validUntil"]),
nextCheckDate: _asDate(j["nextCheckDate"]),
expiry: ExpiryAssessment.fromJson(
j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
bool get passed => result == "passed";
}
class Part with HasAttachment {
final String id;
final String car;
final String name;
final String partNumber;
final String category;
final String notes;
@override
final String fileName;
@override
final bool hasFile;
Part({
required this.id,
required this.car,
required this.name,
required this.partNumber,
this.category = "",
this.notes = "",
this.fileName = "",
this.hasFile = false,
});
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"]),
notes: _asStr(j["notes"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// One refuelling stop. The efficiency figures are derived by the server using
/// the full-tank method and are null wherever it could not compute them — a
/// window with a missed fill, or the first tank ever logged.
class FuelEntry with HasAttachment {
final String id;
final String car;
final DateTime? date;
final int km;
final double liters;
final double cost;
final bool fullTank;
final bool missedFill;
final String station;
final String notes;
final double? pricePerLiter;
final int? distanceKm;
final double? litersUsed;
final double? consumptionL100;
final double? kmPerLiter;
final double? costPerKm;
@override
final String fileName;
@override
final bool hasFile;
FuelEntry({
required this.id,
required this.car,
required this.date,
required this.km,
required this.liters,
required this.cost,
this.fullTank = true,
this.missedFill = false,
this.station = "",
this.notes = "",
this.pricePerLiter,
this.distanceKm,
this.litersUsed,
this.consumptionL100,
this.kmPerLiter,
this.costPerKm,
this.fileName = "",
this.hasFile = false,
});
factory FuelEntry.fromJson(Map<String, dynamic> j) => FuelEntry(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
liters: _asDouble(j["liters"]),
cost: _asDouble(j["cost"]),
fullTank: _asBool(j["fullTank"]),
missedFill: _asBool(j["missedFill"]),
station: _asStr(j["station"]),
notes: _asStr(j["notes"]),
pricePerLiter: _asDoubleOrNull(j["pricePerLiter"]),
distanceKm: _asIntOrNull(j["distanceKm"]),
litersUsed: _asDoubleOrNull(j["litersUsed"]),
consumptionL100: _asDoubleOrNull(j["consumptionL100"]),
kmPerLiter: _asDoubleOrNull(j["kmPerLiter"]),
costPerKm: _asDoubleOrNull(j["costPerKm"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// A summary of a car's whole refill history. [trackedDistanceKm] is the
/// distance covered by computable full-tank windows — less than the odometer
/// span whenever the history starts or ends on a partial fill. The averages
/// describe exactly this distance.
class FuelStats {
final int entries;
final double totalLiters;
final double totalCost;
final int trackedDistanceKm;
final double? avgConsumptionL100;
final double? bestConsumptionL100;
final double? worstConsumptionL100;
final double? avgKmPerLiter;
final double? avgPricePerLiter;
final double? costPerKm;
final DateTime? firstDate;
final DateTime? lastDate;
const FuelStats({
this.entries = 0,
this.totalLiters = 0,
this.totalCost = 0,
this.trackedDistanceKm = 0,
this.avgConsumptionL100,
this.bestConsumptionL100,
this.worstConsumptionL100,
this.avgKmPerLiter,
this.avgPricePerLiter,
this.costPerKm,
this.firstDate,
this.lastDate,
});
factory FuelStats.fromJson(Map<String, dynamic> j) => FuelStats(
entries: _asInt(j["entries"]),
totalLiters: _asDouble(j["totalLiters"]),
totalCost: _asDouble(j["totalCost"]),
trackedDistanceKm: _asInt(j["trackedDistanceKm"]),
avgConsumptionL100: _asDoubleOrNull(j["avgConsumptionL100"]),
bestConsumptionL100: _asDoubleOrNull(j["bestConsumptionL100"]),
worstConsumptionL100: _asDoubleOrNull(j["worstConsumptionL100"]),
avgKmPerLiter: _asDoubleOrNull(j["avgKmPerLiter"]),
avgPricePerLiter: _asDoubleOrNull(j["avgPricePerLiter"]),
costPerKm: _asDoubleOrNull(j["costPerKm"]),
firstDate: _asDate(j["firstDate"]),
lastDate: _asDate(j["lastDate"]),
);
}
/// One workshop visit or repair — work done outside the routine service
/// schedule (which lives in [ServiceRecord]). A broken alternator replaced at a
/// garage belongs here; the annual oil change does not.
class MaintenanceEntry with HasAttachment {
final String id;
final String car;
final DateTime? date;
final int km;
final String type; // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
final String status; // scheduled|in_progress|completed
final String workshop;
final String location;
final String description;
final String partsUsed;
final double laborCost;
final double partsCost;
final String invoiceNumber;
final DateTime? warrantyUntil;
final String notes;
final double totalCost;
final bool? warrantyActive;
final int? warrantyDaysLeft;
@override
final String fileName;
@override
final bool hasFile;
MaintenanceEntry({
required this.id,
required this.car,
required this.date,
required this.km,
required this.type,
required this.status,
this.workshop = "",
this.location = "",
this.description = "",
this.partsUsed = "",
this.laborCost = 0,
this.partsCost = 0,
this.invoiceNumber = "",
this.warrantyUntil,
this.notes = "",
this.totalCost = 0,
this.warrantyActive,
this.warrantyDaysLeft,
this.fileName = "",
this.hasFile = false,
});
factory MaintenanceEntry.fromJson(Map<String, dynamic> j) => MaintenanceEntry(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
type: _asStr(j["type"]).isEmpty ? "repair" : _asStr(j["type"]),
status: _asStr(j["status"]).isEmpty ? "completed" : _asStr(j["status"]),
workshop: _asStr(j["workshop"]),
location: _asStr(j["location"]),
description: _asStr(j["description"]),
partsUsed: _asStr(j["partsUsed"]),
laborCost: _asDouble(j["laborCost"]),
partsCost: _asDouble(j["partsCost"]),
invoiceNumber: _asStr(j["invoiceNumber"]),
warrantyUntil: _asDate(j["warrantyUntil"]),
notes: _asStr(j["notes"]),
totalCost: _asDouble(j["totalCost"]),
warrantyActive: j["warrantyActive"] == null ? null : _asBool(j["warrantyActive"]),
warrantyDaysLeft: _asIntOrNull(j["warrantyDaysLeft"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// A piece of paperwork tied to a car — insurance, emissions certificate,
/// registration papers. The renewal date is the point of the record: an expired
/// policy is a car that cannot legally be driven, so [expiry] is computed live
/// by the server on every read.
class CarDocument with HasAttachment {
final String id;
final String car;
final String type; // insurance|pollution|registration|inspection|roadTax|warranty|other
final String title;
final String provider;
final String reference;
final DateTime? issueDate;
final DateTime? expiryDate; // blank = never expires
final double cost;
final String notes;
final ExpiryAssessment expiry;
@override
final String fileName;
@override
final bool hasFile;
CarDocument({
required this.id,
required this.car,
required this.type,
required this.title,
this.provider = "",
this.reference = "",
this.issueDate,
this.expiryDate,
this.cost = 0,
this.notes = "",
this.expiry = const ExpiryAssessment(),
this.fileName = "",
this.hasFile = false,
});
factory CarDocument.fromJson(Map<String, dynamic> j) => CarDocument(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
title: _asStr(j["title"]),
provider: _asStr(j["provider"]),
reference: _asStr(j["reference"]),
issueDate: _asDate(j["issueDate"]),
expiryDate: _asDate(j["expiryDate"]),
cost: _asDouble(j["cost"]),
notes: _asStr(j["notes"]),
expiry: ExpiryAssessment.fromJson(
j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// Something the user wants to be told about: a booked workshop slot, an
/// insurance renewal, a tyre swap. Fires on a date, an odometer reading, or
/// both — whichever comes first. [auto] marks a reminder the server derived from
/// a document or service record, which is read-only.
class Reminder {
final String id;
final String car;
final String title;
final String type; // maintenance|document|service|inspection|other
final DateTime? dueDate;
final int dueKm;
final int repeatDays;
final int repeatKm;
final bool done;
final DateTime? doneAt;
final String notes;
final String status; // done | overdue | due_soon | upcoming | no_trigger
final int? daysLeft;
final int? kmLeft;
final bool auto;
final String sourceRef;
Reminder({
required this.id,
required this.car,
required this.title,
required this.type,
this.dueDate,
this.dueKm = 0,
this.repeatDays = 0,
this.repeatKm = 0,
this.done = false,
this.doneAt,
this.notes = "",
this.status = "no_trigger",
this.daysLeft,
this.kmLeft,
this.auto = false,
this.sourceRef = "",
});
factory Reminder.fromJson(Map<String, dynamic> j) => Reminder(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
title: _asStr(j["title"]),
type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
dueDate: _asDate(j["dueDate"]),
dueKm: _asInt(j["dueKm"]),
repeatDays: _asInt(j["repeatDays"]),
repeatKm: _asInt(j["repeatKm"]),
done: _asBool(j["done"]),
doneAt: _asDate(j["doneAt"]),
notes: _asStr(j["notes"]),
status: _asStr(j["status"]).isEmpty ? "no_trigger" : _asStr(j["status"]),
daysLeft: _asIntOrNull(j["daysLeft"]),
kmLeft: _asIntOrNull(j["kmLeft"]),
auto: _asBool(j["auto"]),
sourceRef: _asStr(j["sourceRef"]),
);
/// Recurring reminders roll their trigger forward on completion instead of
/// closing out.
bool get repeats => repeatDays > 0 || repeatKm > 0;
}
/// 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";
}
/// A tenant users belong to, as returned by the organization endpoints.
class Organization {
final String id;
final String name;
final String created;
Organization({required this.id, required this.name, this.created = ""});
factory Organization.fromJson(Map<String, dynamic> j) => Organization(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
created: _asStr(j["created"]),
);
}
/// 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; // BCP-47 language-REGION, e.g. "en-US"
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
final String currency; // ISO 4217 code, e.g. "EUR"
final String fontSize; // small | medium | large
final String role; // user | admin
final String organization; // org record id ("" = belongs to no organization)
final String organizationName; // resolved name ("" when unset/unresolvable)
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,
this.currency = "USD",
required this.fontSize,
required this.role,
this.organization = "",
this.organizationName = "",
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"]),
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
organization: _asStr(j["organization"]),
organizationName: _asStr(j["organizationName"]),
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.
// --- Integrations (Toyota / Anker Solix) -----------------------------------
//
// The server resolves a superadmin → org admin → user cascade and returns, per
// field, the effective value (secrets/inherited values masked), the caller's
// own-layer value, its source layer, and whether it's locked (set above us).
// These mirror the resolved shapes the web Settings.vue consumes.
/// One field within a scope: the resolved value plus provenance.
class IntegrationField {
final String effective; // resolved value (secrets masked to "")
final String own; // the caller's own-layer value (unmasked, editable)
final String source; // "global" | "org" | "user" | "unset"
final bool locked; // set above the caller's editable layer
const IntegrationField({
this.effective = "",
this.own = "",
this.source = "unset",
this.locked = false,
});
factory IntegrationField.fromJson(Map<String, dynamic> j) => IntegrationField(
effective: _asStr(j["effective"]),
own: _asStr(j["own"]),
source: _asStr(j["source"]).isEmpty ? "unset" : _asStr(j["source"]),
locked: _asBool(j["locked"]),
);
}
/// A cascade scope ("user" or "org") the caller may edit, with its fields.
class IntegrationScope {
final String editableLayer; // "user" | "org"
final Map<String, IntegrationField> fields;
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
final raw = j["fields"];
final fields = <String, IntegrationField>{};
if (raw is Map) {
raw.forEach((k, v) {
if (v is Map) fields[k.toString()] = IntegrationField.fromJson(Map<String, dynamic>.from(v));
});
}
return IntegrationScope(
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
fields: fields,
);
}
IntegrationField field(String key) => fields[key] ?? const IntegrationField();
}
/// The resolved view of one integration for the signed-in user, from
/// GET /integrations/{toyota|anker-solix}.
class IntegrationView {
final bool available; // the integration is enabled at the master layer
final bool enabled; // the user's personal opt-in
final bool orgEnabled; // the organization gate
final String orgId; // "" when the user has no organization
final bool canEditOrg; // the caller is an org admin
final bool isSuperadmin; // manages the shared layer elsewhere (read-only here)
final String controlMode; // Anker only: effective OCPP mode (off|own|proxy)
final Map<String, IntegrationScope> scopes; // "user" and optionally "org"
const IntegrationView({
this.available = false,
this.enabled = false,
this.orgEnabled = false,
this.orgId = "",
this.canEditOrg = false,
this.isSuperadmin = false,
this.controlMode = "off",
this.scopes = const {},
});
factory IntegrationView.fromJson(Map<String, dynamic> j) {
final raw = j["scopes"];
final scopes = <String, IntegrationScope>{};
if (raw is Map) {
raw.forEach((k, v) {
if (v is Map) scopes[k.toString()] = IntegrationScope.fromJson(Map<String, dynamic>.from(v));
});
}
return IntegrationView(
available: _asBool(j["available"]),
enabled: _asBool(j["enabled"]),
orgEnabled: _asBool(j["orgEnabled"]),
orgId: _asStr(j["orgId"]),
canEditOrg: _asBool(j["canEditOrg"]),
isSuperadmin: _asBool(j["isSuperadmin"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
scopes: scopes,
);
}
IntegrationScope scope(String key) => scopes[key] ?? const IntegrationScope();
}
/// The result of a live connection probe (POST …/health).
class IntegrationHealth {
final String status; // "ok" | "error" | …
final String detail;
const IntegrationHealth({this.status = "", this.detail = ""});
factory IntegrationHealth.fromJson(Map<String, dynamic> j) => IntegrationHealth(
status: _asStr(j["status"]),
detail: _asStr(j["detail"]),
);
}
/// A charger's OCPP control view, from …/chargers/{sn}/control. Serves both the
/// Settings provisioning card (endpoint/token/connection) and the Charging home
/// tab's live control (connector status + meter).
class AnkerControl {
final String endpoint; // OCPP backend URL to point the charger at
final bool hasToken; // a per-charger token has been generated
final String tokenHint; // last chars of the token, for display
final bool connected; // the charger is connected to the control backend
final String controlMode; // off | own | proxy
final String connectorStatus; // OCPP connector status, e.g. "Charging"
final int meterWh; // last meter reading in watt-hours
const AnkerControl({
this.endpoint = "",
this.hasToken = false,
this.tokenHint = "",
this.connected = false,
this.controlMode = "off",
this.connectorStatus = "",
this.meterWh = 0,
});
factory AnkerControl.fromJson(Map<String, dynamic> j) {
final status = j["status"];
final s = status is Map ? Map<String, dynamic>.from(status) : const {};
return AnkerControl(
endpoint: _asStr(j["endpoint"]),
hasToken: _asBool(j["hasToken"]),
tokenHint: _asStr(j["tokenHint"]),
connected: _asBool(j["connected"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
connectorStatus: _asStr(s["connectorStatus"]),
meterWh: _asInt(s["meterWh"]),
);
}
double get meterKwh => meterWh / 1000.0;
}