Commit Graph
24 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 c1b76a801d An inherited field shows what it inherited, not an example
Country read "DE" under the words "inherited from your organization",
while the organization it was inheriting from said DK. The DE was never
a value at all — it was the example placeholder, left in place when the
field locked, and an example in that position is not a hint. It is a
wrong answer to the question the box is being asked: which country am I
inheriting?

The server had already settled what may be shown. It sends the secrets
back as dots and everything else in the clear, country included, and only
the panel was throwing that away. So a locked field now placeholders its
effective value, and the example is kept for the case it was written for:
an empty box waiting to be filled in.

Applied to the non-secret cascading fields rather than to the one that
was noticed — Green Cell's port, serial, timeout and command topic had
the same example hardcoded a card further down, and would have told the
same lie the moment an org set them.

The dots are left alone. They are the panel's own masking, and rerouting
them through the server's effective value would be the same result by a
different path — not worth changing how a secret is displayed as a side
effect of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:51:57 +02:00
tajniak81andClaude Opus 5 dde5410788 The clock stops being a side effect of the region
Whether a time read as 13.45 or 01.45 pm was decided by the region
picker, which also sets the decimal separator and the currency layout —
so a Dane who wanted a 12-hour clock had to move their numbers to get
one. Time format is its own setting now, beside the date format it is
the other half of.

It defaults to "auto", the region's own convention, which is what every
timestamp in the app already said: nothing moves until somebody picks
something. The 24-hour setting asks for hourCycle h23 rather than
hour12:false, because with hour12 the en-US formatter prints midnight as
24:00.

One helper, so it reaches everywhere at once: formatDateTime now calls
formatTime, and every clock the app draws goes through it — the
charger's telemetry and settings, a session's start, when a charger was
linked, the provider panel's own timestamp.

The users collection gains a time_format select in both places the
schema is declared; it is in reconcileOrder, so a restart adds the field
and nothing has to be migrated by hand.

The native time inputs in the charger's settings card are left alone:
the browser renders those in the OS convention whatever this says, and a
text box that respected the setting would be the worse control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:32:52 +02:00
tajniak81andClaude Opus 5 576df58776 Go the way the owner's phone already goes
Control had two transports and neither fitted the ordinary customer. OCPP waits
for the charger to dial in, which needs a public endpoint it can reach, a
certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the
charger, which needs the server on the charger's own network. Between them they
cover a charger we host and a charger we stand next to; the common case is a
charger behind someone else's router, and that had nothing.

It was never unreachable, though. The charger holds a connection open to Anker's
own broker — it is how the mobile app drives it from anywhere, and it is the
mqttStatus register the Modbus snapshot has been reporting all along. So a third
control mode joins that broker as the account: get_user_mqtt_info issues a client
certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same
topics the app publishes on. Nothing on the customer's side has to be forwarded,
addressed or certificated.

What travels is not an API call. The payload is a JSON envelope around a base64
binary frame the device itself speaks — marker, little-endian length, message
type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec
rather than a client, written from the message maps in anker-solix-api and
anchored on the one frame that project documents byte for byte. A frame whose
fields do not tile exactly up to the checksum is refused rather than half-read:
these arrive over a link we do not control, and a truncated frame must not read
as a charger reporting zeros.

Two of the charger's habits shape the rest. It publishes nothing unless asked, so
a status read arms a telemetry trigger and waits for the next frame, and a poll
inside that window answers from what has since arrived. And a broker connection
costs a fetched certificate and a TLS handshake while the plugin manager builds a
throwaway instance per request — so the connection lives on the account's shared
session beside the auth token, for exactly the reason the token lives there, and
closes itself after five idle minutes.

The transport also sees two signals no other one does: the boost flag, and the
plug and start countdowns. The package doc has said since the first commit that
they are never set and the derived mode must do without them. Here they are set,
so a charger that has been told to start and is counting down a delay says so
rather than sitting in "preparing", and "skip the delay" is offered only while
there is a delay to skip.

The clients generalise instead of growing a second layout. Both snapshots name
the same quantities the same way, so what was Modbus-only in the readouts is now
whichever transport read the charger — ModbusStatus becomes ChargerStatus on the
phone, mb becomes dev on the web. What each transport can be *told* still
differs, and the buttons branch on that: reset and clear-limit stay with OCPP,
the timeout and phase registers with Modbus, skip-delay with the cloud. A command
a transport has no equivalent for is refused by name, saying which one has it.

The cost is worth saying plainly. This leans on Anker's cloud being up and on an
unofficial protocol the app may change under us, where Modbus leans on nothing
but the LAN. And it is checked against the reference implementation's own worked
example rather than against hardware — there is no charger on this end to point
it at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:47:10 +02:00
tajniak81andClaude Opus 5 f3235c403c A page opens where you put its tabs
Every tab bar in the app drags into the order you want, and then all three of
them opened on a tab picked in the source anyway: "public" on Charging, "info"
on a car, "personal" in Settings. Dragging Home chargers to the front of the
charging bar rearranged the bar and changed nothing about where the page landed,
which is the opposite of what dragging it there says.

So the front of the bar is now the landing tab, everywhere. An arrangement is
already the statement of what you want to see first; it just wasn't being read
as one. Settings > Appearance overrides it per page for the case where reading
order and landing tab are two different wishes, with "First in the bar" as the
default and the meaning of no override at all.

The rule lives in one place, lib/tabs.js, because it is one rule and three
pages: the saved choice if that tab is actually on the bar, otherwise whatever
leads it. The bar it is given is the one that will really render, hidden tabs
and inapplicable ones already dropped, so a default that no longer has a button
- a tab switched off for that car, Users on a non-admin - falls back to the
front instead of opening nothing. The tab key lists moved there too, since the
picker needs all three and would otherwise have copied them.

Each page starts on no tab and keeps following the profile until the user says
otherwise, rather than guessing and then correcting itself: the arrangement and
the default both arrive with /api/me, which on a hard refresh lands after the
view has mounted. A click ends the following, and so does the start of a drag -
rearranging a bar must not pull the content out from under the pointer. In
Settings ?tab= still wins over both, since that is what /admin redirects to.

Stored as defaultTabs on the profile, one page->tab map validated per page: a
tab that exists but on another page is an error, and an empty value is stored
as an absent key so "no default" has a single representation.

Also adds charger_tab_order and charger_card_order to the PocketBase setup
script. They were never there - the arrangements of the last two commits had no
column to persist into on a freshly set-up server - and default_tabs would have
gone the same way beside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:52:20 +02:00
tajniak81andClaude Opus 5 f025dc100f The local mode, offered by the menu that picks the mode
Modbus TCP has been a working control path since the server learned to dial
the charger, and the Charging page has asked for the address it needs — but
the Control mode selector never listed it. The mode could be reached only by
writing it through the API, which is to say not at all.

Both selectors now offer it, and both stop offering what it does not use: the
OCPP provisioning card and its "Use this one" button are gated on the modes
where the charger dials us, and Modbus gets a line saying where its address is
asked for instead. On the phone that gate is more than tidiness — the field is
a DropdownButtonFormField, so a mode saved from the web left it holding a value
none of its items matched.

Strings added in en/da/pl for both apps. The mode hint no longer says control
happens "over OCPP", because it no longer always does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:26:21 +02:00
tajniak81andClaude Opus 5 c9ffb9c698 One prompt for the whole app, asked where the browser cannot refuse it
Fifteen destructive actions were still gated behind window.confirm(), the
same call that made removing a home charger look broken: a browser that
suppresses native dialogs never shows it and returns false, so deleting a
service, a part, a user or an organization would quietly not happen and say
nothing about why.

askConfirm() puts the question in the page and resolves to what was
actually pressed, so each call site changed by one line and reads the way
it did before. ConfirmDialog is mounted once in App.vue and draws over
everything, including a modal — removing a server is asked from inside one,
which is what Modal's new zIndex is for.

The home charger keeps its own inline prompt: that one belongs to its row
rather than to the middle of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:02:38 +02:00
tajniak81andClaude Opus 5 ad785ee9f8 The fields the cloud sends, kept all the way to the card
Normalizing a provider's charger list threw most of the answer away:
firmware, the site id, how the charger is registered, the charge power and
the cloud's own OCPP reading all arrived from Anker and none of them got
past providerCharger, which carried seven fields and dropped the rest. The
information card could not show what it was never handed.

It carries them now, and the card lays them out: firmware beside the model,
site and site id where the charger lives, "Registered as" for standalone /
in a system / bound, and — when the service knows — state, charge power and
OCPP status. Charge power is relayed exactly as worded upstream, since the
unit is theirs and putting one on it here would be inventing it. Settings'
own list gains the firmware in its subtitle. A field no view supplied still
leaves no row, so an account whose chargers stand outside a system reads
shorter rather than emptier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:43:23 +02:00
tajniak81andClaude Opus 5 86ea97b414 Online said out loud, not left as the absence of "Offline"
Settings listed a charger's reachability with one badge, drawn only when
the cloud said online:false. A reachable charger got nothing — the same
nothing a charger the cloud declined to report on gets — so the account's
two chargers read as one offline and one unremarkable. Online now has a
badge of its own, and the silent case stays silent: not saying is not the
same as saying no.

The Charging page said nothing at all, having only the record, which knows
what a charger is and not whether it is answering. So it asks: each
connected service's charger list is read into a live map keyed by the
provider's own id, and the information card carries the badge and, when
the service reports one, the operating state. Asking costs a round trip
per service, so it happens when the home tab is first opened — the moment
the question is being asked — and after that only when the user presses
Refresh or imports a charger. A service that will not answer leaves its
chargers unbadged rather than offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:22:02 +02:00
tajniak81andClaude Opus 5 06f91578da Integrations, sorted into the categories the panel already sorts them into
The API Server panel has grouped plugins by category since the plugin list was
drawn — car manufacturers, EV chargers, notifications, each tab carrying its
count. The Settings page had the same integrations in one flat stack, so the two
screens described the same set of things in two different shapes. Now they agree:
the same categories, the same counted tabs, the same accent border on the active
one. The category ids come from the Category* constants both sides already read,
so nothing here invents a third vocabulary.

Only a category holding something gets a tab, which is the panel's rule too: two
tabs today rather than six empty ones. Below one category the bar hides itself
entirely — with nothing to switch between, a single tab is a label pretending to
be a control.

The active group falls back to the first rather than to a fixed id, because the
three integration views load independently and a tab can be momentarily empty on
first paint; falling back keeps the section populated instead of blanking it.
The cards moved into a space-y-4 wrapper and lost their per-card mt-4, so the
first card sits the same distance under the bar whichever tab is showing —
previously Toyota was the only one that could ever be first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:20:08 +02:00
tajniak81andClaude Opus 5 a809980d8b Anker health: count the chargers the panel lists, not the ones one endpoint admits to
The probe still asked get_user_bind_and_not_in_station_evchargers and read its
userBindEvChargersCount, so it reported "0 EV charger(s) bound to account" for an
account whose two chargers the panel was listing directly underneath — the same
blind spot the capability was just moved off, left behind in the health check. It
now takes the same inventory the chargers capability returns and counts that.

Authenticated with nothing on the account is degraded rather than ok, following
Greencell's rule: the half we address answers, and the empty half is the account
or the country that picks the regional server, so the message says so instead of
reporting a healthy connection to nothing. A count reached with some view missing
says how many views stayed silent, because the number is then a floor rather than
a total. The web panel colours degraded amber, as it already did for Greencell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:46:02 +02:00
tajniak81andClaude Opus 5 e138fad3f4 Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:36 +02:00
tajniak81andClaude Opus 5 340a81b0d6 Greencell: the charger on your own broker, not a cloud it never had
The HabuDen has no cloud API to connect to. It is commissioned over Bluetooth in
the Greencell GC app, pointed at an MQTT broker the owner runs, and from then on
publishes there — so the connector is an MQTT client rather than an HTTP one,
and nothing in it reaches Greencell. The wire contract is Home Assistant's own
greencell component and the greencell_client 1.0.3 library beneath it, which is
the only published description of the topics: a BROADCAST on /greencell/broadcast
draws device announcements, and /greencell/evse/{sn}/ carries current in
milliamps, voltage, power under "momentary", the EVSE state, and the access level
chosen in the app.

That meant an MQTT client, and the server takes no dependencies, so internal/mqtt
is hand-rolled the way internal/ocpp's RFC 6455 layer is. It is scoped to what
this connector needs and says so: QoS 0 for everything we send, clean session,
no reconnect — a connection lives for one plugin call, which is exactly how the
manager builds and tears down an instance. Inbound PUBLISH is accepted at QoS 0,
1 and 2 with the acknowledgements each requires, because the QoS of a delivery is
the broker's choice and not ours; an unacknowledged QoS 1 is redelivered forever.

Read-only, and the reason is worth writing down rather than rediscovering. A
device in EXECUTE mode accepts START, STOP, SET_CURRENT and QUERY — but the topic
those go to appears in no source: not Greencell's integration page, not
greencell_client, and Home Assistant ships sensor-only for that same reason.
Publishing to a guessed topic would be a control feature whose failure mode is a
driver believing they stopped a charge. So the access level is reported, and
commandTopic is the seam: an operator who has watched their own broker and found
theirs sets it, and a state read then sends QUERY — the one command a READ-mode
device also honours — instead of waiting out the charger's publish cadence. The
day the topic is public, control is a payload away from the same field.

What the cascade resolves here is a broker, not an account, so host, port, TLS and
credentials resolve together from the highest layer that names a host: an
organization's address paired with a user's password would address a broker with
credentials never meant for it. The serial, the QUERY topic and the listen window
each describe the charger rather than the endpoint, so each resolves on its own.

Two reading rules the tests pin. A phase the device did not report stays nil
rather than zero, because zero amps on a charger is a real measurement — a JSON
null decoding to 0.0 was a live bug until a test caught it — and a partial read
returns with received/complete flags instead of failing, since a device that
publishes some topics on a slower cadence is still worth reading. And a reachable
broker with no charger on it is degraded, not down: the half we configure works
and the missing half is the device. The plugin's end-to-end tests run against an
in-process broker written to the raw wire format, so a bug in the client cannot
hide behind a matching bug in the fixture.

The apps get the third connector card. The panel needed nothing — it renders a
plugin's ConfigFields itself — but the per-user panes are still hand-written per
integration, which is now three near-copies and the argument for the generic
version already noted in the plugins README. The web form splits the broker from
the charger because the server resolves them differently. The phone card is a
declarative config against the shared widget, which gained a number field type, a
degraded state that reads amber rather than red, and a fix for a locked field
that was covering its own displayed value with dots. Twenty keys in three
languages across both apps; Greencell, HabuDen and the literal QUERY join the
proper nouns that stay in English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:44 +02:00
tajniak81andClaude Opus 5 e373497958 Settings: fold Users and Organization into the Settings tabs
The left rail is back to the three places you actually go — Garage,
Charging, Settings — and user management moves inside Settings as an
admin-only tab, next to a new Organization tab that used to be a card
buried in the personal settings. Tab order is Personal settings, Users,
Organization, Integrations. /admin redirects to /settings?tab=users so
old links keep working, and ?tab= picks the starting tab in general.

AdminUsers moves from views/ to components/ since it is a panel now, not
a route, and its page header becomes a section header like its
neighbours. The personal panel was split in two around the integrations
markup, which left no gap between the Profile and Privacy cards; it is
one block again.

Creating a user gets an organization picker for superadmins, defaulting
to "no organization" so an org-less account stays a deliberate choice.
Admins see no picker: the server pins their members to their own org
regardless, which users_test.go now covers along with both superadmin
paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:38:54 +02:00
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
tajniak81andClaude Opus 4.8 f9b1bcc520 Web App: fold integration cards; split Charging into two tabs
Settings → Integrations: the Toyota and Anker Solix cards are now
collapsible, like the plugin rows in the API Server panel. Each header
is a toggle with a rotating chevron; the Connected badge stays visible
when collapsed. Bodies use v-show so loaded state and in-flight edits
survive folding. Collapsed by default.

Charging: split into "Public chargers" and "Home chargers" tabs. Public
keeps the discovery map (public pins only) + demo session + nearby
public stations. Home holds the real OCPP control card (moved here) and
the user's home charger list, with a hint pointing to Settings when no
control mode is active. Adds charging.tabs.*, stations.homeHeading and
stations.noControlHint (en/pl/da).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:22:38 +02:00
tajniak81andClaude Opus 4.8 8656aaaee3 Split Web App Settings into tabs; allow unset plugin Control mode
Web App: Settings is now two tabs — Personal settings (account,
appearance, profile, privacy, advanced/danger) and Integrations
(Toyota, Anker Solix). Panels use v-show so loaded state and in-flight
edits survive a tab switch. Adds settings.tabs.* labels (en/pl/da).

API Server panel: non-required select config fields now render a
leading "Not set" option, so a superadmin can leave the Anker Solix
Control mode (and Toyota Brand) unset at the global layer. Previously
every option was a real value, forcing a pick that won the cascade and
locked organizations and users out of choosing their own mode. The
server side already treated an empty value as "abstain"; this closes
the UI gap. Adds plugins.notSet label (en/pl/da) and rebuilds the
embedded panel dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:02:37 +02:00
tajniak81andClaude Opus 4.8 19a7d48feb Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)
Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:03:05 +02:00
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:40:22 +02:00
tajniak81andClaude Opus 4.8 52d9614bad Wire the Anker Solix EV charger into the per-user integration cascade
Expose the anker-solix plugin to end users the same way as Toyota: a
global -> org -> user settings cascade so everyone can run it under their
own Anker account, with a superadmin (and org admin) able to impose
settings from above.

API Server: integrations_ankersolix.go mirrors integrations.go with the
Anker fields (email + password resolve as a pair from the highest layer,
country resolves on its own), plus GET/PUT/health/chargers routes under
/api/integrations/anker-solix. Secrets and inherited emails are masked;
the live probe runs server-side under the resolved credentials.

Web App: api.js client calls, a second Integrations card in Settings.vue
(scope switch, enable toggle, email/password/country with locked-field
inheritance notes, save + test), and the en.json strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:46:16 +02:00
tajniak81andClaude Opus 4.8 5e435c5f77 Add per-user Toyota integration with a settings cascade
Let each user run the Toyota Connected plugin under their own MyToyota
credentials and enable/disable it for themselves in the Web App, while a
superadmin (and, in an organization, an org admin) can impose settings
from above. Resolution is a cascade — top wins, and a lower level only
fills fields the levels above left blank:

  - org user:      API Server (superadmin) -> org admin -> user
  - org-less user: API Server (superadmin) -> user

The MyToyota email + password resolve together as a pair from the highest
layer that supplies an email; brand resolves on its own; enablement is
strictly per-user, gated by the global master switch and the org gate.

API Server:
  - plugins.Manager gains RawConfig / HealthCheckWith / InvokeWith so the
    cascade can read global config and probe/invoke under a per-caller
    resolved config.
  - internal/api/integrations.go resolves the cascade and serves
    GET/PUT /api/integrations/toyota, POST .../health, GET .../vehicles.
    Secrets and inherited usernames are masked before leaving the server.
  - The toyota builtin's credentials are no longer required at the global
    layer, so the master switch can be enabled without global credentials.
  - setup-pocketbase.mjs adds a pluginSettings JSON field to the users and
    organizations collections (the user and org layers of the cascade).

Web App:
  - api.js gains getToyota/saveToyota/testToyota.
  - Settings grows an Integrations section: an enable toggle, credential
    fields with locked / "inherited from" states, a brand select, an
    org-scope switch for admins, and a live test-connection button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:08:58 +02:00
tajniak81andClaude Opus 4.8 b6bb6b1df0 Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:07:48 +02:00
tajniak81andClaude Opus 4.8 03738f08dc Add currency setting and split locale into language + region
Costs rendered as bare numbers because the project had no currency to
render them with. Add one to the profile, beside the existing appearance
preferences:

- users.currency in PocketBase, exposed via /api/me, validated against
  the same 28 codes in the schema, the API and the web app.
- Settings offers the European currencies plus the non-European ones the
  panel already had. Labels come from Intl.DisplayNames rather than a
  hand-kept table, so the lists read in the user's own language and sort
  by what is actually on screen.

The single "Language & region" picker becomes two controls over the one
stored BCP-47 tag, covering European languages and countries, so the two
can be mixed (English in Poland). The API now enforces the
language-REGION shape: the web app feeds the tag straight to Intl, which
throws on a malformed one rather than falling back.

formatKm and the km-count labels passed no locale, so kilometres
followed the browser while the dates and costs beside them followed the
saved preference. They now share one helper that uses the preference.

Rename the "Maintenance log" tab to "Maintenance", the name the
reminder-type list already used.

Amounts are display-only: nothing is converted, so changing currency
reinterprets existing records rather than recalculating them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:32:40 +02:00
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
tajniak81andClaude Opus 4.8 75a2ccc226 Restructure Web App into server/ + web/ (GsmNode parity)
Reorganize the Web App to match the GsmNode project layout: a Go
backend-for-frontend in server/ that embeds the built SPA and reverse-proxies
/api/* to the API Server, with the Vue 3 + Vite frontend moved into web/.

- Move all frontend files into web/ (history preserved via renames)
- Point vite build output at ../server/dist for Go embedding
- Add server/ Go BFF (main.go, go.mod, .env.example, Run-WebApp.ps1)
- Drop Docker/nginx deploy (Dockerfile, docker-compose.yml, nginx.conf.template,
  .dockerignore) in favor of the BFF, matching GsmNode
- Update .claude/launch.json to run the dev server from web/
- Rewrite README.md for the new layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:34:02 +02:00