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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0793b5ec8e
commit
52d9614bad
@@ -0,0 +1,477 @@
|
||||
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)})
|
||||
}
|
||||
@@ -47,6 +47,9 @@
|
||||
// GET /api/integrations/toyota PUT /api/integrations/toyota
|
||||
// POST /api/integrations/toyota/health
|
||||
// GET /api/integrations/toyota/vehicles
|
||||
// GET /api/integrations/anker-solix PUT /api/integrations/anker-solix
|
||||
// POST /api/integrations/anker-solix/health
|
||||
// GET /api/integrations/anker-solix/chargers
|
||||
//
|
||||
// # cars, service records, parts, shares
|
||||
// GET /api/cars POST /api/cars
|
||||
@@ -278,6 +281,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PUT /api/integrations/toyota", s.handlePutToyota)
|
||||
mux.HandleFunc("POST /api/integrations/toyota/health", s.handleToyotaHealth)
|
||||
mux.HandleFunc("GET /api/integrations/toyota/vehicles", s.handleToyotaVehicles)
|
||||
mux.HandleFunc("GET /api/integrations/anker-solix", s.handleGetAnker)
|
||||
mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker)
|
||||
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
|
||||
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
|
||||
@@ -231,6 +231,14 @@ export const api = {
|
||||
saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testToyota: () => request("/integrations/toyota/health", { method: "POST" }),
|
||||
|
||||
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
|
||||
// returns the resolved view (effective/own/locked per field, secrets and
|
||||
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
|
||||
// testAnkerSolix runs a live login probe under the resolved credentials.
|
||||
getAnkerSolix: () => request("/integrations/anker-solix"),
|
||||
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
|
||||
|
||||
// Settings — advanced / danger zone
|
||||
exportData: () => requestBlob("/me/export"),
|
||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -187,7 +187,13 @@
|
||||
"save": "Save",
|
||||
"saved": "Saved ✓",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not connected"
|
||||
"notConnected": "Not connected",
|
||||
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
|
||||
"ankerSolixDesc": "Sign in with your Anker account to pull EV charger data from the Anker Solix cloud.",
|
||||
"ankerEmail": "Anker account email",
|
||||
"ankerPassword": "Anker account password",
|
||||
"country": "Country",
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US)."
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
|
||||
@@ -398,6 +398,117 @@ async function testToyotaConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integrations: Anker Solix (V1 Smart EV Charger) ---
|
||||
//
|
||||
// Same superadmin → org admin → user cascade as Toyota above; the fields are the
|
||||
// Anker account email + password (resolved as a pair) and a country code.
|
||||
|
||||
const anker = ref(null); // resolved view from the server
|
||||
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
||||
const ankerForm = ref({ email: "", password: "", country: "" });
|
||||
const ankerSaving = ref(false);
|
||||
const ankerSaved = ref(false);
|
||||
const ankerError = ref("");
|
||||
const ankerTesting = ref(false);
|
||||
const ankerHealth = ref(null); // { status, detail } from the last test
|
||||
|
||||
const ankerReadOnly = computed(() => !!anker.value?.isSuperadmin);
|
||||
const ankerScopeKey = computed(() => (anker.value?.isSuperadmin ? "user" : ankerScope.value));
|
||||
const ankerScopeData = computed(
|
||||
() => anker.value?.scopes?.[ankerScopeKey.value] || { editableLayer: "user", fields: {} }
|
||||
);
|
||||
const ankerEditingOrg = computed(() => ankerScopeKey.value === "org");
|
||||
|
||||
function ankerField(k) {
|
||||
return ankerScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
|
||||
}
|
||||
function ankerLocked(k) {
|
||||
return ankerReadOnly.value || ankerField(k).locked;
|
||||
}
|
||||
const ankerEnabled = computed(() =>
|
||||
ankerEditingOrg.value ? anker.value?.orgEnabled : anker.value?.enabled
|
||||
);
|
||||
function ankerSourceLabel(k) {
|
||||
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
|
||||
const key = map[ankerField(k).source] || "sourceGlobal";
|
||||
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
||||
}
|
||||
|
||||
function fillAnkerForm() {
|
||||
const f = ankerScopeData.value.fields || {};
|
||||
ankerForm.value = {
|
||||
email: f.email?.locked ? "" : f.email?.own || "",
|
||||
password: "",
|
||||
country: f.country?.locked ? "" : f.country?.own || "",
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnkerView(body) {
|
||||
anker.value = body;
|
||||
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
|
||||
fillAnkerForm();
|
||||
}
|
||||
|
||||
async function loadAnkerSolix() {
|
||||
try {
|
||||
applyAnkerView(await api.getAnkerSolix());
|
||||
} catch (e) {
|
||||
ankerError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
watch(ankerScope, () => {
|
||||
ankerError.value = "";
|
||||
ankerSaved.value = false;
|
||||
ankerHealth.value = null;
|
||||
fillAnkerForm();
|
||||
});
|
||||
|
||||
async function toggleAnker(v) {
|
||||
ankerError.value = "";
|
||||
const org = ankerEditingOrg.value;
|
||||
try {
|
||||
applyAnkerView(await api.saveAnkerSolix(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
|
||||
} catch (e) {
|
||||
ankerError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnkerSettings() {
|
||||
ankerError.value = "";
|
||||
ankerSaving.value = true;
|
||||
ankerSaved.value = false;
|
||||
const config = {};
|
||||
for (const k of ["email", "password", "country"]) {
|
||||
if (ankerLocked(k)) continue;
|
||||
if (k === "password" && !ankerForm.value.password) continue;
|
||||
config[k] = ankerForm.value[k];
|
||||
}
|
||||
try {
|
||||
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
|
||||
ankerSaved.value = true;
|
||||
setTimeout(() => (ankerSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
ankerError.value = e.message;
|
||||
} finally {
|
||||
ankerSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testAnkerConnection() {
|
||||
ankerError.value = "";
|
||||
ankerHealth.value = null;
|
||||
ankerTesting.value = true;
|
||||
try {
|
||||
const { health } = await api.testAnkerSolix();
|
||||
ankerHealth.value = health;
|
||||
} catch (e) {
|
||||
ankerError.value = e.message;
|
||||
} finally {
|
||||
ankerTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
logout();
|
||||
router.replace({ name: "login" });
|
||||
@@ -532,6 +643,7 @@ onMounted(async () => {
|
||||
initDrafts();
|
||||
await loadAvatar();
|
||||
await loadToyota();
|
||||
await loadAnkerSolix();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -720,11 +832,11 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<!-- Integrations -->
|
||||
<section v-if="toyota" class="dh-card p-6">
|
||||
<section v-if="toyota || anker" class="dh-card p-6">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.integrations.title") }}</h2>
|
||||
<p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p>
|
||||
|
||||
<div class="rounded-control border border-subtle p-4">
|
||||
<div v-if="toyota" class="rounded-control border border-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
|
||||
@@ -839,6 +951,127 @@ onBeforeUnmount(() => {
|
||||
|
||||
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Anker Solix (V1 Smart EV Charger) -->
|
||||
<div v-if="anker" class="mt-4 rounded-control border border-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.ankerSolix") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.ankerSolixDesc") }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="!ankerEditingOrg"
|
||||
class="dh-badge shrink-0"
|
||||
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Master / org gates -->
|
||||
<p v-if="!anker.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
||||
<p
|
||||
v-else-if="anker.orgId && !anker.orgEnabled && !ankerEditingOrg"
|
||||
class="mt-3 text-sm text-warning"
|
||||
>
|
||||
{{ t("settings.integrations.orgDisabled") }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Scope switch (org admins) -->
|
||||
<div v-if="anker.canEditOrg" class="mt-4 flex gap-2">
|
||||
<button
|
||||
v-for="sc in ['user', 'org']"
|
||||
:key="sc"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
:class="ankerScope === sc
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="ankerScope = sc"
|
||||
>
|
||||
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="ankerEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
|
||||
<p v-if="ankerReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
|
||||
|
||||
<!-- Enable toggle -->
|
||||
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
|
||||
:checked="ankerEnabled"
|
||||
@change="toggleAnker($event.target.checked)"
|
||||
/>
|
||||
<span>{{ ankerEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Credential fields -->
|
||||
<div class="mt-4 grid max-w-sm gap-3">
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.ankerEmail") }}</label>
|
||||
<input
|
||||
v-model="ankerForm.email"
|
||||
class="dh-input"
|
||||
:disabled="ankerLocked('email')"
|
||||
:placeholder="ankerLocked('email') ? '••••••••' : ''"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="ankerField('email').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('email') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.ankerPassword") }}</label>
|
||||
<input
|
||||
v-model="ankerForm.password"
|
||||
type="password"
|
||||
class="dh-input"
|
||||
:disabled="ankerLocked('password')"
|
||||
:placeholder="ankerField('password').effective ? '••••••••' : ''"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="ankerField('password').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('password') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.country") }}</label>
|
||||
<input
|
||||
v-model="ankerForm.country"
|
||||
class="dh-input"
|
||||
:disabled="ankerLocked('country')"
|
||||
placeholder="DE"
|
||||
maxlength="2"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="ankerField('country').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('country') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.countryHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
v-if="!ankerReadOnly"
|
||||
class="dh-btn dh-btn-primary"
|
||||
:disabled="ankerSaving"
|
||||
@click="saveAnkerSettings"
|
||||
>
|
||||
{{ ankerSaving ? t("common.saving") : ankerSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
|
||||
</button>
|
||||
<button class="dh-btn dh-btn-ghost" :disabled="ankerTesting" @click="testAnkerConnection">
|
||||
{{ ankerTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="ankerHealth"
|
||||
class="mt-2 text-sm"
|
||||
:class="ankerHealth.status === 'ok' ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ ankerHealth.detail || ankerHealth.status }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
|
||||
Reference in New Issue
Block a user