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:
tajniak81
2026-07-18 11:08:58 +02:00
co-authored by Claude Opus 4.8
parent 5a729abd71
commit 5e435c5f77
9 changed files with 910 additions and 11 deletions
+508
View File
@@ -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)})
}
+13
View File
@@ -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)
@@ -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")
}
}
+60
View File
@@ -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()
+19
View File
@@ -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:
// { "<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
// 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:
// { "<plugin>": { "config": {…}, "enabled": bool } }
// The `enabled` flag is the personal opt-in; see internal/api/integrations.go.
F.json("pluginSettings"),
],
};