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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a729abd71
commit
5e435c5f77
@@ -0,0 +1,508 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integrations exposes the Toyota Connected Europe plugin to end users under a
|
||||||
|
// three-layer cascade so that everyone can run it under their own MyToyota
|
||||||
|
// account, while a superadmin (and, in an organization, an org admin) can impose
|
||||||
|
// settings from above.
|
||||||
|
//
|
||||||
|
// Resolution is a cascade: the top layer wins, and a lower layer only fills a
|
||||||
|
// field the layers above left blank.
|
||||||
|
//
|
||||||
|
// - global (L1): the plugin's config in plugins.json, set in the API Server
|
||||||
|
// panel by a superadmin. This is the top of the cascade for everyone.
|
||||||
|
// - org (L2): pluginSettings.toyota on the caller's organization record,
|
||||||
|
// editable by an org admin. Present only for users who belong to an org.
|
||||||
|
// - user (L3): pluginSettings.toyota on the caller's own user record.
|
||||||
|
//
|
||||||
|
// So the effective cascade is:
|
||||||
|
// - org user: global → org → user
|
||||||
|
// - org-less user: global → user
|
||||||
|
//
|
||||||
|
// The MyToyota username + password resolve together as a *pair* from the highest
|
||||||
|
// layer that supplies a username, so credential halves are never mixed across
|
||||||
|
// layers. The brand resolves on its own. Enablement is strictly per-user (L3),
|
||||||
|
// gated by the global master switch (the plugin being enabled in the panel) and,
|
||||||
|
// for org users, by the org gate.
|
||||||
|
//
|
||||||
|
// Secrets (and inherited usernames) are never returned to a lower-privileged
|
||||||
|
// client: the effective config is resolved server-side and only masked values
|
||||||
|
// leave the API. The live probe runs server-side against the resolved config.
|
||||||
|
|
||||||
|
const (
|
||||||
|
toyotaPlugin = "toyota"
|
||||||
|
toyotaSecretMask = "••••••••"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toyotaConfig is one layer's Toyota settings.
|
||||||
|
type toyotaConfig struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaStored is what we persist per user/org under pluginSettings.toyota.
|
||||||
|
type toyotaStored struct {
|
||||||
|
Config toyotaConfig `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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaSettingsDoc is the pluginSettings JSON shape (only toyota today).
|
||||||
|
type toyotaSettingsDoc struct {
|
||||||
|
Toyota toyotaStored `json:"toyota"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaFieldView is one field's resolved state for the UI.
|
||||||
|
type toyotaFieldView 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaResolution is the fully-resolved Toyota state for one caller. It captures
|
||||||
|
// the effective cascade plus both editable layers (personal + organization), so
|
||||||
|
// an org admin can manage each independently.
|
||||||
|
type toyotaResolution struct {
|
||||||
|
eff toyotaConfig // effective (unmasked) — used only server-side (probes)
|
||||||
|
userOwn toyotaConfig // caller's personal (L3) values (unmasked)
|
||||||
|
orgOwn toyotaConfig // 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaLayerRank orders the cascade layers; a higher number is lower priority.
|
||||||
|
var toyotaLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
||||||
|
|
||||||
|
// resolveToyota computes the cascade for a caller. userRaw is the caller's
|
||||||
|
// pluginSettings blob (read from their user record).
|
||||||
|
func (s *Server) resolveToyota(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) toyotaResolution {
|
||||||
|
g, masterEnabled, _ := s.plugins.RawConfig(toyotaPlugin)
|
||||||
|
gc := toyotaConfig{Username: g["username"], Password: g["password"], Brand: g["brand"]}
|
||||||
|
|
||||||
|
var oStored toyotaStored
|
||||||
|
if who.OrgID != "" {
|
||||||
|
oStored, _ = s.orgToyota(ctx, who.OrgID)
|
||||||
|
}
|
||||||
|
oc := oStored.Config
|
||||||
|
|
||||||
|
var uStored toyotaStored
|
||||||
|
if len(userRaw) > 0 {
|
||||||
|
var d toyotaSettingsDoc
|
||||||
|
_ = json.Unmarshal(userRaw, &d)
|
||||||
|
uStored = d.Toyota
|
||||||
|
}
|
||||||
|
uc := uStored.Config
|
||||||
|
|
||||||
|
res := toyotaResolution{
|
||||||
|
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 toyotaConfig
|
||||||
|
}
|
||||||
|
layers := []layer{{"global", gc}}
|
||||||
|
if who.OrgID != "" {
|
||||||
|
layers = append(layers, layer{"org", oc})
|
||||||
|
}
|
||||||
|
layers = append(layers, layer{"user", uc})
|
||||||
|
|
||||||
|
// Brand resolves independently: the highest layer that sets it wins.
|
||||||
|
res.source["brand"] = "unset"
|
||||||
|
for _, l := range layers {
|
||||||
|
if v := strings.TrimSpace(l.c.Brand); v != "" {
|
||||||
|
res.eff.Brand, res.source["brand"] = v, l.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials resolve as a pair from the highest layer with a username, so
|
||||||
|
// the username and password never come from different layers.
|
||||||
|
credSrc := "unset"
|
||||||
|
for _, l := range layers {
|
||||||
|
if u := strings.TrimSpace(l.c.Username); u != "" {
|
||||||
|
res.eff.Username, res.eff.Password, credSrc = u, l.c.Password, l.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.source["username"] = credSrc
|
||||||
|
res.source["password"] = credSrc
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaLockedFor 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 toyotaLockedFor(source, editable string) bool {
|
||||||
|
if editable == "none" {
|
||||||
|
return true // superadmin edits the global layer in the panel, not here
|
||||||
|
}
|
||||||
|
sr, ok := toyotaLayerRank[source]
|
||||||
|
if !ok {
|
||||||
|
return false // unset — the caller may be the first to set it
|
||||||
|
}
|
||||||
|
return sr < toyotaLayerRank[editable]
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaMaskPresent returns the secret mask when v is non-empty, else "".
|
||||||
|
func toyotaMaskPresent(v string) string {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return toyotaSecretMask
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// orgToyota reads an organization's stored Toyota 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) orgToyota(ctx context.Context, orgID string) (toyotaStored, json.RawMessage) {
|
||||||
|
if orgID == "" || !s.pb.Configured() {
|
||||||
|
return toyotaStored{}, 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 toyotaStored{}, nil
|
||||||
|
}
|
||||||
|
var rec struct {
|
||||||
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &rec)
|
||||||
|
var doc toyotaSettingsDoc
|
||||||
|
if len(rec.PluginSettings) > 0 {
|
||||||
|
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||||
|
}
|
||||||
|
return doc.Toyota, rec.PluginSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPluginSettings reads a user's raw pluginSettings blob via the service
|
||||||
|
// account. Best effort: nil on any miss.
|
||||||
|
func (s *Server) userPluginSettings(ctx context.Context, id string) json.RawMessage {
|
||||||
|
if id == "" || !s.pb.Configured() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rec struct {
|
||||||
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||||
|
}
|
||||||
|
if err := s.pb.GetOne(ctx, s.usersCollection(), id, &rec); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rec.PluginSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeToyota applies a mutation to the toyota entry of a pluginSettings blob,
|
||||||
|
// preserving any other plugin keys, and returns the new blob.
|
||||||
|
func mergeToyota(existing json.RawMessage, apply func(*toyotaStored)) 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 ts toyotaStored
|
||||||
|
if raw, ok := doc["toyota"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &ts)
|
||||||
|
}
|
||||||
|
apply(&ts)
|
||||||
|
b, _ := json.Marshal(ts)
|
||||||
|
doc["toyota"] = b
|
||||||
|
out, _ := json.Marshal(doc)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaScopeView 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) toyotaScopeView(res toyotaResolution, editable string) map[string]any {
|
||||||
|
own := res.userOwn
|
||||||
|
if editable == "org" {
|
||||||
|
own = res.orgOwn
|
||||||
|
}
|
||||||
|
field := func(key, eff, ownv string, secret bool) toyotaFieldView {
|
||||||
|
src := res.source[key]
|
||||||
|
locked := toyotaLockedFor(src, editable)
|
||||||
|
fv := toyotaFieldView{Source: src, Locked: locked}
|
||||||
|
switch {
|
||||||
|
case secret:
|
||||||
|
// Never expose a secret; show only presence.
|
||||||
|
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
|
||||||
|
case key == "username" && locked:
|
||||||
|
// Inherited username — hide the concrete value from a lower layer.
|
||||||
|
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
|
||||||
|
default:
|
||||||
|
fv.Effective, fv.Own = eff, ownv
|
||||||
|
}
|
||||||
|
return fv
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"editableLayer": editable,
|
||||||
|
"fields": map[string]toyotaFieldView{
|
||||||
|
"username": field("username", res.eff.Username, own.Username, false),
|
||||||
|
"password": field("password", res.eff.Password, own.Password, true),
|
||||||
|
"brand": field("brand", res.eff.Brand, own.Brand, false),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toyotaView builds the masked, client-safe response body from a resolution. It
|
||||||
|
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
|
||||||
|
// with its own locked-field state — so the UI can present the two independently.
|
||||||
|
func (s *Server) toyotaView(who *callerIdentity, res toyotaResolution) 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.toyotaScopeView(res, "none")}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
scopes := map[string]any{"user": s.toyotaScopeView(res, "user")}
|
||||||
|
if res.canOrg {
|
||||||
|
scopes["org"] = s.toyotaScopeView(res, "org")
|
||||||
|
}
|
||||||
|
out["scopes"] = scopes
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/integrations/toyota — resolved Toyota view for the caller.
|
||||||
|
func (s *Server) handleGetToyota(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.resolveToyota(r.Context(), who, userRaw)
|
||||||
|
writeJSON(w, http.StatusOK, s.toyotaView(who, res))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/integrations/toyota — save the caller's editable layer. Body:
|
||||||
|
// {enabled?: bool, scope?: "user"|"org", config?: {username, password, brand}}.
|
||||||
|
// Fields locked above the caller are ignored; a password left at the mask is
|
||||||
|
// preserved.
|
||||||
|
func (s *Server) handlePutToyota(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.resolveToyota(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(*toyotaConfig, string)) {
|
||||||
|
v, ok := body.Config[key]
|
||||||
|
if !ok || toyotaLockedFor(res.source[key], editable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if key == "password" && v == toyotaSecretMask {
|
||||||
|
return // keep current secret
|
||||||
|
}
|
||||||
|
set(&newOwn, strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
applyField("username", func(c *toyotaConfig, v string) { c.Username = v })
|
||||||
|
applyField("password", func(c *toyotaConfig, v string) { c.Password = v })
|
||||||
|
applyField("brand", func(c *toyotaConfig, v string) { c.Brand = v })
|
||||||
|
|
||||||
|
// Persist the organization layer (admins) via the service account.
|
||||||
|
if editable == "org" {
|
||||||
|
_, orgRaw := s.orgToyota(r.Context(), who.OrgID)
|
||||||
|
newDoc := mergeToyota(orgRaw, func(ts *toyotaStored) {
|
||||||
|
ts.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 {
|
||||||
|
ts.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 := mergeToyota(userRaw, func(ts *toyotaStored) {
|
||||||
|
if personalEnable {
|
||||||
|
ts.Enabled = *body.Enabled
|
||||||
|
}
|
||||||
|
if editable == "user" {
|
||||||
|
ts.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.resolveToyota(r.Context(), who, fresh)
|
||||||
|
writeJSON(w, http.StatusOK, s.toyotaView(who, res2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/integrations/toyota/health — live probe using the caller's resolved
|
||||||
|
// config. Never returns secrets.
|
||||||
|
func (s *Server) handleToyotaHealth(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.resolveToyota(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 Toyota integration is disabled by the administrator")
|
||||||
|
return
|
||||||
|
case !res.orgEnabled:
|
||||||
|
down("The Toyota integration is disabled for your organization")
|
||||||
|
return
|
||||||
|
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
||||||
|
down("Enter your MyToyota email and password to connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := map[string]string{
|
||||||
|
"username": res.eff.Username,
|
||||||
|
"password": res.eff.Password,
|
||||||
|
"brand": res.eff.Brand,
|
||||||
|
}
|
||||||
|
h, err := s.plugins.HealthCheckWith(r.Context(), toyotaPlugin, 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/toyota/vehicles — the caller's Toyota vehicles, 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) handleToyotaVehicles(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.resolveToyota(r.Context(), who, userRaw)
|
||||||
|
|
||||||
|
unavailable := func(detail string) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"vehicles": []any{}, "unavailable": true, "detail": detail})
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !res.available:
|
||||||
|
unavailable("The Toyota integration is disabled by the administrator")
|
||||||
|
return
|
||||||
|
case !res.orgEnabled:
|
||||||
|
unavailable("The Toyota integration is disabled for your organization")
|
||||||
|
return
|
||||||
|
case !res.enabled:
|
||||||
|
unavailable("Enable the Toyota integration in Settings to load your vehicles")
|
||||||
|
return
|
||||||
|
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
||||||
|
unavailable("Enter your MyToyota email and password to connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := map[string]string{
|
||||||
|
"username": res.eff.Username,
|
||||||
|
"password": res.eff.Password,
|
||||||
|
"brand": res.eff.Brand,
|
||||||
|
}
|
||||||
|
raw, err := s.plugins.InvokeWith(r.Context(), toyotaPlugin, cfg, "vehicles", nil)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Relay the upstream JSON verbatim under "vehicles".
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"vehicles": json.RawMessage(raw)})
|
||||||
|
}
|
||||||
@@ -43,6 +43,11 @@
|
|||||||
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
||||||
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
||||||
//
|
//
|
||||||
|
// # integrations (per-user plugin settings; superadmin → org admin → user cascade)
|
||||||
|
// GET /api/integrations/toyota PUT /api/integrations/toyota
|
||||||
|
// POST /api/integrations/toyota/health
|
||||||
|
// GET /api/integrations/toyota/vehicles
|
||||||
|
//
|
||||||
// # cars, service records, parts, shares
|
// # cars, service records, parts, shares
|
||||||
// GET /api/cars POST /api/cars
|
// GET /api/cars POST /api/cars
|
||||||
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
||||||
@@ -266,6 +271,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
||||||
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
||||||
|
|
||||||
|
// Integrations — per-user plugin settings under the superadmin → org admin →
|
||||||
|
// user cascade (see integrations.go). Any authenticated user manages their
|
||||||
|
// own layer; an org admin may also target their organization's layer.
|
||||||
|
mux.HandleFunc("GET /api/integrations/toyota", s.handleGetToyota)
|
||||||
|
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)
|
||||||
|
|
||||||
// Cars + sharing.
|
// Cars + sharing.
|
||||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||||
mux.HandleFunc("POST /api/cars", s.createCar)
|
mux.HandleFunc("POST /api/cars", s.createCar)
|
||||||
|
|||||||
@@ -127,9 +127,14 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
|||||||
{ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."},
|
{ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."},
|
||||||
},
|
},
|
||||||
ConfigFields: []plugins.ConfigField{
|
ConfigFields: []plugins.ConfigField{
|
||||||
{Key: "username", Label: "MyToyota email", Type: "text", Required: true,
|
// Credentials are intentionally NOT required at the global (panel) layer:
|
||||||
|
// this connector runs under each user's own MyToyota account, supplied in
|
||||||
|
// the Web App's per-user integration settings. A superadmin/org admin may
|
||||||
|
// still set a shared (e.g. fleet) account here that users inherit. See the
|
||||||
|
// cascade in internal/api/integrations.go.
|
||||||
|
{Key: "username", Label: "MyToyota email", Type: "text",
|
||||||
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
|
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
|
||||||
{Key: "password", Label: "MyToyota password", Type: "password", Required: true, Secret: true,
|
{Key: "password", Label: "MyToyota password", Type: "password", Secret: true,
|
||||||
Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
|
Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
|
||||||
{Key: "brand", Label: "Brand", Type: "select", Default: "T",
|
{Key: "brand", Label: "Brand", Type: "select", Default: "T",
|
||||||
Help: "Vehicle brand tied to the account.",
|
Help: "Vehicle brand tied to the account.",
|
||||||
|
|||||||
@@ -21,17 +21,23 @@ func TestDescriptor(t *testing.T) {
|
|||||||
if len(d.Capabilities) == 0 {
|
if len(d.Capabilities) == 0 {
|
||||||
t.Fatal("expected capabilities")
|
t.Fatal("expected capabilities")
|
||||||
}
|
}
|
||||||
// Required credential fields must be present.
|
// The credential + brand fields must be advertised. They are intentionally
|
||||||
req := map[string]bool{}
|
// NOT required at the global layer — credentials come from the per-user
|
||||||
|
// cascade — and the password field must be marked secret.
|
||||||
|
fields := map[string]plugins.ConfigField{}
|
||||||
for _, f := range d.ConfigFields {
|
for _, f := range d.ConfigFields {
|
||||||
if f.Required {
|
fields[f.Key] = f
|
||||||
req[f.Key] = true
|
}
|
||||||
|
for _, k := range []string{"username", "password", "brand"} {
|
||||||
|
if _, ok := fields[k]; !ok {
|
||||||
|
t.Errorf("config field %q should be present", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, k := range []string{"username", "password"} {
|
if fields["username"].Required || fields["password"].Required {
|
||||||
if !req[k] {
|
t.Error("credentials must not be required at the global layer (per-user cascade)")
|
||||||
t.Errorf("config field %q should be required", k)
|
}
|
||||||
}
|
if !fields["password"].Secret {
|
||||||
|
t.Error("password field must be marked secret")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,66 @@ func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error)
|
|||||||
return h, nil
|
return h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthCheckWith probes a plugin against a caller-resolved config rather than
|
||||||
|
// the stored global config. It builds a transient instance, Inits it with cfg,
|
||||||
|
// probes, and tears it down — so a per-user cascade (see internal/api/
|
||||||
|
// integrations.go) can health-check under the credentials in force for that
|
||||||
|
// caller without disturbing the global instance or its cached health.
|
||||||
|
func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[string]string) (Health, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
rec := m.records[name]
|
||||||
|
p := construct(name, m.factories[name], rec)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if p == nil {
|
||||||
|
return Health{}, errUnknown
|
||||||
|
}
|
||||||
|
_ = p.Init(ctx, cfg)
|
||||||
|
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||||
|
return p.HealthCheck(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeWith runs a capability against a caller-resolved config. Like
|
||||||
|
// HealthCheckWith, it uses a transient instance Inited with cfg so per-user
|
||||||
|
// credentials drive the call. Returns the plugin's raw JSON result.
|
||||||
|
func (m *Manager) InvokeWith(ctx context.Context, name string, cfg map[string]string, action string, payload json.RawMessage) (json.RawMessage, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
rec := m.records[name]
|
||||||
|
p := construct(name, m.factories[name], rec)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if p == nil {
|
||||||
|
return nil, errUnknown
|
||||||
|
}
|
||||||
|
_ = p.Init(ctx, cfg)
|
||||||
|
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||||
|
return p.Invoke(ctx, action, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawConfig returns a copy of a plugin's stored (global) config and its enabled
|
||||||
|
// flag. ok is false for an unknown plugin. This is the top layer (L1) of the
|
||||||
|
// per-user cascade: the config a superadmin set in the panel, which lower layers
|
||||||
|
// inherit blank fields from. Secrets are returned in clear — callers must mask
|
||||||
|
// before returning anything to a client.
|
||||||
|
func (m *Manager) RawConfig(name string) (cfg map[string]string, enabled, ok bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
_, isBuiltin := m.factories[name]
|
||||||
|
rec := m.records[name]
|
||||||
|
if !isBuiltin && rec == nil {
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
if rec != nil {
|
||||||
|
for k, v := range rec.Config {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
enabled = rec.Enabled
|
||||||
|
}
|
||||||
|
return out, enabled, true
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
||||||
func (m *Manager) Shutdown(ctx context.Context) {
|
func (m *Manager) Shutdown(ctx context.Context) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const F = {
|
|||||||
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
|
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
|
||||||
// Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
|
// Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
|
||||||
file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
|
file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
|
||||||
|
// Free-form JSON blob. maxSize is in bytes.
|
||||||
|
json: (name, maxSize = 100000) => ({ name, type: "json", required: false, maxSize }),
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderField(def, format, idByName) {
|
function renderField(def, format, idByName) {
|
||||||
@@ -96,6 +98,9 @@ function renderField(def, format, idByName) {
|
|||||||
options.maxSize = def.maxSize;
|
options.maxSize = def.maxSize;
|
||||||
options.mimeTypes = def.mimeTypes || [];
|
options.mimeTypes = def.mimeTypes || [];
|
||||||
}
|
}
|
||||||
|
if (def.type === "json") {
|
||||||
|
options.maxSize = def.maxSize;
|
||||||
|
}
|
||||||
return { name: def.name, type: def.type, required: def.required, options };
|
return { name: def.name, type: def.type, required: def.required, options };
|
||||||
}
|
}
|
||||||
// Modern: options flattened onto the field.
|
// Modern: options flattened onto the field.
|
||||||
@@ -119,6 +124,9 @@ function renderField(def, format, idByName) {
|
|||||||
field.maxSize = def.maxSize;
|
field.maxSize = def.maxSize;
|
||||||
field.mimeTypes = def.mimeTypes || [];
|
field.mimeTypes = def.mimeTypes || [];
|
||||||
}
|
}
|
||||||
|
if (def.type === "json") {
|
||||||
|
field.maxSize = def.maxSize;
|
||||||
|
}
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +388,12 @@ const DESIRED = {
|
|||||||
organizations: [
|
organizations: [
|
||||||
F.text("name", true),
|
F.text("name", true),
|
||||||
F.autodate("created", true, false),
|
F.autodate("created", true, false),
|
||||||
|
// Per-organization plugin/integration config — the middle (org admin) layer
|
||||||
|
// of the integration cascade (API Server → org admin → user). Shape:
|
||||||
|
// { "<plugin>": { "config": {…}, "disabled": bool } }
|
||||||
|
// See internal/api/integrations.go. Only meaningful for plugins that expose
|
||||||
|
// a per-user cascade (today: toyota).
|
||||||
|
F.json("pluginSettings"),
|
||||||
],
|
],
|
||||||
// Custom fields layered onto the built-in "users" auth collection (which
|
// Custom fields layered onto the built-in "users" auth collection (which
|
||||||
// already ships with email/name/avatar). Settings-panel additions:
|
// already ships with email/name/avatar). Settings-panel additions:
|
||||||
@@ -404,6 +418,11 @@ const DESIRED = {
|
|||||||
// not delete its people. (The API refuses to delete an org that still has
|
// not delete its people. (The API refuses to delete an org that still has
|
||||||
// members, so this should not arise in practice.)
|
// members, so this should not arise in practice.)
|
||||||
F.relation("organization", "organizations", false, false),
|
F.relation("organization", "organizations", false, false),
|
||||||
|
// Per-user plugin/integration config — the bottom (user) layer of the
|
||||||
|
// integration cascade. Shape:
|
||||||
|
// { "<plugin>": { "config": {…}, "enabled": bool } }
|
||||||
|
// The `enabled` flag is the personal opt-in; see internal/api/integrations.go.
|
||||||
|
F.json("pluginSettings"),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,15 @@ export const api = {
|
|||||||
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
|
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
|
||||||
getAvatarBlob: () => requestBlob("/me/avatar"),
|
getAvatarBlob: () => requestBlob("/me/avatar"),
|
||||||
|
|
||||||
|
// Integrations — per-user plugin settings under the superadmin → org admin →
|
||||||
|
// user cascade. getToyota returns the resolved view (effective/own/locked per
|
||||||
|
// field, with secrets and inherited usernames masked); saveToyota writes the
|
||||||
|
// caller's editable layer (scope "user" by default, "org" for org admins);
|
||||||
|
// testToyota runs a live login probe under the resolved credentials.
|
||||||
|
getToyota: () => request("/integrations/toyota"),
|
||||||
|
saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
testToyota: () => request("/integrations/toyota/health", { method: "POST" }),
|
||||||
|
|
||||||
// Settings — advanced / danger zone
|
// Settings — advanced / danger zone
|
||||||
exportData: () => requestBlob("/me/export"),
|
exportData: () => requestBlob("/me/export"),
|
||||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|||||||
@@ -158,6 +158,38 @@
|
|||||||
"saveBio": "Save bio"
|
"saveBio": "Save bio"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"integrations": {
|
||||||
|
"title": "Integrations",
|
||||||
|
"subtitle": "Connect external services to your account.",
|
||||||
|
"toyota": "Toyota Connected (MyToyota)",
|
||||||
|
"toyotaDesc": "Sign in with your MyToyota account to pull vehicle data from Toyota Connected Europe. Europe only.",
|
||||||
|
"enable": "Enable for my account",
|
||||||
|
"enableOrg": "Enable for the organization",
|
||||||
|
"unavailable": "This integration is turned off by the administrator.",
|
||||||
|
"orgDisabled": "This integration is turned off for your organization.",
|
||||||
|
"scopeMy": "My settings",
|
||||||
|
"scopeOrg": "Organization",
|
||||||
|
"scopeHint": "Organization settings apply to everyone in your organization who leaves a field blank.",
|
||||||
|
"readOnly": "You manage this integration's shared settings in the API Server panel; here it is read-only.",
|
||||||
|
"email": "MyToyota email",
|
||||||
|
"password": "MyToyota password",
|
||||||
|
"brand": "Brand",
|
||||||
|
"brandToyota": "Toyota",
|
||||||
|
"brandLexus": "Lexus",
|
||||||
|
"inherited": "Set above your level",
|
||||||
|
"inheritedFrom": "Inherited from {source}",
|
||||||
|
"sourceGlobal": "the administrator",
|
||||||
|
"sourceOrg": "your organization",
|
||||||
|
"sourceUser": "you",
|
||||||
|
"passwordKeep": "Leave blank to keep the saved password.",
|
||||||
|
"test": "Test connection",
|
||||||
|
"testing": "Testing…",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved ✓",
|
||||||
|
"connected": "Connected",
|
||||||
|
"notConnected": "Not connected"
|
||||||
|
},
|
||||||
|
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy & security",
|
"title": "Privacy & security",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { state, logout, refreshProfile } from "../auth";
|
import { state, logout, refreshProfile } from "../auth";
|
||||||
@@ -274,6 +274,130 @@ async function saveBio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
|
||||||
|
//
|
||||||
|
// The server resolves a superadmin → org admin → user cascade and returns, per
|
||||||
|
// field, the effective value (secrets/inherited usernames masked), the caller's
|
||||||
|
// own-layer value, its source layer, and whether it's locked (set above us).
|
||||||
|
// An org admin gets a second "org" scope to edit organization-wide defaults.
|
||||||
|
|
||||||
|
const toyota = ref(null); // resolved view from the server
|
||||||
|
const toyotaScope = ref("user"); // "user" | "org" (org admins only)
|
||||||
|
const toyotaForm = ref({ username: "", password: "", brand: "" });
|
||||||
|
const toyotaSaving = ref(false);
|
||||||
|
const toyotaSaved = ref(false);
|
||||||
|
const toyotaError = ref("");
|
||||||
|
const toyotaTesting = ref(false);
|
||||||
|
const toyotaHealth = ref(null); // { status, detail } from the last test
|
||||||
|
|
||||||
|
// Superadmins manage the shared (global) layer in the API Server panel; here the
|
||||||
|
// credential fields are read-only (they may still toggle their own opt-in).
|
||||||
|
const toyotaReadOnly = computed(() => !!toyota.value?.isSuperadmin);
|
||||||
|
const toyotaScopeKey = computed(() => (toyota.value?.isSuperadmin ? "user" : toyotaScope.value));
|
||||||
|
const toyotaScopeData = computed(
|
||||||
|
() => toyota.value?.scopes?.[toyotaScopeKey.value] || { editableLayer: "user", fields: {} }
|
||||||
|
);
|
||||||
|
const toyotaEditingOrg = computed(() => toyotaScopeKey.value === "org");
|
||||||
|
|
||||||
|
function toyotaField(k) {
|
||||||
|
return toyotaScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
|
||||||
|
}
|
||||||
|
// A field is locked when it's set above the caller's editable layer, or the
|
||||||
|
// whole scope is read-only (superadmin).
|
||||||
|
function toyotaLocked(k) {
|
||||||
|
return toyotaReadOnly.value || toyotaField(k).locked;
|
||||||
|
}
|
||||||
|
// The toggle reflects the personal opt-in normally, or the org gate in org scope.
|
||||||
|
const toyotaEnabled = computed(() =>
|
||||||
|
toyotaEditingOrg.value ? toyota.value?.orgEnabled : toyota.value?.enabled
|
||||||
|
);
|
||||||
|
// A friendly "inherited from …" note for a locked field.
|
||||||
|
function toyotaSourceLabel(k) {
|
||||||
|
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
|
||||||
|
const key = map[toyotaField(k).source] || "sourceGlobal";
|
||||||
|
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the editable inputs from the current scope's own-layer values. Locked
|
||||||
|
// fields and the secret password are never prefilled.
|
||||||
|
function fillToyotaForm() {
|
||||||
|
const f = toyotaScopeData.value.fields || {};
|
||||||
|
toyotaForm.value = {
|
||||||
|
username: f.username?.locked ? "" : f.username?.own || "",
|
||||||
|
password: "",
|
||||||
|
brand: f.brand?.locked ? "" : f.brand?.own || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToyotaView(body) {
|
||||||
|
toyota.value = body;
|
||||||
|
// Keep the selected scope valid (fall back to personal when org isn't offered).
|
||||||
|
if (toyotaScope.value === "org" && !body.canEditOrg) toyotaScope.value = "user";
|
||||||
|
fillToyotaForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToyota() {
|
||||||
|
try {
|
||||||
|
applyToyotaView(await api.getToyota());
|
||||||
|
} catch (e) {
|
||||||
|
toyotaError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refill the form when an admin flips between personal and organization scope.
|
||||||
|
watch(toyotaScope, () => {
|
||||||
|
toyotaError.value = "";
|
||||||
|
toyotaSaved.value = false;
|
||||||
|
toyotaHealth.value = null;
|
||||||
|
fillToyotaForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleToyota(v) {
|
||||||
|
toyotaError.value = "";
|
||||||
|
const org = toyotaEditingOrg.value;
|
||||||
|
try {
|
||||||
|
applyToyotaView(await api.saveToyota(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
|
||||||
|
} catch (e) {
|
||||||
|
toyotaError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToyotaSettings() {
|
||||||
|
toyotaError.value = "";
|
||||||
|
toyotaSaving.value = true;
|
||||||
|
toyotaSaved.value = false;
|
||||||
|
const config = {};
|
||||||
|
for (const k of ["username", "password", "brand"]) {
|
||||||
|
if (toyotaLocked(k)) continue;
|
||||||
|
// Only send the password when the user actually typed a new one.
|
||||||
|
if (k === "password" && !toyotaForm.value.password) continue;
|
||||||
|
config[k] = toyotaForm.value[k];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyToyotaView(await api.saveToyota({ scope: toyotaScopeKey.value, config }));
|
||||||
|
toyotaSaved.value = true;
|
||||||
|
setTimeout(() => (toyotaSaved.value = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
toyotaError.value = e.message;
|
||||||
|
} finally {
|
||||||
|
toyotaSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testToyotaConnection() {
|
||||||
|
toyotaError.value = "";
|
||||||
|
toyotaHealth.value = null;
|
||||||
|
toyotaTesting.value = true;
|
||||||
|
try {
|
||||||
|
const { health } = await api.testToyota();
|
||||||
|
toyotaHealth.value = health;
|
||||||
|
} catch (e) {
|
||||||
|
toyotaError.value = e.message;
|
||||||
|
} finally {
|
||||||
|
toyotaTesting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onLogout() {
|
function onLogout() {
|
||||||
logout();
|
logout();
|
||||||
router.replace({ name: "login" });
|
router.replace({ name: "login" });
|
||||||
@@ -407,6 +531,7 @@ onMounted(async () => {
|
|||||||
await load();
|
await load();
|
||||||
initDrafts();
|
initDrafts();
|
||||||
await loadAvatar();
|
await loadAvatar();
|
||||||
|
await loadToyota();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -594,6 +719,128 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Integrations -->
|
||||||
|
<section v-if="toyota" 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 class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.toyotaDesc") }}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="!toyotaEditingOrg"
|
||||||
|
class="dh-badge shrink-0"
|
||||||
|
:class="toyota.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||||
|
>
|
||||||
|
{{ toyota.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Master / org gates -->
|
||||||
|
<p v-if="!toyota.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
||||||
|
<p
|
||||||
|
v-else-if="toyota.orgId && !toyota.orgEnabled && !toyotaEditingOrg"
|
||||||
|
class="mt-3 text-sm text-warning"
|
||||||
|
>
|
||||||
|
{{ t("settings.integrations.orgDisabled") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Scope switch (org admins) -->
|
||||||
|
<div v-if="toyota.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="toyotaScope === sc
|
||||||
|
? 'border-accent bg-accent text-white'
|
||||||
|
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||||
|
@click="toyotaScope = sc"
|
||||||
|
>
|
||||||
|
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="toyotaEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
|
||||||
|
<p v-if="toyotaReadOnly" 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="toyotaEnabled"
|
||||||
|
@change="toggleToyota($event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ toyotaEditingOrg ? 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.email") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="toyotaForm.username"
|
||||||
|
class="dh-input"
|
||||||
|
:disabled="toyotaLocked('username')"
|
||||||
|
:placeholder="toyotaLocked('username') ? '••••••••' : ''"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<p v-if="toyotaField('username').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('username') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="dh-label">{{ t("settings.integrations.password") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="toyotaForm.password"
|
||||||
|
type="password"
|
||||||
|
class="dh-input"
|
||||||
|
:disabled="toyotaLocked('password')"
|
||||||
|
:placeholder="toyotaField('password').effective ? '••••••••' : ''"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<p v-if="toyotaField('password').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('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.brand") }}</label>
|
||||||
|
<select v-model="toyotaForm.brand" class="dh-input" :disabled="toyotaLocked('brand')">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="T">{{ t("settings.integrations.brandToyota") }}</option>
|
||||||
|
<option value="L">{{ t("settings.integrations.brandLexus") }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="toyotaField('brand').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('brand') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!toyotaReadOnly"
|
||||||
|
class="dh-btn dh-btn-primary"
|
||||||
|
:disabled="toyotaSaving"
|
||||||
|
@click="saveToyotaSettings"
|
||||||
|
>
|
||||||
|
{{ toyotaSaving ? t("common.saving") : toyotaSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
|
||||||
|
</button>
|
||||||
|
<button class="dh-btn dh-btn-ghost" :disabled="toyotaTesting" @click="testToyotaConnection">
|
||||||
|
{{ toyotaTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="toyotaHealth"
|
||||||
|
class="mt-2 text-sm"
|
||||||
|
:class="toyotaHealth.status === 'ok' ? 'text-success' : 'text-danger'"
|
||||||
|
>
|
||||||
|
{{ toyotaHealth.detail || toyotaHealth.status }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Privacy & Security -->
|
<!-- Privacy & Security -->
|
||||||
<section class="dh-card p-6">
|
<section class="dh-card p-6">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user