Packages
phoenix_kit
1.7.133
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/live/settings/integration_form.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
socket={@socket}
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={"#{@project_title} - #{@page_title}"}
current_path={@current_path}
project_title={@project_title}
current_locale={@current_locale}
>
<div class="container mx-auto px-4 py-6">
<%!-- Page header — title only. Provider name + description + status
live in the provider info card below; repeating the name as a
subtitle here just doubled it visually. The picker step gets
a subtitle since there's no provider context yet. --%>
<.admin_page_header
back={PhoenixKit.Utils.Routes.path("/admin/settings/integrations")}
title={@page_title}
subtitle={if @provider == nil, do: gettext("Choose a service to connect")}
/>
<%!-- Flash messages --%>
<div :if={@success} class="alert alert-success mb-4" phx-click="dismiss">
<span>{@success}</span>
</div>
<div :if={@error} class="alert alert-error mb-4" phx-click="dismiss">
<span>{@error}</span>
</div>
<%!-- Step 1: Provider picker (new mode, no provider selected yet) --%>
<div :if={@live_action == :new && @selected_provider == nil} class="max-w-4xl mx-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
:for={provider <- @providers}
phx-click="select_provider"
phx-value-provider={provider.key}
class="card bg-base-100 shadow-sm border border-base-300 hover:border-primary transition-colors cursor-pointer"
>
<div class="card-body p-4 flex-row items-center gap-3">
<span class="w-10 h-10 flex items-center justify-center bg-base-200 rounded-lg">
<.icon name={provider.icon} class="w-5 h-5" />
</span>
<div class="text-left">
<div class="font-semibold">{provider.name}</div>
<div class="text-xs text-base-content/60">{provider.description}</div>
</div>
</div>
</button>
</div>
</div>
<%!-- Step 2: Setup form (provider selected in new mode, or edit mode) --%>
<div :if={@provider != nil} class="space-y-6 max-w-4xl mx-auto">
<%!-- Back to provider picker (new mode only) --%>
<button
:if={@live_action == :new && @name == nil}
phx-click="back_to_providers"
class="btn btn-ghost btn-sm -mt-2"
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
{gettext("Choose a different service")}
</button>
<%!-- Provider info header with status + validation context.
Compact single-row layout: title + badge + connected-account
on one line, then a status footer with error / timestamps
inline. Previously the alert-error wrapper was visually
huge compared to the small "Error" badge — collapsed to
inline red text on the same line as the timestamps so the
status info reads as one cohesive block. Provider
description was also removed; once you've picked a
provider you know what it is. --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="flex items-center gap-3">
<span class="w-10 h-10 flex items-center justify-center bg-base-200 rounded-lg shrink-0">
<.icon name={@provider.icon} class="w-5 h-5" />
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-lg font-semibold">{@provider.name}</h2>
<%!--
No badge until the integration has actually been saved
(@name set). Showing "Not connected" on a fresh /new
flow misleads — nothing has been attempted yet, there's
nothing to be disconnected from.
--%>
<%= cond do %>
<% @name == nil -> %>
<% @data["status"] == "connected" -> %>
<span class="badge badge-success badge-sm gap-1">
<.icon name="hero-check-circle" class="w-3 h-3" />
{gettext("Connected")}
</span>
<% @data["status"] == "configured" -> %>
<span class="badge badge-warning badge-sm">{gettext("Not tested")}</span>
<% @data["status"] == "error" -> %>
<span class="badge badge-error badge-sm">{gettext("Error")}</span>
<% true -> %>
<span class="badge badge-ghost badge-sm">{gettext("Not connected")}</span>
<% end %>
<span
:if={@data["external_account_id"]}
class="text-sm text-base-content/60 truncate"
>
· {@data["external_account_id"]}
</span>
</div>
<%!-- Status footer: error message + timestamps on one
line. Only renders when there's something to show
(post-save state with validation context). --%>
<div
:if={
@name != nil &&
(@data["validation_status"] || @data["last_validated_at"] ||
@data["connected_at"])
}
class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs items-center"
>
<span
:if={@data["status"] == "error" && @data["validation_status"]}
class="text-error inline-flex items-center gap-1"
>
<.icon name="hero-exclamation-triangle" class="w-3 h-3" />
{@data["validation_status"]}
</span>
<span
:if={format_relative(@data["last_validated_at"])}
class="text-base-content/50 inline-flex items-center gap-1"
>
<.icon name="hero-signal" class="w-3 h-3" />
{gettext("Last tested")} {format_relative(@data["last_validated_at"])}
</span>
<span
:if={
format_relative(@data["connected_at"]) &&
@data["connected_at"] != @data["last_validated_at"]
}
class="text-base-content/50 inline-flex items-center gap-1"
>
<.icon name="hero-link" class="w-3 h-3" />
{gettext("Connected")} {format_relative(@data["connected_at"])}
</span>
</div>
</div>
</div>
</div>
</div>
<% credentials_saved = @name != nil && has_setup_credentials?(@data, @provider) %>
<%!-- ONE form, ONE card. Connection Name + provider fields +
action buttons all inside the same card body. Backend
dispatches via `save_form`:
- `@name == nil` → create_connection (creates row + saves
creds in one shot)
- `@name` set + posted name differs → rename_connection,
then save_setup
- name unchanged → just save_setup
Test Connection lives next to Save inside the same card
(secondary action, `type="button"` so it doesn't trigger
submit). OAuth's Connect Account is a separate card below
— different action shape (auth flow, not a save). --%>
<form phx-submit="save_form" autocomplete="off">
<%!-- `py-4` on the card body matches the provider-info card
above. Default `card-body` has 32px vertical padding —
fine for OAuth (the first child is a "Step 1" header
that anchors the top), but visually empty for non-OAuth
providers where the first child is a plain field
label. Tightening to `py-4` closes the gap between the
provider title and the first form input. --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 space-y-4">
<%!-- OAuth-only Step 1 header. The two-card OAuth flow
has Step 1 (this card: API credentials) and Step 2
(the separate card below: Connect Account). API key /
bot token providers have no Step 2 — no badge. --%>
<div :if={@provider.auth_type == :oauth2} class="space-y-1">
<div class="flex items-center gap-2">
<span class={"badge badge-sm #{if credentials_saved, do: "badge-success", else: "badge-ghost"}"}>
{gettext("Step 1")}
</span>
<h3 class="card-title text-base">{gettext("API Credentials")}</h3>
</div>
<p class="text-sm text-base-content/60">
{gettext("Enter your API credentials from the developer console.")}
</p>
</div>
<%!-- Connection name — always present, editable in both
modes. New mode: empty placeholder; edit mode:
current name pre-filled. Backend's `save_form`
detects rename via params["name"] vs @name. --%>
<div class="form-control">
<label class="label" for="field-name">
<span class="label-text">{gettext("Connection Name")}</span>
</label>
<input
type="text"
id="field-name"
name="name"
value={@name || @new_name}
class="input input-bordered w-full"
placeholder={gettext("e.g. Main, Personal, My Company Drive")}
required
/>
</div>
<%!-- Provider-specific fields. Even credential-shaped
fields render as `type="text"` — they're API keys /
OAuth secrets / bot tokens, not site logins.
`type="password"` would trigger every browser's
password-save heuristic (Firefox ignores
`autocomplete="off"` on password fields by Mozilla
policy) and offer to vault these as if they were
user credentials. The `type="text"` choice avoids
that whole class of UX noise; if shoulder-surfing
ever becomes a concern in practice we can revisit
with CSS-based masking. --%>
<div :for={field <- @provider.setup_fields} class="form-control">
<label class="label" for={"field-#{field.key}"}>
<span class="label-text">
{field.label}
<span :if={field.required} class="text-error">*</span>
</span>
</label>
<%!-- Value resolution order:
1. `@form_values` — user-typed values captured
during a dry-run test (`/new` flow). Without
this, a failed test would re-render with empty
inputs and look like the form ate the user's
input.
2. `@data` — the saved credential, edit mode.
3. `""` — fresh /new, untouched. --%>
<input
type="text"
name={field.key}
id={"field-#{field.key}"}
value={Map.get(@form_values, field.key) || @data[field.key] || ""}
class="input input-bordered w-full"
placeholder={field.placeholder || ""}
required={field.required}
autocomplete="off"
/>
<label :if={field.help} class="label">
<span class="label-text-alt text-base-content/50">{field.help}</span>
</label>
</div>
<%!-- Action row at the bottom of the same card body.
Both buttons submit the SAME form (`save_form`),
which always saves the inputted values then
auto-tests via `maybe_auto_test/2`. That means
"Test Connection" verifies what's currently in the
fields, not whatever was saved last time — typing a
new api_key and clicking Test exercises the new
key, not the stale one.
For password fields the user didn't touch, the
input is empty in the DOM; `extract_setup_attrs/2`
strips empties so the existing saved credential is
preserved across the no-op save, and the auto-test
re-verifies it. --%>
<div class="flex flex-wrap gap-2 items-center pt-2">
<button
type="submit"
class="btn btn-primary"
phx-disable-with={gettext("Saving…")}
>
{if @name, do: gettext("Save Changes"), else: gettext("Create Connection")}
</button>
<%!-- Test Connection — non-OAuth providers only. Two
modes:
- Saved row (`@name != nil`): submits the form
so the inputted values get saved + auto-tested
via `apply_save_outcome/2` (see backend).
- /new flow (`@name == nil`): submits with
`_intent="test"` which `save_form` routes to
`test_credentials_dry_run/2` — probes the
provider with the in-memory values, no
persistence. Lets the operator verify the key
works before committing to a connection row.
`formnovalidate` bypasses HTML5 required-field
validation when this specific button submits.
The Connection Name input is `required` (the
form needs it for `Save Changes` / `Create
Connection`), but a Test doesn't need a name —
the operator's intent is "does this api_key
work?", and forcing them to invent a name first
is needless friction. --%>
<button
:if={@provider.auth_type != :oauth2}
type="submit"
name="_intent"
value="test"
formnovalidate
class={"btn btn-outline #{if @testing, do: "loading"}"}
disabled={@testing}
phx-disable-with={gettext("Testing…")}
>
<.icon :if={!@testing} name="hero-signal" class="w-4 h-4" />
{if @testing, do: gettext("Testing..."), else: gettext("Test Connection")}
</button>
</div>
</div>
</div>
</form>
<%!-- Step 2: Connect Account (OAuth providers only) --%>
<div :if={@provider.auth_type == :oauth2 && @name != nil} class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center gap-2">
<span class={"badge badge-sm #{if @data["status"] == "connected", do: "badge-success", else: "badge-ghost"}"}>
{gettext("Step 2")}
</span>
<h3 class="card-title text-base">{gettext("Connect Account")}</h3>
</div>
<% meta = @data["metadata"] || %{} %>
<%= if @data["status"] == "connected" do %>
<div class="flex items-center gap-3 mt-3">
<%!-- Avatar --%>
<%= if meta["picture"] do %>
<img
src={meta["picture"]}
class="w-10 h-10 rounded-full"
referrerpolicy="no-referrer"
/>
<% else %>
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center">
<.icon name="hero-user" class="w-5 h-5 text-base-content/40" />
</div>
<% end %>
<div>
<div class="flex items-center gap-2">
<span :if={meta["name"]} class="font-medium">{meta["name"]}</span>
<div class="badge badge-success badge-sm gap-1">
<.icon name="hero-check-circle" class="w-3 h-3" />
{gettext("Connected")}
</div>
</div>
<span :if={@data["external_account_id"]} class="text-sm text-base-content/60">
{@data["external_account_id"]}
</span>
<span :if={@data["connected_at"]} class="text-xs text-base-content/40 block">
{gettext("Connected on")} {format_date(@data["connected_at"])}
</span>
</div>
</div>
<% else %>
<p class="text-sm text-base-content/60 mt-1">
{gettext(
"Authorize access to your %{provider} account. This will open a sign-in page where you choose which account to connect.",
provider: @provider.name
)}
</p>
<%= if credentials_saved do %>
<button
type="button"
phx-click="connect_oauth"
class="btn btn-accent btn-sm mt-3"
phx-disable-with={gettext("Redirecting…")}
>
<.icon name="hero-link" class="w-4 h-4" />
{gettext("Connect %{provider} Account", provider: @provider.name)}
</button>
<% else %>
<p class="text-sm text-base-content/40 mt-2">
{gettext("Complete Step 1 first to enable account connection.")}
</p>
<% end %>
<% end %>
</div>
</div>
<%!-- Setup instructions (from provider definition).
In edit mode the operator has already done this once —
collapse by default to save vertical space. In /new mode
keep it open so the operator following the steps doesn't
have to expand it first. --%>
<details
:if={Map.get(@provider, :instructions, []) != []}
class="card bg-base-200/50"
open={@name == nil}
>
<summary class="card-body cursor-pointer flex-row items-center gap-2 select-none">
<.icon name="hero-book-open" class="w-4 h-4" />
<h3 class="font-semibold text-base-content text-base">
{gettext("Setup Instructions")}
</h3>
<.icon name="hero-chevron-down" class="w-4 h-4 ml-auto text-base-content/40" />
</summary>
<div class="card-body pt-0 text-sm text-base-content/70 space-y-4">
<div :for={{section, idx} <- Enum.with_index(Map.get(@provider, :instructions, []))}>
<h4 class="font-semibold text-base-content">
{idx + 1}. {section.title}
</h4>
<p
:if={Map.get(section, :note)}
class="text-xs text-base-content/50 mt-1 ml-2 mb-2"
>
<span class="[&>strong]:font-semibold [&>strong]:text-base-content/70">
{Phoenix.HTML.raw(
render_markdown_inline(section.note, %{"redirect_uri" => @redirect_uri || ""})
)}
</span>
</p>
<ol class="list-decimal list-inside space-y-1 mt-1 ml-2">
<li :for={{text, _detail} <- section.steps}>
<span class="[&>a]:link [&>a]:link-primary [&>strong]:font-semibold [&>strong]:text-base-content">
{Phoenix.HTML.raw(
render_markdown_inline(text, %{"redirect_uri" => @redirect_uri || ""})
)}
</span>
</li>
</ol>
</div>
</div>
</details>
<%!-- Danger Zone (edit mode only). Consolidates destructive
actions so the operator doesn't have to navigate back to
the list to disconnect / remove. Disconnect is OAuth-only
(clears tokens but preserves client_id/secret + the row);
Delete removes the integration row entirely.
Both confirm via `data-confirm` browser dialog before
firing. The Delete handler push_navigates back to the
list on success. --%>
<details :if={@name != nil} class="card bg-base-100 border-2 border-error/30">
<summary class="card-body py-3 cursor-pointer flex-row items-center gap-2 select-none">
<.icon name="hero-exclamation-triangle" class="w-4 h-4 text-error" />
<h3 class="font-semibold text-error text-base">
{gettext("Danger Zone")}
</h3>
<.icon name="hero-chevron-down" class="w-4 h-4 ml-auto text-base-content/40" />
</summary>
<div class="card-body pt-0 space-y-4">
<%!-- Disconnect: OAuth providers only. Clears the access /
refresh tokens so the next request requires a fresh
OAuth dance. The credentials (client_id/secret) and
the integration row stay so re-connecting is one
click. --%>
<div
:if={@provider.auth_type == :oauth2 && @data["status"] == "connected"}
class="flex items-center justify-between gap-4"
>
<div>
<p class="font-medium text-sm">{gettext("Disconnect account")}</p>
<p class="text-xs text-base-content/60">
{gettext(
"Clears the OAuth tokens. Credentials and the connection itself stay so you can reconnect with one click."
)}
</p>
</div>
<button
type="button"
phx-click="disconnect_account"
class="btn btn-outline btn-warning btn-sm shrink-0"
data-confirm={gettext("Are you sure you want to disconnect this account?")}
phx-disable-with={gettext("Disconnecting…")}
>
<.icon name="hero-link-slash" class="w-4 h-4" />
{gettext("Disconnect")}
</button>
</div>
<%!-- Delete: removes the integration row. Any module that
referenced its uuid will surface :not_configured on
next use — the loud failure is correct (the credentials
are actually gone). --%>
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-sm">{gettext("Delete this connection")}</p>
<p class="text-xs text-base-content/60">
{gettext(
"Removes the integration permanently. Any module pinned to this connection will stop working until repointed."
)}
</p>
</div>
<button
type="button"
phx-click="delete_connection"
class="btn btn-outline btn-error btn-sm shrink-0"
data-confirm={gettext("Permanently delete this connection? This cannot be undone.")}
phx-disable-with={gettext("Deleting…")}
>
<.icon name="hero-trash" class="w-4 h-4" />
{gettext("Delete")}
</button>
</div>
</div>
</details>
</div>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>