Packages

phoenix_kit

2.13.15
2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 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
phoenix_kit lib phoenix_kit_web live settings integrations.ex
Raw

lib/phoenix_kit_web/live/settings/integrations.ex

defmodule PhoenixKitWeb.Live.Settings.Integrations do
@moduledoc """
Integrations list page — shows all configured service connections.
Each connection is displayed as a card with status, connected account info,
and quick actions (disconnect, test). An "Add Integration" button links to
the form page for creating new connections.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
# Imported per-LiveView rather than from `PhoenixKitWeb, :live_view`: a host
# app that defines its own `row_link/1` would get an ambiguous import the
# moment core wired this one in project-wide.
import PhoenixKitWeb.Components.Core.RowLink, only: [row_link: 1]
alias PhoenixKit.Integrations
alias PhoenixKit.Integrations.Encryption
alias PhoenixKit.Integrations.Events
alias PhoenixKit.Integrations.Providers
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
def mount(_params, _session, socket) do
if connected?(socket), do: Events.subscribe()
project_title = Settings.get_project_title()
socket =
socket
|> assign(:page_title, gettext("Integrations"))
|> assign(:project_title, project_title)
|> assign(:current_path, get_current_path(socket.assigns.current_locale_base))
|> load_encryption_report()
|> load_connections()
|> assign(:validating, nil)
{:ok, socket}
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
def handle_event("disconnect", %{"uuid" => uuid}, socket) do
Integrations.disconnect(uuid, actor_uuid(socket), owner: :system)
{:noreply,
socket
|> put_flash(:info, gettext("Disconnected"))
|> load_connections()}
end
def handle_event("validate_connection", %{"uuid" => uuid}, socket) do
send(self(), {:do_validate, uuid})
{:noreply, assign(socket, :validating, uuid)}
end
def handle_event("remove_connection", %{"uuid" => uuid}, socket) do
case Integrations.remove_connection(uuid, actor_uuid(socket), owner: :system) do
:ok ->
{:noreply,
socket
|> put_flash(:info, gettext("Connection removed"))
|> load_connections()}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to remove connection"))}
end
end
# ---------------------------------------------------------------------------
# Async validation
# ---------------------------------------------------------------------------
def handle_info({:do_validate, uuid}, socket) do
actor = actor_uuid(socket)
result = Integrations.validate_connection(uuid, actor, owner: :system)
Integrations.record_validation(uuid, result, owner: :system)
{:noreply,
socket
|> assign(:validating, nil)
|> load_connections()}
end
# ---------------------------------------------------------------------------
# PubSub handlers
# ---------------------------------------------------------------------------
def handle_info({:integration_setup_saved, _, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_connected, _, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_disconnected, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_validated, _, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_connection_added, _, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_connection_removed, _, _}, socket),
do: {:noreply, load_connections(socket)}
def handle_info({:integration_connection_renamed, _, _, _}, socket),
do: {:noreply, load_connections(socket)}
# Catch-all to prevent crashes from unexpected messages
def handle_info(_msg, socket), do: {:noreply, socket}
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
# Computed once at mount, not on every `load_connections/1` reload below.
# `Encryption.key_report/0` is store-backed, not a pure config read: with a
# Chain/S3 store it does a network round trip, and it always runs a
# PBKDF2-HMAC-SHA256 with 100_000 iterations (~0.1 CPU-sec) to fingerprint
# the key. `load_connections/1` runs on every PubSub event this LiveView
# gets, including ones about connections that have nothing to do with the
# key, so paying that cost there on each one would be wasted work.
#
# The trade-off this accepts: the report CAN go stale before the operator
# reloads the page. The dedicated key itself only changes on a restart
# (rotation is a Mix task, and there is no running-app event for "the key
# changed"), which would remount this LiveView anyway — but the STORE
# around that key is read fresh on every `key_report/0` call precisely so
# `store_unreadable`/`store_shadowed`/`no_secret_yet` can flip on a live VM
# without a restart (see `Encryption.key_signals/0` and `store_state/2`),
# and this page will not notice such a flip until it is reloaded.
defp load_encryption_report(socket) do
# ONE report per mount. This used to be three separate calls —
# `status/0`, `key_diagnosis/0`, `key_fingerprint/0` — each re-resolving the
# key independently, and a key store that answers one read and fails the
# next could hand the three of them different answers for the same page.
report = Encryption.key_report()
socket
|> assign(:encryption_report, report)
|> assign(:encryption_fingerprint, encryption_fingerprint(report))
end
defp load_connections(socket) do
# System page: only providers usable system-wide, and only SYSTEM-owned
# connections (owner: :system) — a user's personal connection never leaks here.
providers = Providers.for_scope(:system)
provider_keys = Enum.map(providers, & &1.key)
providers_by_key = Map.new(providers, &{&1.key, &1})
# Single query for all providers instead of N+1
all_connections = Integrations.load_all_connections(provider_keys, owner: :system)
connections =
Enum.flat_map(providers, fn provider ->
Map.get(all_connections, provider.key, [])
|> Enum.map(fn %{uuid: uuid, name: name, data: data} ->
%{
provider: providers_by_key[provider.key],
uuid: uuid,
name: name,
data: data
}
end)
end)
socket
|> assign(:connections, connections)
|> assign(:provider_names, join_with_and(Enum.map(providers, & &1.name)))
end
# Joins a list of names with commas, using a translated "and" before
# the final item: ["A"] → "A", ["A", "B"] → "A and B",
# ["A", "B", "C"] → "A, B and C". `gettext("and")` is extracted to the
# .pot so each locale can supply its own conjunction.
defp join_with_and([]), do: ""
defp join_with_and([single]), do: single
defp join_with_and(list) do
{init, [last]} = Enum.split(list, -1)
Enum.join(init, ", ") <> " " <> gettext("and") <> " " <> last
end
defp actor_uuid(socket) do
case socket.assigns[:phoenix_kit_current_scope] do
%{user: %{uuid: uuid}} -> uuid
_ -> nil
end
end
defp get_current_path(locale) do
Routes.path("/admin/settings/integrations/website", locale: locale)
end
defp integration_status_badge("connected"), do: {"badge-success", gettext("Connected")}
defp integration_status_badge("configured"), do: {"badge-warning", gettext("Not tested")}
defp integration_status_badge("disconnected"), do: {"badge-ghost", gettext("Not connected")}
defp integration_status_badge("error"), do: {"badge-error", gettext("Error")}
defp integration_status_badge(_), do: {"badge-ghost", gettext("Not configured")}
# Public seams, `@doc false`: the defect these exist against lives in the
# rendering, and a test that cannot reach the rendering cannot guard it — the
# same reasoning that made `Mix.Tasks.PhoenixKit.Doctor.integration_key_result/2`
# public. The template guards the banner on the report's severity, so the
# healthy `{:dedicated, :ok}` case never reaches these.
#
# Keyed on `report.diagnosis`, not on the status alone. Keyed on the status,
# this page told an operator whose key store is merely UNREADABLE that "no
# dedicated encryption key is configured" — false, they configured one — and
# then advised the rotation that would abandon the key their data may be
# encrypted under.
#
# `{:dedicated, :store_unreadable}` gets its own clause ahead of the generic
# one for the same reason it got its own clause in `Encryption.key_report/1`:
# without it this page called a working dedicated key a FALLBACK, and — since
# the banner used to be guarded on `status != :dedicated` — printed that label
# under no banner at all, so the broken store was never mentioned.
@doc false
def encryption_status_title(%{diagnosis: {:dedicated, :store_unreadable}}),
do: gettext("The encryption key is fine, but its key store cannot be read")
def encryption_status_title(%{diagnosis: {:dedicated, :store_shadowed}}),
do: gettext("The key store holds a different secret from the key in use")
def encryption_status_title(%{diagnosis: {_status, :store_unreadable}}),
do: gettext("The configured encryption key store cannot be read")
# Mirrors `{_status, :store_unreadable}` immediately above: `:legacy` and
# `:none` only had the `:dedicated` clause to fall back to, which is silent
# about the store for the exact window — after a rotation, before the
# restart it requires — where an operator most needs to hear that the store
# already holds a secret waiting to be picked up.
def encryption_status_title(%{diagnosis: {_status, :store_shadowed}}),
do: gettext("The configured key store holds a different secret")
# A rejected key does not have to be `integrations_encryption_key`. Until the
# store could supply one, it always was, and these clauses said so; the moment
# it could, they went on saying it. The source is now carried on the report
# rather than assumed from the diagnosis.
def encryption_status_title(%{diagnosis: {_status, :key_too_short}, rejected_key: :store}),
do: gettext("The secret in the key store was rejected as too short")
def encryption_status_title(%{diagnosis: {_status, :key_too_short}}),
do: gettext("The configured encryption key was rejected as too short")
def encryption_status_title(%{diagnosis: {:legacy_secret_key_base, _reason}}),
do: gettext("Credentials are protected only by a shared application secret")
def encryption_status_title(%{diagnosis: {:disabled_no_key, _reason}}),
do: gettext("Credentials are stored in plain text")
def encryption_status_title(%{diagnosis: {:disabled_explicit, _reason}}),
do: gettext("Encryption is turned off for integration credentials")
# Catch-all: the template renders this banner for ANY status other than
# `:dedicated` (see the guard note above), so a future `key_status/0`
# value this page hasn't been taught about must degrade to a generic
# warning instead of a `FunctionClauseError` crashing the settings page.
def encryption_status_title(_report),
do: gettext("Integration credential encryption needs attention")
# Two clauses per fault, because the consequence genuinely differs: with a
# legacy secret still available the data is merely on a weaker key, with none
# it is in plain text. Saying "fell back" in the second case is the exact
# falsehood this module already removed from two other surfaces — and saying
# it for `{:dedicated, _}` is the same falsehood a third time, since nothing
# fell back at all there.
@doc false
def encryption_status_detail(%{diagnosis: {:dedicated, :store_unreadable}}) do
gettext(
"Encryption itself is working — the key in use comes from configuration. The configured " <>
"key store cannot be read, so nothing confirms that key is saved anywhere. Do not " <>
"rotate until the store reads back: the rotation pre-flight only checks that the " <>
"store can be written, so it will not stop for this."
)
end
def encryption_status_detail(%{diagnosis: {:dedicated, :store_shadowed}}) do
gettext(
"Encryption is working — the key in use comes from configuration. The configured key " <>
"store holds a different secret, so it is not a copy of that key: restoring from it " <>
"would produce a key that decrypts nothing stored here. Do not rotate before you " <>
"know what the stored secret is for — a rotation replaces it in every configured " <>
"store and keeps no copy."
)
end
def encryption_status_detail(%{diagnosis: {:disabled_no_key, :store_unreadable}}) do
gettext(
"A key store is configured but its secret could not be read, and no other key resolves " <>
"either — credentials below are being written in plain text. Do NOT run " <>
"mix phoenix_kit.integrations.rotate_key: the stored key may be the one existing " <>
"credentials are encrypted under. Repair the store first; repairing it later will not " <>
"make anything written in the meantime readable."
)
end
def encryption_status_detail(%{diagnosis: {_status, :store_unreadable}}) do
gettext(
"A key store is configured but its secret could not be read, so encryption fell back to " <>
"a weaker key. Values written under the stored key will not decrypt. Do NOT run " <>
"mix phoenix_kit.integrations.rotate_key: the stored key may be the one they are " <>
"encrypted under. Repair the store first; repairing it later will not make anything " <>
"written in the meantime readable."
)
end
def encryption_status_detail(%{diagnosis: {:disabled_no_key, :store_shadowed}} = report) do
gettext(
"A key store is configured and holds a secret at %{location}, but no encryption key " <>
"resolves at all — credentials below are being written in plain text. Do NOT run " <>
"mix phoenix_kit.integrations.rotate_key: it refuses while no key is active. Check " <>
"what the store holds — if it is a real key from an earlier rotation, wire it in as " <>
"integrations_encryption_key and restart.",
location: store_location(report)
)
end
# Mirrors the generic `{_status, :store_unreadable}` clause above, for the
# sibling fault: the store holds something, just not the key in use. Most
# plausibly the window between a rotation and the restart it requires — see
# `Encryption.key_report/1`'s `:legacy, store: {:shadowed, _}` clause.
def encryption_status_detail(%{diagnosis: {_status, :store_shadowed}} = report) do
gettext(
"A key store is configured but holds a secret that is not the key in use, so " <>
"encryption fell back to a weaker key. %{location} is most plausibly already " <>
"holding a dedicated secret from a rotation this app has not restarted to pick up " <>
"yet. Do NOT run mix phoenix_kit.integrations.rotate_key before checking: if that " <>
"is the case, restart instead of rotating again, since rotating replaces it with no " <>
"copy kept.",
location: store_location(report)
)
end
# The clause the checker found. Keyed on the diagnosis alone it matched ANY
# status with a rejected key and told an operator whose short secret sits in
# the key store that "the key store is not consulted at all, so repairing or
# filling the store changes nothing" — false three times over, and it argues
# against the one repair that would work.
def encryption_status_detail(
%{diagnosis: {:disabled_no_key, :key_too_short}, rejected_key: :store} = report
) do
gettext(
"The secret in the key store (%{location}) was rejected as shorter than the minimum, " <>
"and no other key resolves — credentials below are being written in plain text. No " <>
"integrations_encryption_key is set, so the store is where the key is read from: put " <>
"a longer secret there and restart.",
location: store_location(report)
)
end
def encryption_status_detail(
%{diagnosis: {_status, :key_too_short}, rejected_key: :store} = report
) do
gettext(
"The secret in the key store (%{location}) was rejected as shorter than the minimum, " <>
"so a weaker key is in use. No integrations_encryption_key is set, so the store is " <>
"where the key is read from: put a longer secret there and restart.",
location: store_location(report)
)
end
def encryption_status_detail(%{diagnosis: {:disabled_no_key, :key_too_short}}) do
gettext(
"A dedicated encryption key is configured but was rejected as too short, and no other " <>
"key resolves — credentials below are being written in plain text. Replace it with a " <>
"longer secret and restart; rotation cannot help while no key is active."
)
end
def encryption_status_detail(%{diagnosis: {_status, :key_too_short}}) do
gettext(
"A dedicated encryption key is configured but was rejected as too short, so a weaker " <>
"key is in use. This is not the same as having none configured. Replace it with a " <>
"longer secret — while a rejected key is set, the key store is not consulted at all, " <>
"so repairing or filling the store changes nothing."
)
end
def encryption_status_detail(%{diagnosis: {:legacy_secret_key_base, _reason}}) do
gettext(
"No dedicated encryption key is configured, so credentials below fall back to a key " <>
"derived from secret_key_base — a secret shared with session signing and CSRF tokens. " <>
"Anyone who can read secret_key_base can decrypt every credential here. Run " <>
"mix phoenix_kit.integrations.rotate_key to fix this."
)
end
def encryption_status_detail(%{diagnosis: {:disabled_no_key, _reason}}) do
gettext(
"No encryption key could be resolved. New and existing credentials below are stored as " <>
"plain text in the database."
)
end
def encryption_status_detail(%{diagnosis: {:disabled_explicit, _reason}}) do
gettext(
"integration_encryption_enabled is set to false. Credentials below are stored as plain " <>
"text in the database."
)
end
# See `encryption_status_title/1`'s catch-all note.
def encryption_status_detail(_report) do
gettext(
"The current encryption status could not be described by this admin page — it may be " <>
"newer than what this page recognizes. Check PhoenixKit.Integrations.Encryption.status/0 " <>
"directly."
)
end
# Returns `{fingerprint, tier}` or nil. The tier is not decoration: a site
# whose key store is unreadable, or whose dedicated key was rejected as too
# short, fingerprints its FALLBACK key. A bare number would let an operator
# comparing two sites conclude their keys differ when the comparison was never
# like-for-like.
#
# `:none` renders nothing rather than a placeholder: with no key there is
# nothing to compare, and the banner above already says the credentials are
# unencrypted.
defp encryption_fingerprint(report) do
case report.fingerprint do
{:ok, fingerprint, _label} -> {fingerprint, fingerprint_tier(report)}
:none -> nil
end
end
# `:key_store` is `{state, location}` or nil; a clause that names a location
# only runs where the store supplied the secret, so nil cannot reach it — but
# the verdict is total and so is this.
defp store_location(%{key_store: {_state, location}}), do: location
defp store_location(_report), do: "the configured key store"
@doc false
def fingerprint_tier(%{diagnosis: {:dedicated, :ok}}), do: gettext("dedicated key")
# Ahead of the generic `:store_unreadable` clause below, which would otherwise
# label a working dedicated key "FALLBACK".
def fingerprint_tier(%{diagnosis: {:dedicated, :store_unreadable}}),
do: gettext("dedicated key — its key store could not be read")
def fingerprint_tier(%{diagnosis: {:dedicated, :store_shadowed}}),
do: gettext("dedicated key — the key store holds a different secret")
def fingerprint_tier(%{diagnosis: {_status, :store_unreadable}}),
do: gettext("FALLBACK key — the configured key store could not be read")
def fingerprint_tier(%{diagnosis: {_status, :store_shadowed}}),
do: gettext("FALLBACK key — the configured key store holds a different secret")
def fingerprint_tier(%{diagnosis: {_status, :key_too_short}, rejected_key: :store}),
do: gettext("FALLBACK key — the secret in the key store was rejected as too short")
def fingerprint_tier(%{diagnosis: {_status, :key_too_short}}),
do: gettext("FALLBACK key — the configured key was rejected as too short")
def fingerprint_tier(%{diagnosis: {:legacy_secret_key_base, _}}),
do: gettext("derived from secret_key_base")
def fingerprint_tier(_report), do: gettext("unrecognised key state")
end