From 6f445c1cda1484587ee7e7fe2123540258ebcf58 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:12:39 -0400 Subject: [PATCH] fix(watchsync): support declarative setup fallbacks --- internal/watchsync/plugin_provider.go | 12 ++ internal/watchsync/plugin_provider_test.go | 16 +++ .../admin/plugins/PluginConfigForm.test.tsx | 24 ++++ .../admin/plugins/PluginConfigForm.tsx | 104 +++--------------- .../admin/plugins/configSchemaAdminForm.ts | 69 ++++++++++++ .../pages/settings/WatchProvidersSettings.tsx | 6 +- .../watchProviderConnectionConfig.test.ts | 17 +++ .../settings/watchProviderConnectionConfig.ts | 10 ++ 8 files changed, 164 insertions(+), 94 deletions(-) create mode 100644 web/src/components/admin/plugins/configSchemaAdminForm.ts diff --git a/internal/watchsync/plugin_provider.go b/internal/watchsync/plugin_provider.go index db652909..cf00a978 100644 --- a/internal/watchsync/plugin_provider.go +++ b/internal/watchsync/plugin_provider.go @@ -89,6 +89,9 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) { if err := validateWatchSyncConnectionConfigSchemas(options.ConnectionConfigSchema); err != nil { return nil, fmt.Errorf("watch sync plugin %q %w", options.ProviderKey, err) } + if authMethod != AuthMethodAPIKey && hasWatchSyncConnectionConfigSchema(options.ConnectionConfigSchema) { + return nil, fmt.Errorf("watch sync plugin %q connection config requires API-key authentication", options.ProviderKey) + } if options.ResolveClient == nil { return nil, fmt.Errorf("watch sync plugin client resolver is required") } @@ -107,6 +110,15 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) { }, nil } +func hasWatchSyncConnectionConfigSchema(schemas []*pluginv1.ConfigSchema) bool { + for _, schema := range schemas { + if schema != nil { + return true + } + } + return false +} + func (p *PluginProvider) Key() string { return p.providerKey } func (p *PluginProvider) DisplayName() string { diff --git a/internal/watchsync/plugin_provider_test.go b/internal/watchsync/plugin_provider_test.go index a160a448..8c323a5c 100644 --- a/internal/watchsync/plugin_provider_test.go +++ b/internal/watchsync/plugin_provider_test.go @@ -348,6 +348,22 @@ func TestPluginProviderRejectsUnresolvableDynamicConnectionOptions(t *testing.T) } } +func TestPluginProviderRejectsConnectionConfigForDeviceAuthorization(t *testing.T) { + _, err := NewPluginProvider(PluginProviderOptions{ + InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID, + Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{ + pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE, + }}, + ConnectionConfigSchema: []*pluginv1.ConfigSchema{{Key: "server"}}, + ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) { + return &fakeWatchSyncPluginClient{}, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "connection config requires API-key authentication") { + t.Fatalf("error = %v", err) + } +} + func TestPluginProviderRefreshReturnsCredentialsAlongsideFault(t *testing.T) { client := &fakeWatchSyncPluginClient{refreshResponse: &pluginv1.WatchSyncCredentialResponse{ Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testRotatedAccessToken, TokenType: testBearerTokenType}, diff --git a/web/src/components/admin/plugins/PluginConfigForm.test.tsx b/web/src/components/admin/plugins/PluginConfigForm.test.tsx index 0ddb85e8..686395af 100644 --- a/web/src/components/admin/plugins/PluginConfigForm.test.tsx +++ b/web/src/components/admin/plugins/PluginConfigForm.test.tsx @@ -36,6 +36,30 @@ const schema: PluginConfigSchema = { }; describe("PluginConfigForm secrets", () => { + it("derives a form when a plugin only supplies JSON Schema", () => { + render( + , + ); + + expect(screen.getByLabelText("Base URL")).toBeInTheDocument(); + expect(screen.getByLabelText("Api Key")).toHaveAttribute("type", "password"); + }); + it("shows redacted saved state and only clears through an explicit action", async () => { const onSave = vi.fn(); render( diff --git a/web/src/components/admin/plugins/PluginConfigForm.tsx b/web/src/components/admin/plugins/PluginConfigForm.tsx index 4dbc1590..82e04604 100644 --- a/web/src/components/admin/plugins/PluginConfigForm.tsx +++ b/web/src/components/admin/plugins/PluginConfigForm.tsx @@ -10,11 +10,14 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction" import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { adminFormForConfigSchema, humanizeConfigKey } from "./configSchemaAdminForm"; import { SchemaForm } from "./SchemaForm"; import { buildSchemaValues } from "./schemaFormUtils"; type PluginConfigValue = Record; +const EMPTY_FIELDS: PluginAdminFormField[] = []; + type Props = { schema: PluginConfigSchema; value?: PluginConfigValue; @@ -29,82 +32,7 @@ type Props = { isTesting?: boolean; }; -type SupportedField = PluginAdminFormField & { - inferredType?: "string" | "number" | "integer" | "boolean"; -}; - -type ParsedObjectSchema = { - supported: boolean; - fields: SupportedField[]; -}; - -function humanizeKey(value: string) { - return value - .split("_") - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema { - try { - const parsed = JSON.parse(schema.json_schema) as { - type?: string; - required?: string[]; - properties?: Record< - string, - { - type?: string; - title?: string; - description?: string; - writeOnly?: boolean; - format?: string; - } - >; - }; - if (parsed.type !== "object" || !parsed.properties) { - return { supported: false, fields: [] }; - } - - const fields = Object.entries(parsed.properties).map(([key, property]) => { - const propertyType = property.type; - if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) { - return null; - } - const isSensitive = property.writeOnly === true || property.format === "password"; - const control = - propertyType === "boolean" - ? "SWITCH" - : propertyType === "number" || propertyType === "integer" - ? "NUMBER" - : isSensitive - ? "PASSWORD" - : "TEXT"; - return { - key, - label: property.title || humanizeKey(key), - description: property.description, - control, - placeholder: "", - required: parsed.required?.includes(key) ?? false, - secret: isSensitive, - multiline: false, - options: [], - rows: 0, - inferredType: propertyType as "string" | "number" | "integer" | "boolean", - } satisfies SupportedField; - }); - - if (fields.some((field) => field == null)) { - return { supported: false, fields: [] }; - } - return { supported: true, fields: fields.filter(Boolean) as SupportedField[] }; - } catch { - return { supported: false, fields: [] }; - } -} - -function defaultValueForField(field: SupportedField): string | boolean { +function defaultValueForField(field: PluginAdminFormField): string | boolean { if (field.default_value !== undefined) { if (typeof field.default_value === "boolean") { return field.default_value; @@ -122,7 +50,10 @@ function defaultValueForField(field: SupportedField): string | boolean { return ""; } -function valueForField(field: SupportedField, configValue?: PluginConfigValue): string | boolean { +function valueForField( + field: PluginAdminFormField, + configValue?: PluginConfigValue, +): string | boolean { const raw = configValue?.[field.key]; if (typeof raw === "boolean") { return raw; @@ -145,19 +76,12 @@ export function PluginConfigForm({ isSaving = false, isTesting = false, }: Props) { - const parsedFallback = useMemo(() => parseJSONSchema(schema), [schema]); - const fields = useMemo(() => { - if (schema.admin_form?.fields?.length) { - return schema.admin_form.fields; - } - return parsedFallback.fields; - }, [parsedFallback.fields, schema.admin_form?.fields]); - - const supported = - fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported); + const inferredDescriptor = useMemo(() => adminFormForConfigSchema(schema), [schema]); + const fields = inferredDescriptor?.fields ?? EMPTY_FIELDS; + const supported = inferredDescriptor != null; const descriptor = useMemo(() => { - const base = schema.admin_form ?? { fields }; + const base = inferredDescriptor ?? { fields }; const configured = new Set(configuredSecrets); return { ...base, @@ -167,7 +91,7 @@ export function PluginConfigForm({ : field, ), }; - }, [configuredSecrets, fields, schema.admin_form]); + }, [configuredSecrets, fields, inferredDescriptor]); const [values, setValues] = useState(() => Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])), @@ -248,7 +172,7 @@ export function PluginConfigForm({ return (
- {field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"} + {field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"} {required ? " (required)" : ""} {!required ? ( diff --git a/web/src/components/admin/plugins/configSchemaAdminForm.ts b/web/src/components/admin/plugins/configSchemaAdminForm.ts new file mode 100644 index 00000000..e569962b --- /dev/null +++ b/web/src/components/admin/plugins/configSchemaAdminForm.ts @@ -0,0 +1,69 @@ +import type { PluginAdminForm, PluginAdminFormField, PluginConfigSchema } from "@/api/types"; + +export function humanizeConfigKey(value: string) { + return value + .split("_") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function adminFormForConfigSchema(schema: PluginConfigSchema): PluginAdminForm | null { + if (schema.admin_form?.fields?.length) return schema.admin_form; + + try { + const parsed = JSON.parse(schema.json_schema) as { + type?: string; + required?: string[]; + properties?: Record< + string, + { + type?: string; + title?: string; + description?: string; + writeOnly?: boolean; + format?: string; + } + >; + }; + if (parsed.type !== "object" || !parsed.properties) return null; + + const fields = Object.entries(parsed.properties).map( + ([key, property]): PluginAdminFormField | null => { + const propertyType = property.type; + if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) { + return null; + } + const secret = property.writeOnly === true || property.format === "password"; + const control = + propertyType === "boolean" + ? "SWITCH" + : propertyType === "number" || propertyType === "integer" + ? "NUMBER" + : secret + ? "PASSWORD" + : "TEXT"; + return { + key, + label: property.title || humanizeConfigKey(key), + description: property.description, + control, + placeholder: "", + required: parsed.required?.includes(key) ?? false, + secret, + multiline: false, + options: [], + rows: 0, + }; + }, + ); + if (fields.some((field) => field == null)) return null; + + return { + ...schema.admin_form, + fields: fields.filter((field): field is PluginAdminFormField => field != null), + }; + } catch { + return null; + } +} diff --git a/web/src/pages/settings/WatchProvidersSettings.tsx b/web/src/pages/settings/WatchProvidersSettings.tsx index 65143c6e..828579fb 100644 --- a/web/src/pages/settings/WatchProvidersSettings.tsx +++ b/web/src/pages/settings/WatchProvidersSettings.tsx @@ -38,7 +38,7 @@ import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProvide import { buildConnectionConfig, connectionSchemasAreValid, - type RenderableConnectionSchema, + renderableConnectionSchemas, } from "./watchProviderConnectionConfig"; function formatRelativeTime(value?: string) { @@ -239,9 +239,7 @@ function APIKeyBlock({ const [connectionConfig, setConnectionConfig] = useState({}); const [configValidity, setConfigValidity] = useState>({}); const trimmed = value.trim(); - const renderableSchemas = configSchemas.filter( - (schema): schema is RenderableConnectionSchema => schema.admin_form != null, - ); + const renderableSchemas = renderableConnectionSchemas(configSchemas); const configValid = connectionSchemasAreValid( renderableSchemas, connectionConfig, diff --git a/web/src/pages/settings/watchProviderConnectionConfig.test.ts b/web/src/pages/settings/watchProviderConnectionConfig.test.ts index 1161adf3..b9f0abd6 100644 --- a/web/src/pages/settings/watchProviderConnectionConfig.test.ts +++ b/web/src/pages/settings/watchProviderConnectionConfig.test.ts @@ -6,6 +6,7 @@ import { activeConnectionSchemas, buildConnectionConfig, connectionSchemasAreValid, + renderableConnectionSchemas, } from "./watchProviderConnectionConfig"; const optionalSchema: PluginConfigSchema = { @@ -53,4 +54,20 @@ describe("watch provider connection config", () => { expect(activeConnectionSchemas([requiredSchema], {})).toEqual([requiredSchema]); expect(connectionSchemasAreValid([requiredSchema], {}, {})).toBe(false); }); + + it("derives a usable form for a required JSON-schema-only block", () => { + const headless = { ...optionalSchema, required: true, admin_form: undefined }; + const schemas = renderableConnectionSchemas([headless]); + expect(schemas).toHaveLength(1); + const renderable = schemas[0]!; + expect(renderable.admin_form.fields).toEqual([ + expect.objectContaining({ + key: "base_url", + label: "Base Url", + control: "TEXT", + required: true, + }), + ]); + expect(connectionSchemasAreValid([renderable], {}, {})).toBe(false); + }); }); diff --git a/web/src/pages/settings/watchProviderConnectionConfig.ts b/web/src/pages/settings/watchProviderConnectionConfig.ts index 9ea3b4fd..b269ea5c 100644 --- a/web/src/pages/settings/watchProviderConnectionConfig.ts +++ b/web/src/pages/settings/watchProviderConnectionConfig.ts @@ -1,4 +1,5 @@ import type { PluginConfigSchema } from "@/api/types"; +import { adminFormForConfigSchema } from "@/components/admin/plugins/configSchemaAdminForm"; import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils"; import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders"; @@ -6,6 +7,15 @@ export type RenderableConnectionSchema = PluginConfigSchema & { admin_form: NonNullable; }; +export function renderableConnectionSchemas( + schemas: PluginConfigSchema[], +): RenderableConnectionSchema[] { + return schemas.flatMap((schema) => { + const adminForm = adminFormForConfigSchema(schema); + return adminForm == null ? [] : [{ ...schema, admin_form: adminForm }]; + }); +} + function hasEnteredValue(value: unknown): boolean { if (value == null) return false; if (typeof value === "string") return value.trim().length > 0;