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
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user