Connector Widget¶
The connector widget is a self-contained Vue 3 custom element (<conflux-connector>) that an embedding platform (e.g. Rapidmail) drops onto its own pages to let users connect their e-commerce store to Conflux — without ever leaving the embedding platform's UI.
It is distinct from the configuration widget (<conflux-config>): the configuration widget manages sync settings for an already-established connection; the connector widget handles the connection establishment itself.
Why a custom element¶
The widget is built with Vue 3's defineCustomElement and compiled to a single IIFE bundle (connector-widget.js). Embedding it requires one <script> tag and one HTML element — no npm install, no framework requirement, no style leakage in either direction thanks to the shadow DOM boundary.
Two-phase flow¶
A typical embed goes through two phases in sequence.
Phase 1 — Initialisation¶
On mount, if no tenant-id prop is present, the widget immediately calls POST /api/system-connections/connect with system_slug and external_user_id in the request body.
When the system_slug matches a known System record in Conflux (e.g. rapidmail), the endpoint:
- Auto-creates a new
Tenantwith a generated name. - Creates a
SystemConnectionlinked to that tenant withstatus = activeandlast_connected_atstamped immediately — no plugin handshake is needed because the embedding system is Conflux itself. - Issues a never-expiring
SystemConnectionToken(purpose = access) and returns it astokenin the response alongsidetenant_id.
The widget stores both values internally and fires the conflux-initialized event so the host page can persist them. Future page loads should pass tenant-id and token back as props, which skips phase 1 entirely.
sequenceDiagram
participant Widget as <conflux-connector>
participant Conflux
participant HostPage as Host Page (JS)
Widget->>Conflux: POST /api/system-connections/connect<br/>{ system_slug: "rapidmail", external_user_id: "..." }
Conflux->>Conflux: create Tenant + active SystemConnection<br/>issue SystemConnectionToken (purpose=access)
Conflux-->>Widget: 201 { tenant_id, token, connection }
Widget->>HostPage: dispatchEvent("conflux-initialized")<br/>{ tenant_id, token }
Note over HostPage: persist tenant_id + token<br/>for future page loads
Phase 2 — Plugin pairing¶
Once initialised, the widget shows the user platform-specific instructions and a "Generate connection token" button. When clicked, generateToken() requires an access token to already be present: if internalToken is empty (phase 1 failed to return one, or was skipped without token/tenant-id props supplied), it stops immediately and shows a user-facing error instead of firing a request that would only fail server-side with no explanation for the user. Otherwise it calls POST /api/system-connections/connect again, this time with Authorization: Bearer <access-token> (so the server resolves the tenant from the token) and { tenant_id: internalTenantId } in the body.
The endpoint creates a new SystemConnection with status = pending and returns a short-lived connection_token for the user to paste into their e-commerce plugin.
The widget then begins polling GET /api/widget/connections/{connection_id}/status on a backoff cadence (see Widget polling below). When the plugin completes its side of the handshake and Conflux marks the connection active, the widget stops polling, shows a success banner, and fires the conflux-registered event.
Polling treats any status other than active or pending (e.g. error, disconnected, uninstalled, inactive — see App\Enums\SystemConnectionStatus) as a terminal failure: it stops polling and shows an error message in step 3 in place of the waiting indicator, rather than continuing to poll a connection that will never reach active. As a belt-and-braces backstop against a status that never resolves either way, polling also gives up once the session has run for an hour and shows a timeout error.
sequenceDiagram
participant Widget as <conflux-connector>
participant Conflux
participant Plugin as E-commerce Plugin
participant HostPage as Host Page (JS)
Widget->>Conflux: POST /api/system-connections/connect<br/>Authorization: Bearer <access-token>
Conflux->>Conflux: resolve tenant from token<br/>create pending SystemConnection<br/>issue short-lived connection_token
Conflux-->>Widget: 201 { connection_id, connection_token, expires_at }
Widget->>Widget: show token + start polling
loop backing-off delay (3s → 60s)
Widget->>Conflux: GET /api/widget/connections/{id}/status<br/>Authorization: Bearer <access-token>
Conflux-->>Widget: { id, status: "pending" }
end
Note over Plugin: merchant pastes token into plugin
Plugin->>Conflux: POST /api/system-connections/register<br/>connection_token + credentials
Conflux->>Conflux: verify + activate connection
Widget->>Conflux: GET /api/widget/connections/{id}/status
Conflux-->>Widget: { id, status: "active" }
Widget->>Widget: stop polling, show success
Widget->>HostPage: dispatchEvent("conflux-registered")<br/>{ connection_id }
Access tokens vs connection tokens¶
Two different token types are involved.
SystemConnectionToken (purpose = access) |
SystemConnectionToken (purpose = handshake) |
|
|---|---|---|
| Table | system_connection_tokens |
system_connection_tokens |
| Issued when | Phase 1 init (Active direct-system connection) | Phase 2 "Generate token" button |
| Lifetime | Never expires (expires_at is null) |
1 hour |
| Reuse | Reusable — every widget API call presents the same token | Single-use — used_at is stamped on redemption |
| Purpose | Authenticate subsequent widget API calls | Plugin-pairing handshake |
| Returned to | Host page via conflux-initialized |
User (shown in widget UI for copy-paste) |
The access token is returned in the conflux-initialized event detail only once. The host page must persist it (e.g. in its own database keyed to the user). If lost, the user must re-initialise the widget to obtain a new one.
This access token is minted with name = connector-widget, which is also what scopes it: it is the only token in the system handed to a third party's own frontend JS, so EnsureWidgetTokenIsAuthorizedForRoute restricts it to a fixed allow-list of /api/widget/* routes rather than the full set every other access token reaches. See System Connection Tokens — Route-level scope and ADR-0038 for the allow-list and the rationale behind it.
Submitting external credentials¶
A consuming platform that needs Conflux to call back to its own API under credentials dedicated to that connection — rather than whatever it originally registered with — submits them with the access token via POST /api/widget/external-credentials:
WidgetExternalCredentialsController validates the submitted map against the connection's System.auth_fields (the same shape check RegisterSystemConnectionController applies, shared via the AssertsCredentialShape trait), then calls SystemConnectionService::verify() before persisting the result to SystemConnection.credentials. A failed verification returns 422 and leaves any previously stored credentials untouched. The verified result must also match the connection's existing external identity: if external_user_id is already set and the newly verified account differs, the request is rejected with 422 and nothing is persisted — a token minted for one external workspace must not be able to re-point a connection that already represents another. A connection with no external_user_id yet establishes one from this verification, the same as any other activation. The submitted values are never echoed back in the response, and the route is throttled per connection (widget-external-credentials, conflux.rate_limits.widget_external_credentials), since it triggers a live outbound verification call on every request. This route is on the connector-widget token's allow-list (see above), so the host page can call it directly without going through the <conflux-connector> element itself. See the auto-generated API docs for the full request/response shape.
Events¶
Both events use bubbles: true, composed: true so they pierce the shadow DOM boundary and can be caught on window or any ancestor element.
Events are dispatched from the host element itself (this.$el.getRootNode().host), falling back to this.$el when no shadow root host is present. This matters for Shadow DOM consumers that attach listeners directly to the <conflux-connector> element rather than to window.
conflux-initialized¶
window.addEventListener('conflux-initialized', (event) => {
const { tenant_id, token } = event.detail;
// Persist these; pass them as props on subsequent loads.
});
Fires once at the end of phase 1, when the widget has created (or confirmed) a tenant and an active connection to the embedding system. The token value is the never-expiring access token and is returned only this once.
conflux-registered¶
window.addEventListener('conflux-registered', (event) => {
const { connection_id } = event.detail;
// The e-commerce plugin is now connected.
});
Fires when polling detects status = active on the pending connection — i.e. when the e-commerce plugin has completed its side of the pairing handshake.
Widget phases¶
Internally the widget moves through three display phases:
| Phase | Trigger | UI |
|---|---|---|
initializing |
On mount, no tenant-id prop present |
Spinner; error state with retry if the init call fails |
instructing |
Init completed (or tenant-id prop was already set) |
Plugin CTA, generate-token button, token display, paste instructions, "What's next" panel, polling status |
connected |
Only for the direct-system init (phase 1 created an Active connection with no plugin) | Success banner immediately — no phase 2 needed |
The connected phase is only reached via phase 1 when the system_slug maps to a direct system (like rapidmail). For e-commerce plugin connections, the instructing phase persists while polling waits for the plugin.
UX sections in the instructing phase¶
The instructing phase renders several distinct sections.
Intro and plugin CTA. A short description ("You'll need the Positive Connector plugin…") appears above the token area. A "Get the Positive Connector plugin" external link is always rendered below it. When PLUGIN_URLS[connection][system] resolves to a non-null URL the link goes to that URL; otherwise it falls back to href="../" (effectively a no-op). Set a real URL in plugin-urls.js to activate the link for a given combination.
Token area. The "Generate connection token" button triggers phase 2. Once a token is generated, it is displayed in a read-only copy-paste input alongside its expiry time. A "Generate a new token" link allows re-generation.
"Where do I paste this?" disclosure. A labelled section below the token area lists the paste steps for the active connection type. Steps are driven by the PASTE_STEPS configuration object in the source file and rendered as a numbered list with bold navigation labels. A generic fallback is shown when connection does not match a known key.
Success banner. When connected becomes true (polling detects status: active), a green "Plugin connected successfully!" banner is shown within the instructing phase.
"What's next" disclosure. A two-state info block:
| Widget state | Text shown |
|---|---|
Not yet connected (connected === false) |
Muted: "Once connected, we'll sync your data based on the default settings…" |
Connected (connected === true) |
Active: "We're syncing your data based on the default settings…" + settings breadcrumb from SETTINGS_PATH[connection] |
The settings breadcrumb (e.g. WooCommerce → Positive Connector → Sync settings) is driven by the SETTINGS_PATH configuration object and is hidden when the key is absent.
Configuration constants¶
Two constants at the top of ConfluxConnector.vue and one in a companion file are the intended customisation points for new embedding systems or shop platforms:
| Constant | Location | Keyed by | Purpose |
|---|---|---|---|
PLUGIN_URLS |
resources/widget-connector/plugin-urls.js |
connection → system |
Plugin download URL per shop platform and embedding system. Use default as the fallback key. null hides the CTA. Baked in at build time by Vite. |
PASTE_STEPS |
ConfluxConnector.vue |
connection |
Numbered HTML steps for the "Where do I paste this?" section. Falls back to GENERIC_PASTE_STEPS when the key is absent. |
SETTINGS_PATH |
ConfluxConnector.vue |
connection |
Admin breadcrumb string shown in the connected "What's next" state. Hidden when the key is absent. |
PLUGIN_URLS was deliberately separated into its own file so it can be updated without touching the Vue component. The structure inside plugin-urls.js is:
{
woocommerce: {
default: null, // fallback when system prop does not match any key
rapidmail: null, // embedding-system-specific URL
},
prestashop: {
default: null,
rapidmail: null,
},
}
Set any value to a non-null string to enable the "Get the Positive Connector plugin" CTA link for that combination. Rebuild the bundle after editing this file.
Localisation¶
The lang prop selects which language the widget's copy renders in. It is a BCP 47 tag, but only six locales have translations: en, fr, pl, de, es, it (SUPPORTED_LOCALES in resources/widget-connector/i18n.js). Only the primary language subtag is matched — region/script variants like de-DE, de_DE, or en-GB resolve to de/en. Any tag whose primary subtag isn't one of the six — including an unset prop — falls back to en (DEFAULT_LOCALE).
Translation module¶
resources/widget-connector/i18n.js builds its TRANSLATIONS dictionary directly from translations-source.json (see "Source of truth") — every key is a literal sheet row, keyed by locale then by the sheet's own Token value (e.g. step1_title_woocommerce, step2_done_status). There is no hand-maintained overrides layer: nothing in this file is translated ad hoc.
ConfluxConnector.vue renders every piece of UI copy through a local t(key, vars) helper, which delegates to translate(locale, key, vars):
- Normalize
localeto its primary subtag and look it up againstSUPPORTED_LOCALES, falling back toDEFAULT_LOCALEif it isn't one of the six. - Look up
keywithinTRANSLATIONS[locale]. If the row doesn't exist for that key, or the sheet cell for that locale is an empty string, fall back to the key name itself as the rendered text — never to another locale's copy — so a gap is always visibly obvious in the widget rather than silently blank or masked by English. - Interpolate
{placeholder}tokens against thevarsobject.
Parity across locales follows the sheet directly, but a gap always renders as the raw token — there's no cross-locale fallback for an individual missing or empty string.
Source of truth¶
Translation content lives in the team's "Conflux_Widgets_translations_MERGED" Google Sheet, not from a resources/lang/ Laravel structure — the connector widget is a standalone client-side custom element embedded in third-party shop admin panels, not a Laravel Blade page, so Laravel's own localisation stack does not apply here.
The sheet reaches the widget via a small pipeline, not by hand-typing strings into i18n.js:
- Export the sheet to CSV (or fetch it via the Google Drive MCP connector — see the how-to guide).
- Run
docker compose run --rm -T node npm run widget:translations(scripts/generate-widget-translations.mjs), which writesresources/widget-connector/translations-source.json— a plain token → per-locale-value dump, checked into git so sheet changes are visible in code review. i18n.jsimports that JSON directly and uses it as-is.
The {system}/{int_system}/{connector_name} placeholder values (SYSTEM_LABELS, INT_SYSTEM_LABELS, CONNECTOR_NAME_LABELS in i18n.js) are likewise read straight from the generated JSON's system_*/int_system_*/connector_name_* rows, rather than duplicated as separate hardcoded constants — the two drifting apart (a hardcoded 'rapidmail user' vs. the sheet's 'rapidmail') is exactly the bug this consolidation was fixed to prevent.
The configuration widget (<conflux-config>) had exactly this drift: it kept its own hardcoded account-label map that no longer matched the sheet. It now imports INT_SYSTEM_LABELS from this same i18n.js for its one label, rather than maintaining a second copy — it does not otherwise participate in this translation pipeline (no lang prop, no other translated strings).
The connector widget's own pluginName and accountLabel computed properties had the same class of drift risk one level down: when system doesn't match a known key in CONNECTOR_NAME_LABELS/INT_SYSTEM_LABELS, they fall back to those same dictionaries' 'user-platform' entries rather than a separate hardcoded string literal, so the fallback copy tracks the sheet too instead of needing its own update whenever the sheet's default wording changes.
Strings not yet present in the sheet (e.g. widget_copy_cta and a few other short UI labels as of CFX-207) simply render as their own key name until a matching row is added — see the fallback behaviour above. There's no code-side substitute for missing sheet content. See Update Connector Widget Translations for the full workflow.
Inline formatting¶
Some strings need a bolded or emphasised fragment (e.g. a CTA name quoted inside an instruction sentence). Rather than allow full HTML or Markdown, i18n.js allowlists exactly two bare tags — <strong> and <em> — via sanitizeInlineHtml(): the whole template is HTML-escaped first, then only the literal, attribute-free sequences <strong>, </strong>, <em>, </em> are re-opened back into real tags. Anything else a translator types — another tag, or <strong class="..."> — stays inert escaped text. A translator wanting bold/emphasis in a string types the tag directly into the sheet cell; there's no code-side wrapping mechanism.
Values interpolated into a template via {placeholder} are HTML-escaped individually. ConfluxConnector.vue renders every translated string with v-html, relying on this sanitisation for safety.
Dev-only language switcher¶
The dev preview page (resources/views/examples/connection-widget.blade.php, served at /examples/connection-widget) has its own "Widget language" <select> in the demo's Step 1 setup form. Choosing an option sets the lang attribute on the mounted <conflux-connector> element for that preview session. This select is part of the throwaway demo harness only — it is not rendered by the widget itself and has no equivalent in a real embed.
Widget polling¶
Polling uses GET /api/widget/connections/{connection}/status inside the widget-auth middleware group. The route is protected by AuthenticateWidgetToken, which resolves the SystemConnection from the Bearer access token and then checks that the requested connection belongs to the same tenant before returning its status. The widget stops polling as soon as it receives status: active, receives any other non-pending status, exceeds its 1-hour session cap (MAX_POLL_DURATION_MS), or the component is unmounted.
The poll delay backs off over wall-clock time rather than firing on a flat interval: nextPollDelayMs() doubles the delay every fixed 5-minute tier since the session started (3s, 6s, 12s, 24s, 48s, capped at 60s via POLL_TIER_MS/POLL_BASE_DELAY_MS/POLL_MAX_DELAY_MS), so a user still mid plugin-setup gets fast feedback while a tab left open and abandoned is checked less often. Growth is tied to elapsed time rather than to the previous delay so a tier doesn't end almost as soon as it starts once the delay itself gets large. Each poll session tracks a pollGeneration counter bumped by stopPolling(); an in-flight request that resolves after it's been superseded (e.g. "regenerate token" clicked, or the widget unmounted mid-fetch) checks this counter before touching component state, so it can't clobber a newer session.
Separately, visibilitychange suspends polling while the tab is hidden: the pending timer is cleared (not merely left to fire in the background) and only re-armed once the tab is visible again, at which point the widget polls immediately rather than waiting out whatever delay was dropped — useful since the user may be mid-pairing in another tab (e.g. pasting the token into their shop admin) and switches back to check. The elapsed-time clock behind the backoff tiers keeps running regardless of visibility, so this only skips polls nobody's watching — it doesn't reset or extend the backoff schedule.
Font loading¶
The widget self-hosts four Inter woff2 weights (300/400/500/700) rather than using Google or emailsys, whose web delivery strips the OpenType zero stylistic set (slashed zero) the subset files in resources/fonts/ keep. It registers them through 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 not part of the bundle. Vite emits them as separate content-hashed files to public/widget-assets/ (for example inter-400-<hash>.woff2), and the bundle resolves their URLs at runtime against its own <script> URL, so they always load from the Conflux origin. Both widgets reference the same files, so a browser that has loaded one widget already has the fonts for the other, and the files stay cached across widget releases. nginx serves /widget-assets/ with Access-Control-Allow-Origin: * (fonts are fetched in CORS mode, and the widget runs on third-party origins) and Cache-Control: public, max-age=31536000, immutable. The widget renders with its fallback font stack first and switches to Inter once a weight has loaded; if loading fails, the fallback stays. See ADR-0040.
The imports, the weight map and the registration are shared code: resources/widget-shared/registerInterFont.js exports registerInterFont(fontFamily), called from mounted() with this widget's own CONNECTOR_FONT_FAMILY constant. The configuration widget calls the same helper with its own family name — the two widgets register under different namespaced family names (not plain "Inter") because document.fonts is a single document-wide registry even for faces scoped to a shadow root, so a shared name could collide with a same-named face the host page or the other widget loads itself.
Build¶
This runs vite build --config vite.connector-widget.config.js and writes the IIFE bundle to public/connector-widget.js and the Inter fonts to public/widget-assets/. Run this after editing plugin-urls.js or any other file under resources/widget-connector/ — Vite bakes those values in at build time. The node service's npm run dev keeps a vite build --watch of this widget running with the Vite config it read at start, so after editing vite.connector-widget.config.js restart it with docker compose restart node, otherwise it rebuilds with the old config.
Source files¶
| File | Purpose |
|---|---|
resources/widget-connector/ConfluxConnector.vue |
Vue 3 custom element — template, phase logic, polling, event dispatch, scoped styles |
resources/widget-connector/i18n.js |
Translation dictionary (en/fr/pl/de/es/it), built directly from translations-source.json — translate() lookup + interpolation, inline <strong>/<em> sanitisation |
resources/widget-connector/translations-source.json |
Generated — sheet export as token → per-locale-value JSON. Do not hand-edit; regenerate via npm run widget:translations |
scripts/generate-widget-translations.mjs |
CSV → translations-source.json generator |
resources/widget-connector/plugin-urls.js |
Plugin download URL map (connection → system → URL); edit this to activate the CTA link |
resources/widget-connector/main.js |
Entry point — calls defineCustomElement and registers <conflux-connector> |
resources/widget-shared/registerInterFont.js |
Shared Inter font imports, weight map and FontFace-API registration, used by both this widget and the configuration widget |
vite.connector-widget.config.js |
Vite IIFE build config; outputs public/connector-widget.js and the fonts under public/widget-assets/ |
app/Http/Controllers/Api/ConnectSystemConnectionController.php |
POST /api/system-connections/connect — both init and generate-token paths |
app/Http/Controllers/Api/Widget/ConnectionStatusController.php |
GET /api/widget/connections/{connection}/status |
app/Http/Controllers/Api/Widget/WidgetExternalCredentialsController.php |
POST /api/widget/external-credentials — validates and verifies externally supplied credentials before persisting |
app/Http/Controllers/Concerns/AssertsCredentialShape.php |
Shared credential-shape validation against System.auth_fields, used by both this controller and RegisterSystemConnectionController |
app/Http/Middleware/AuthenticateWidgetToken.php |
Bearer token resolution for widget routes; binds widget_connection and widget_connection_token |
app/Http/Middleware/EnsureWidgetTokenIsAuthorizedForRoute.php |
Restricts the connector-widget token to an allow-listed route subset |
resources/views/examples/connection-widget.blade.php |
Interactive dev playground (two-step setup form + event log), served at /examples/connection-widget |
See also¶
- Embed the Connector Widget — step-by-step embedding guide with full examples
- Connection Widget — the configuration widget (
<conflux-config>) used after a connection is established - Connection Flow — the plugin registration handshake that phase 2 completes
- System Connection Tokens — token hashing, storage, and route-level scope
- ADR-0038 — why the connector-widget token's access is scoped via an allow-list middleware