Skip to content

Connection Widget

The connection widget is a self-contained Vue 3 custom element (<conflux-config>) that merchants embed on their own site to configure a sync connection and monitor recent sync activity. It communicates with Conflux over a token-authenticated API; Conflux renders nothing server-side.

Why a custom element

Embedding a widget on an arbitrary merchant site means working inside markup Conflux does not control: different CSS frameworks, CSPs, and frontend stacks. A native web component built with defineCustomElement encapsulates its own shadow DOM styles and has no runtime dependency on any framework the host page might (or might not) provide. The merchant drops in one <script> tag and one HTML element — no npm install, no bundler config.

Two initiation flows

A connection can be established from either side.

Flow A — plugin-initiated. The merchant installs the e-commerce plugin (WooCommerce, PrestaShop, etc.) on their own site. The plugin calls POST /api/system-connections/register with its credentials, Conflux verifies them, and — once verification succeeds — issues a widget_token back to the plugin. The plugin stores this token and embeds the <conflux-config> element with it so the merchant can complete the configuration without ever visiting the Conflux admin panel.

Flow B — admin-initiated. A Conflux administrator drives the handshake manually from the System Connections list page's "Connect" header action (ConnectSystemConnectionAction), for a tenant they can reach: Initiate creates a Pending connection and generates a short-lived handshake token to hand to the plugin out-of-band, or Finalize activates a Pending connection given a token the plugin already produced. This is a development/support tool for driving the API-level handshake, not a way to render the widget itself — the admin panel never embeds <conflux-config>. The widget itself is only ever configured by the merchant, on their own site, with a token scoped to their own connection.

In both flows the widget itself is identical where it appears at all — Flow A is the only one that places it on a page.

Token authentication

Widget routes (/api/widget/*) are not behind Sanctum. They use three chained middlewares instead of one: AuthenticateWidgetToken resolves the token, EnsureWidgetTokenIsAuthorizedForRoute restricts the connector-widget token to an allow-listed subset of those routes, and EnsureWidgetConnectionIsActive gates on the connection's status. The latter two are not applied to POST /system-connections/disconnect and POST /system-connections/uninstall, which sit behind AuthenticateWidgetToken alone — see Connection Flow — Why the Widget's Lifecycle Endpoints Are Looser Than Everything Else.

AuthenticateWidgetToken:

  1. Reads the Authorization: Bearer <plaintext-token> header.
  2. Hashes the plaintext with SHA-256 and queries system_connection_tokens.token, filtered to purpose = access.
  3. Resolves the matching SystemConnection and binds it onto the request as widget_connection, and the SystemConnectionToken record itself as widget_connection_token.
  4. Updates last_used_at on every successful request.

EnsureWidgetTokenIsAuthorizedForRoute, applied to every /api/widget/* route:

  1. Reads name off widget_connection_token and, only when it is connector-widget, aborts with 403 unless the matched route is on a fixed allow-list. Every other name (plugin-widget, or none) passes through unchecked. See System Connection Tokens — Route-level scope and ADR-0038.

EnsureWidgetConnectionIsActive, applied only to the five /api/widget/* routes:

  1. Aborts with 403 unless the connection isHealthy() (status === Active).

Tokens are long-lived and hash-stored, following the same one-way storage pattern as connection tokens — see System Connection Tokens. The name column records the token's origin context: plugin-widget for tokens issued during plugin-initiated registration, and connector-widget for tokens issued by ConnectSystemConnectionController and IssueConnectionAccessTokenController. admin-preview is a third value, no longer issued by the admin panel; it may still appear on older connections' tokens. Only the connector-widget name is read for authorization purposes — see step 5 above.

The status check in step 6 uses the same broad isHealthy() definition as EnsureSystemConnectionIsActive on the main API, blocking Pending, Error, Inactive, Disconnected, and Uninstalled alike — not just an explicit deactivation. This is load-bearing rather than defence in depth for Disconnected: the whole point of the 30-day grace period is that credentials and the widget token stay valid so a reconnect needs no new setup, so the token alone cannot signal whether the connection is still live. For Inactive and Uninstalled the check mostly protects the narrow window before token revocation takes effect — SystemConnectionService::revokeAllTokens() hard-deletes the token outright on both (see System Connections — Deactivation and Uninstall). See Connection Flow — Deactivation and Reconnection for the fuller rationale.

Two-card layout

The widget renders two side-by-side cards inside a single shadow-DOM host element. On narrow viewports (below 640 px) the cards stack vertically.

Connection Settings card (left) — the schema-driven configuration form described in Schema-to-form lifecycle below. This card has existed since the widget was first introduced.

Synchronisation History card (right) — added in CFX-135. On mount the widget calls GET /api/widget/sync-batches and displays the five most recent SyncBatch records for the connection as a scrollable table with the following columns:

Column Content
Scope Capability label — Products, Customers, Orders, or — if unset
Status Badge: blue (in_progress), green (completed), red (failed)
Inserted Count of newly created records (green)
Updated Count of modified records (blue)
Failed Count of records that could not be processed (red)
Date started_at formatted as a human-readable local datetime

If no batches exist yet the card shows "No synchronisations recorded yet." Both cards fetch their data in parallel on mount.

Schema-to-form lifecycle

The widget drives its form entirely from the schema returned by the API — it contains no hard-coded field definitions.

Fetch (GET /api/widget/configuration)

The controller resolves the SystemConnection's associated System, then resolves the plugin via PluginRegistry. If the plugin implements HasConnectionConfiguration, it calls connectionConfigurationSchema()->toArray() and returns:

{
    "schema": {
        "fields": [
            { "type": "select",      "name": "direction",   "label": "Sync direction", "required": true, "options": [...] },
            { "type": "multiselect", "name": "data_types",  "label": "Data to sync",   "required": true, "options": [...] },
            { "type": "radio",       "name": "frequency",   "label": "Sync frequency", "required": true, "options": [...] }
        ]
    },
    "configuration": {
        "direction": "inbound",
        "data_types": ["customers", "orders"],
        "frequency": "daily"
    }
}

The widget iterates schema.fields and renders the appropriate input for each type. Current values come from configuration and pre-populate the form.

If the plugin does not implement HasConnectionConfiguration, schema is null and the widget shows "No configuration available for this connection." — this is a valid state, not an error.

Save (PATCH /api/widget/configuration)

The widget sends { "configuration": { "field_name": "value", ... } }. The controller writes the submitted values into system_connections.configuration (a JSON column cast to array) and returns the persisted values.

The same request then queues App\Jobs\GenerateDefaultSyncRulesJob to re-derive the tenant's default SyncRules, provided the connection is active — so a shop owner who switches a data type back on starts syncing it once the job runs, without waiting for a re-activation. Generation is idempotent, so the job is queued on every save rather than only when data_types changed. The response is not held up for it, and a failure is not swallowed: it propagates out of the job so the queue retries it, landing in failed_jobs if retries are exhausted, rather than being lost silently. See Sync Rules — Rules and the opt-in are two gates.

Data type eligibility

The data_types field's options are not simply whatever the shop's own driver schema lists — WidgetConfigurationController::resolveSchema() narrows them, via a private restrictDataTypes(), to the domains at least one of the connection's eligibleCounterparts() can structurally accept. A domain is kept only if SyncRule::isDirectionFlowValid() (ADR-0005) passes between the connection's System and at least one counterpart's System for that domain's SystemCapability (via SyncDataType::toCapability()). If the connection has no eligibleCounterparts() yet — a fresh, unpaired connection — the schema is left unrestricted, since there is nothing to narrow against.

This replaces an earlier fix (CFX-231) that hardcoded opt.value !== 'orders' in the widget's own visibleOptions(), because Rapidmail — the only paired system at the time — cannot receive orders. That hid the symptom for one specific pairing rather than the general rule: a shop paired only with Rapidmail should see only contacts, but a shop paired with a full-capability system (e.g. User Platform) should see all three, and the exclusion has to be computed per-pairing, not hardcoded per-value. visibleOptions() is now a plain passthrough of field.options, since the backend already returns only eligible options.

The same narrowing also drives stale-value cleanup, generalised across every option-driven field rather than hardcoded to data_types: fetchConfiguration() loops over each schema.fields entry that has an options array and drops any stored value no longer present in that field's freshly-returned options — array-valued fields (multiselect, e.g. data_types) are filtered in place, scalar fields (select/radio) are reset to '' if their stored value fell out of the allowed set. This way a saved selection that predates a pairing change (or a driver change) is not silently kept in configuration after it becomes ineligible, on any field the backend narrows this way, not just data_types.

Default configuration

When a SystemConnection is first created, SystemConnectionObserver::saving() checks whether the resolved plugin implements HasConnectionConfiguration. If it does, it writes defaultConnectionConfiguration() into configuration before the record is inserted. The widget therefore always opens with sensible values pre-selected rather than an empty form.

Account label

The header ("{accountLabel} – Shop Connector") and a few hints in the Connection Settings card name the paired account by a human-readable label rather than its system slug (e.g. rapidmail, not user-platform). accountLabel looks this up from INT_SYSTEM_LABELS, imported from resources/widget-connector/i18n.js — the same translation-sheet-sourced dictionary the connector widget uses for its {int_system} placeholder, keyed by the sheet's int_system_* rows. system comes from the GET /api/widget/configuration response; INT_SYSTEM_LABELS['user-platform'] is the fallback when it doesn't match a known key.

This widget used to keep its own hardcoded label map instead of reading INT_SYSTEM_LABELS, which let the two drift apart ('rapidmail user' here vs. the sheet's 'rapidmail') — the same class of bug the connector widget's i18n consolidation was meant to prevent. Sharing the source fixes it for this one label, but does not extend translation to the rest of the widget: every other string in ConfluxConfig.vue remains hardcoded English, and only the .en value of INT_SYSTEM_LABELS is ever read here — the widget has no lang prop or locale-switching of its own.

Font loading

Like the connector widget, this widget self-hosts four Inter woff2 weights and registers them against document.fonts via the FontFace API rather than a CSS @font-face rule, because that rule isn't reliably matched when the host page inserts the widget into a still-display:none container and unhides it synchronously afterwards. The fonts are separate content-hashed files under public/widget-assets/, shared with the connector widget and loaded from the Conflux origin; the connector widget's section describes how they are built and served. Registration is shared code — resources/widget-shared/registerInterFont.js, called from mounted() with this widget's own CONFIG_FONT_FAMILY constant. The family name is namespaced (not plain "Inter") because document.fonts is a single document-wide registry, so a shared name could collide with a same-named face the host page or the connector widget loads itself.

Source files

File Purpose
resources/widget/ConfluxConfig.vue Vue 3 custom element — two-card template, fetch/save logic, sync log rendering, scoped styles
resources/widget/main.js Entry point; registers <conflux-config> via customElements.define
resources/widget-shared/registerInterFont.js Shared Inter font imports, weight map and FontFace-API registration, used by both this widget and the connector widget
vite.widget.config.js Vite build config; outputs public/widget.js and the fonts under public/widget-assets/
app/Http/Controllers/Api/Widget/WidgetConfigurationController.php Handles GET and PATCH /api/widget/configuration; restrictDataTypes() narrows data_types options to eligible counterparts
app/Http/Controllers/Api/Widget/SyncBatchesController.php Handles GET /api/widget/sync-batches — returns the five most recent SyncBatch records
app/Http/Middleware/AuthenticateWidgetToken.php Token hashing and connection binding; binds widget_connection and widget_connection_token
app/Http/Middleware/EnsureWidgetTokenIsAuthorizedForRoute.php Restricts a connector-widget token to an allow-listed route subset
routes/api.php Route group under /api/widget/ chaining AuthenticateWidgetToken, EnsureWidgetTokenIsAuthorizedForRoute, and EnsureWidgetConnectionIsActive
app/Filament/Resources/SystemConnections/Actions/ConnectSystemConnectionAction.php Admin panel's manual initiate/finalize handshake action (list page); does not render the widget

See also