From 5e435c5f770fe54620f53edb7dd643503a60e7dc Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:08:58 +0200 Subject: [PATCH] Add per-user Toyota integration with a settings cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- API Server/internal/api/integrations.go | 508 ++++++++++++++++++ API Server/internal/api/server.go | 13 + .../internal/plugins/builtin/toyota/toyota.go | 9 +- .../plugins/builtin/toyota/toyota_test.go | 22 +- API Server/internal/plugins/manager.go | 60 +++ API Server/scripts/setup-pocketbase.mjs | 19 + Web App/web/src/api.js | 9 + Web App/web/src/i18n/en.json | 32 ++ Web App/web/src/views/Settings.vue | 249 ++++++++- 9 files changed, 910 insertions(+), 11 deletions(-) create mode 100644 API Server/internal/api/integrations.go diff --git a/API Server/internal/api/integrations.go b/API Server/internal/api/integrations.go new file mode 100644 index 0000000..cf3df41 --- /dev/null +++ b/API Server/internal/api/integrations.go @@ -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)}) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 6c633bc..941a7e1 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -43,6 +43,11 @@ // GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name} // 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 // GET /api/cars POST /api/cars // 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("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. mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("POST /api/cars", s.createCar) diff --git a/API Server/internal/plugins/builtin/toyota/toyota.go b/API Server/internal/plugins/builtin/toyota/toyota.go index 9ee8138..fd955a3 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota.go +++ b/API Server/internal/plugins/builtin/toyota/toyota.go @@ -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."}, }, 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."}, - {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."}, {Key: "brand", Label: "Brand", Type: "select", Default: "T", Help: "Vehicle brand tied to the account.", diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go index bb9abb9..3c2fd7b 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota_test.go +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -21,17 +21,23 @@ func TestDescriptor(t *testing.T) { if len(d.Capabilities) == 0 { t.Fatal("expected capabilities") } - // Required credential fields must be present. - req := map[string]bool{} + // The credential + brand fields must be advertised. They are intentionally + // 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 { - if f.Required { - req[f.Key] = true + fields[f.Key] = f + } + 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 !req[k] { - t.Errorf("config field %q should be required", k) - } + if fields["username"].Required || fields["password"].Required { + t.Error("credentials must not be required at the global layer (per-user cascade)") + } + if !fields["password"].Secret { + t.Error("password field must be marked secret") } } diff --git a/API Server/internal/plugins/manager.go b/API Server/internal/plugins/manager.go index 4d96b55..2698512 100644 --- a/API Server/internal/plugins/manager.go +++ b/API Server/internal/plugins/manager.go @@ -321,6 +321,66 @@ func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) 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. func (m *Manager) Shutdown(ctx context.Context) { m.mu.Lock() diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index c3b212a..29c7c75 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -75,6 +75,8 @@ const F = { autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }), // Single-file attachment. maxSize is in bytes; mimeTypes [] means "any". 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) { @@ -96,6 +98,9 @@ function renderField(def, format, idByName) { options.maxSize = def.maxSize; options.mimeTypes = def.mimeTypes || []; } + if (def.type === "json") { + options.maxSize = def.maxSize; + } return { name: def.name, type: def.type, required: def.required, options }; } // Modern: options flattened onto the field. @@ -119,6 +124,9 @@ function renderField(def, format, idByName) { field.maxSize = def.maxSize; field.mimeTypes = def.mimeTypes || []; } + if (def.type === "json") { + field.maxSize = def.maxSize; + } return field; } @@ -380,6 +388,12 @@ const DESIRED = { organizations: [ F.text("name", true), 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: + // { "": { "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 // 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 // members, so this should not arise in practice.) F.relation("organization", "organizations", false, false), + // Per-user plugin/integration config — the bottom (user) layer of the + // integration cascade. Shape: + // { "": { "config": {…}, "enabled": bool } } + // The `enabled` flag is the personal opt-in; see internal/api/integrations.go. + F.json("pluginSettings"), ], }; diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index b516896..d35cfc1 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -222,6 +222,15 @@ export const api = { deleteAvatar: () => request("/me/avatar", { method: "DELETE" }), 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 exportData: () => requestBlob("/me/export"), importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }), diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index bece48a..23275e9 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -158,6 +158,38 @@ "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": { "title": "Privacy & security", "signOut": "Sign out", diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 55872ee..9283e4e 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -1,5 +1,5 @@