A "Connect your X account" button that runs an OAuth authorization in
a popup window — the Google-login-style pattern for third-party
integrations (distinct from OAuthProvider, which handles sign-in
to the app itself).
Flow contract:
- The button opens
hrefin a named popup (the app's OAuth start route, which redirects to the provider's consent page). - The provider redirects back to the app's callback route inside the popup; the callback completes the exchange server-side.
- The callback page refreshes the opener and closes itself — see "Callback page" below.
Progressive enhancement
This renders a real <a href>, and the popup is opened by the
PopupLink JS hook rather than an inline onclick (the kit
removed inline handlers everywhere for CSP). The click is intercepted
only after window.open actually returns a window, so a blocked
popup, JS being off, or a modifier-click all fall back to ordinary
navigation and the flow still completes full-page. The previous
inline version ended every click with return false, which cancelled
navigation even when the popup never opened — the button did nothing.
Host wiring: the hook ships in core's phoenix_kit.js, which hosts
already load and spread into their LiveSocket (mix phoenix_kit.install
wires this). No per-app JS.
Callback page
The popup hands control back to the opener. On the callback page:
<script>
if (window.opener && !window.opener.closed) {
window.opener.location.reload();
}
window.close();
</script>The popup is deliberately not rel="noopener": window.opener is
exactly what the callback needs in order to refresh the page behind it.
Be clear-eyed about what that costs. The popup navigates on to the
provider's consent page, and window.opener survives navigation — so
the provider's origin holds a live (cross-origin) reference to your
window. The same-origin policy limits it to opener.location = …,
postMessage, close() and focus(), but the first of those is
reverse tabnabbing: a compromised or open-redirect-chained provider
page can navigate the tab behind the popup.
href is still required to be a local path — that stops the reference
being handed straight to an arbitrary origin, and keeps the flow
starting on a route you control — but it is not what makes the popup
safe once the provider takes over.
Note that Cross-Origin-Opener-Policy: same-origin-allow-popups does
NOT help here: that value exists precisely to keep the opener
relationship for popups, which is the relationship in question. If the
provider is inside your threat model, the only real fix is to stop
relying on window.opener at all — open with noopener and have the
callback route broadcast on PubSub so the opener LiveView updates
itself. That is the Phoenix-native version of this flow and needs no
opener reference or location.reload().
Origin: extracted from NordSwitch's Shelly account connect flow; intended for any Integrations-system OAuth (Google, Stripe, …).
Summary
Functions
Renders the connect-account popup button.
Functions
Renders the connect-account popup button.
Examples
<.connect_account_button href="/shelly/oauth/start">
Connect Shelly account
</.connect_account_button>
<.connect_account_button
href={~p"/integrations/google/start"}
class="btn btn-outline btn-sm"
window_name="google-connect"
>
Connect Google
</.connect_account_button>Attributes
href(:string) (required) - The app's OAuth start route. Must be a local path — the popup keeps itswindow.opener, so it is never pointed at a third-party origin.window_name(:string) - Popup window name. Defaults to one derived fromhref, so two buttons on a page (Connect Google / Connect Shelly) get distinct windows AND distinct DOM ids without configuration. Sharing a name reuses the same window. Defaults tonil.window_width(:integer) - Defaults to480.window_height(:integer) - Defaults to680.class(:any) - REPLACES the default styling rather than merging with it, so you can swapbtn-primaryforbtn-outlinewithout a specificity fight. Pass the full class list you want. Defaults to"btn btn-primary btn-sm".id(:string) - DOM id. A phx-hook element MUST have one, so it defaults to the window name — pass an explicit id when two buttons share a window name. Defaults tonil.- Global attributes are accepted. Extra attributes for the anchor (aria-, data-, …).
Slots
inner_block(required)