Files
DriverVault/API Server/internal/api/integrations_ankersolix.go
T
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

478 lines
17 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// This file mirrors integrations.go for the Anker Solix V1 Smart EV Charger
// plugin: the same three-layer cascade (global → org → user) lets everyone run
// the integration under their own Anker account while a superadmin (and, in an
// organization, an org admin) can impose settings from above. See integrations.go
// for the full rationale; only the fields differ.
//
// - global (L1): the plugin's config in plugins.json, set in the API Server
// panel by a superadmin — the top of the cascade for everyone.
// - org (L2): pluginSettings.ankerSolix on the caller's organization record.
// - user (L3): pluginSettings.ankerSolix on the caller's own user record.
//
// The Anker email + password resolve together as a *pair* from the highest layer
// that supplies an email, so credential halves are never mixed across layers. The
// country resolves on its own. Enablement is strictly per-user (L3), gated by the
// global master switch and, for org users, the org gate.
const (
ankerPlugin = "anker-solix"
ankerSecretMask = "••••••••"
)
// ankerConfig is one layer's Anker Solix settings.
type ankerConfig struct {
Email string `json:"email"`
Password string `json:"password"`
Country string `json:"country"`
}
// ankerStored is what we persist per user/org under pluginSettings.ankerSolix.
type ankerStored struct {
Config ankerConfig `json:"config"`
// Enabled is the personal per-user opt-in (user layer). Default false.
Enabled bool `json:"enabled"`
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled. Only meaningful on the org record; ignored on user records.
Disabled bool `json:"disabled,omitempty"`
}
// ankerSettingsDoc is the pluginSettings JSON shape for the ankerSolix key.
type ankerSettingsDoc struct {
AnkerSolix ankerStored `json:"ankerSolix"`
}
// ankerFieldView is one field's resolved state for the UI.
type ankerFieldView struct {
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
Source string `json:"source"` // global | org | user | unset
Locked bool `json:"locked"` // set above the caller's editable layer
}
// ankerResolution is the fully-resolved Anker Solix state for one caller.
type ankerResolution struct {
eff ankerConfig // effective (unmasked) — used only server-side (probes)
userOwn ankerConfig // caller's personal (L3) values (unmasked)
orgOwn ankerConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch (plugin enabled in the panel)
orgEnabled bool // org gate (default true; gates the org's users)
enabled bool // caller's personal enable flag
}
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// resolveAnker computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (read from their user record).
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"]}
var oStored ankerStored
if who.OrgID != "" {
oStored, _ = s.orgAnker(ctx, who.OrgID)
}
oc := oStored.Config
var uStored ankerStored
if len(userRaw) > 0 {
var d ankerSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.AnkerSolix
}
uc := uStored.Config
res := ankerResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own
// personal layer. Requires the service account (org writes go through it).
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.pb.Configured(),
available: masterEnabled,
orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
enabled: uStored.Enabled,
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
c ankerConfig
}
layers := []layer{{"global", gc}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oc})
}
layers = append(layers, layer{"user", uc})
// Country resolves independently: the highest layer that sets it wins.
res.source["country"] = "unset"
for _, l := range layers {
if v := strings.TrimSpace(l.c.Country); v != "" {
res.eff.Country, res.source["country"] = v, l.name
break
}
}
// Credentials resolve as a pair from the highest layer with an email, so the
// email and password never come from different layers.
credSrc := "unset"
for _, l := range layers {
if e := strings.TrimSpace(l.c.Email); e != "" {
res.eff.Email, res.eff.Password, credSrc = e, l.c.Password, l.name
break
}
}
res.source["email"] = credSrc
res.source["password"] = credSrc
return res
}
// ankerLockedFor reports whether a field whose value comes from source is locked
// for a caller whose editable layer is editable (i.e. the value is set above them).
func ankerLockedFor(source, editable string) bool {
if editable == "none" {
return true // superadmin edits the global layer in the panel, not here
}
sr, ok := ankerLayerRank[source]
if !ok {
return false // unset — the caller may be the first to set it
}
return sr < ankerLayerRank[editable]
}
// ankerMaskPresent returns the secret mask when v is non-empty, else "".
func ankerMaskPresent(v string) string {
if strings.TrimSpace(v) != "" {
return ankerSecretMask
}
return ""
}
// orgAnker reads an organization's stored Anker Solix settings (config + the org
// gate) and its raw pluginSettings blob via the service account. Best effort:
// zero values on any miss so callers proceed as if the org layer were empty.
func (s *Server) orgAnker(ctx context.Context, orgID string) (ankerStored, json.RawMessage) {
if orgID == "" || !s.pb.Configured() {
return ankerStored{}, nil
}
data, status, err := s.pb.Raw(ctx, http.MethodGet,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return ankerStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc ankerSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.AnkerSolix, rec.PluginSettings
}
// mergeAnker applies a mutation to the ankerSolix entry of a pluginSettings blob,
// preserving any other plugin keys, and returns the new blob.
func mergeAnker(existing json.RawMessage, apply func(*ankerStored)) json.RawMessage {
doc := map[string]json.RawMessage{}
if len(existing) > 0 {
_ = json.Unmarshal(existing, &doc)
}
if doc == nil {
doc = map[string]json.RawMessage{} // existing was JSON null
}
var as ankerStored
if raw, ok := doc["ankerSolix"]; ok {
_ = json.Unmarshal(raw, &as)
}
apply(&as)
b, _ := json.Marshal(as)
doc["ankerSolix"] = b
out, _ := json.Marshal(doc)
return out
}
// ankerScopeView builds the masked field set for one editable scope. editable is
// the layer the caller edits in this scope ("user" | "org" | "none"); a field is
// locked when its effective value is set above that layer.
func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string]any {
own := res.userOwn
if editable == "org" {
own = res.orgOwn
}
field := func(key, eff, ownv string, secret bool) ankerFieldView {
src := res.source[key]
locked := ankerLockedFor(src, editable)
fv := ankerFieldView{Source: src, Locked: locked}
switch {
case secret:
// Never expose a secret; show only presence.
fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv)
case key == "email" && locked:
// Inherited email — hide the concrete value from a lower layer.
fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv)
default:
fv.Effective, fv.Own = eff, ownv
}
return fv
}
return map[string]any{
"editableLayer": editable,
"fields": map[string]ankerFieldView{
"email": field("email", res.eff.Email, own.Email, false),
"password": field("password", res.eff.Password, own.Password, true),
"country": field("country", res.eff.Country, own.Country, false),
},
}
}
// ankerView builds the masked, client-safe response body from a resolution.
func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]any {
out := map[string]any{
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
"isSuperadmin": res.isSuper,
}
if res.isSuper {
// Superadmin manages the global layer in the panel; here it is read-only.
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.ankerScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.ankerScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.ankerScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// GET /api/integrations/anker-solix — resolved Anker Solix view for the caller.
func (s *Server) handleGetAnker(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
writeJSON(w, http.StatusOK, s.ankerView(who, res))
}
// PUT /api/integrations/anker-solix — save the caller's editable layer. Body:
// {enabled?: bool, scope?: "user"|"org", config?: {email, password, country}}.
// Fields locked above the caller are ignored; a password left at the mask is
// preserved.
func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
return
}
var body struct {
Enabled *bool `json:"enabled"`
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
// Resolve which layer this write targets. Everyone edits their own personal
// (user) layer by default; an org admin may target the organization layer by
// asking for scope "org". Superadmins are read-only here (they manage global
// in the panel) and may only toggle their personal enable flag.
editable := "user"
switch {
case res.isSuper:
editable = "none"
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
if !res.canOrg {
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
return
}
editable = "org"
}
// Build the new target-layer config from its current own values, overlaying
// only fields the caller is allowed to change in this scope.
newOwn := res.userOwn
if editable == "org" {
newOwn = res.orgOwn
}
applyField := func(key string, set func(*ankerConfig, string)) {
v, ok := body.Config[key]
if !ok || ankerLockedFor(res.source[key], editable) {
return
}
if key == "password" && v == ankerSecretMask {
return // keep current secret
}
set(&newOwn, strings.TrimSpace(v))
}
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
// Persist the organization layer (admins) via the service account.
if editable == "org" {
_, orgRaw := s.orgAnker(r.Context(), who.OrgID)
newDoc := mergeAnker(orgRaw, func(as *ankerStored) {
as.Config = newOwn
// In the org scope the enable flag is the org master switch, stored
// inverted (disabled) so absent means enabled.
if body.Enabled != nil {
as.Disabled = !*body.Enabled
}
})
_, st, err := s.pb.Raw(r.Context(), http.MethodPatch,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeUpstreamDown(w, err)
return
}
if st != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save organization settings")
return
}
}
// Persist the user record: the personal enable flag lives here (user/
// superadmin scope — in the org scope it targets the org gate instead), and
// so does the personal config layer when this write targets the user scope.
personalEnable := body.Enabled != nil && editable != "org"
if personalEnable || editable == "user" {
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
if personalEnable {
as.Enabled = *body.Enabled
}
if editable == "user" {
as.Config = newOwn
}
})
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
writePBError(w, err)
return
}
}
// Re-resolve and return the fresh view.
fresh := s.userPluginSettings(r.Context(), who.ID)
res2 := s.resolveAnker(r.Context(), who, fresh)
writeJSON(w, http.StatusOK, s.ankerView(who, res2))
}
// POST /api/integrations/anker-solix/health — live probe using the caller's
// resolved config. Never returns secrets.
func (s *Server) handleAnkerHealth(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
down := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": detail}})
}
switch {
case !res.available:
down("The Anker Solix integration is disabled by the administrator")
return
case !res.orgEnabled:
down("The Anker Solix integration is disabled for your organization")
return
case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "":
down("Enter your Anker account email and password to connect")
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
h, err := s.plugins.HealthCheckWith(r.Context(), ankerPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// GET /api/integrations/anker-solix/chargers — the caller's Anker EV chargers,
// fetched server-side under their resolved credentials. Gated by the same
// switches as the settings view (global master, org gate, personal opt-in,
// credentials present); when any gate is off it returns 200 with an empty list
// plus a reason, so the UI can degrade quietly rather than error.
func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
unavailable := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": detail})
}
switch {
case !res.available:
unavailable("The Anker Solix integration is disabled by the administrator")
return
case !res.orgEnabled:
unavailable("The Anker Solix integration is disabled for your organization")
return
case !res.enabled:
unavailable("Enable the Anker Solix integration in Settings to load your chargers")
return
case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "":
unavailable("Enter your Anker account email and password to connect")
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "chargers", nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Relay the upstream JSON verbatim under "chargers".
writeJSON(w, http.StatusOK, map[string]any{"chargers": json.RawMessage(raw)})
}