Packages
phoenix_kit
2.13.4
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
Current section
Files
lib/phoenix_kit_web/live/settings/authorization.ex
defmodule PhoenixKitWeb.Live.Settings.Authorization do
@moduledoc """
Authorization settings management LiveView for PhoenixKit.
Manages login page branding and authentication methods including
magic links and OAuth provider configuration.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Storage.URLSigner
alias PhoenixKit.Settings
alias PhoenixKit.Users.OAuthConfig
alias PhoenixKit.Utils.CssValue
require Logger
# S009: the only OAuth setting keys this page renders as `type="password"`
# inputs. The template never echoes the real value into `value=` (see
# authorization.html.heex), so an untouched field submits blank on every
# `validate_settings`/`save_settings` event — `preserve_unset_secrets/2`
# is what keeps that blank from overwriting the real stored secret.
# Deliberately NOT `Settings.restricted_setting_keys()`: that list also
# covers `aws_access_key_id`/`aws_secret_access_key`, which this page
# never renders.
@oauth_secret_keys ~w(oauth_google_client_secret oauth_github_client_secret oauth_facebook_app_secret)
def mount(_params, _session, socket) do
current_settings = Settings.list_all_settings()
defaults = Settings.get_defaults()
merged_settings = Map.merge(defaults, current_settings)
changeset = Settings.change_settings(merged_settings)
socket =
socket
|> assign(:page_title, "Authorization Settings")
|> assign(:settings, merged_settings)
|> assign(:saved_settings, merged_settings)
|> assign(:changeset, changeset)
|> assign(:saving, false)
|> assign(
:project_title,
merged_settings["project_title"] || PhoenixKit.Config.get(:project_title, "PhoenixKit")
)
|> assign(:show_media_selector, false)
|> assign(:media_selection_target, nil)
{:ok, socket}
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
def handle_event("validate_settings", %{"settings" => settings_params}, socket) do
settings_params = preserve_unset_secrets(settings_params, socket.assigns.settings)
changeset = Settings.validate_settings(settings_params)
socket =
socket
|> assign(:settings, settings_params)
|> assign(:changeset, changeset)
{:noreply, socket}
end
def handle_event("save_settings", %{"settings" => settings_params}, socket) do
socket = assign(socket, :saving, true)
case validate_background_color(settings_params) do
:ok ->
do_save_settings(socket, settings_params)
{:error, message} ->
{:noreply, socket |> assign(:saving, false) |> put_flash(:error, message)}
end
end
def handle_event("test_oauth", %{"provider" => provider}, socket) do
provider_atom = String.to_existing_atom(provider)
credentials = oauth_credentials_from_settings(provider_atom, socket.assigns.settings)
case OAuthConfig.test_connection(provider_atom, credentials) do
{:ok, message} ->
{:noreply, put_flash(socket, :info, message)}
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
end
end
def handle_event("reload_oauth_config", _params, socket) do
OAuthConfig.configure_providers()
{:noreply, put_flash(socket, :info, "OAuth configuration reloaded from database")}
end
def handle_event("open_media_selector", %{"target" => target}, socket) do
{:noreply,
socket
|> assign(:show_media_selector, true)
|> assign(:media_selection_target, String.to_existing_atom(target))}
end
def handle_event("clear_branding_image", %{"target" => target}, socket) do
key =
case target do
"logo" -> "auth_logo_file_uuid"
"background" -> "auth_background_image_file_uuid"
"background_mobile" -> "auth_background_image_mobile_file_uuid"
end
settings = Map.put(socket.assigns.settings, key, "")
{:noreply, assign(socket, :settings, settings)}
end
## Media selector callbacks
def handle_info({:media_selected, file_uuids}, socket) do
file_uuid = List.first(file_uuids) || ""
key =
case socket.assigns.media_selection_target do
:logo -> "auth_logo_file_uuid"
:background -> "auth_background_image_file_uuid"
:background_mobile -> "auth_background_image_mobile_file_uuid"
end
settings = Map.put(socket.assigns.settings, key, file_uuid)
{:noreply,
socket
|> assign(:settings, settings)
|> assign(:show_media_selector, false)
|> assign(:media_selection_target, nil)}
end
def handle_info({:media_selector_closed}, socket) do
{:noreply,
socket
|> assign(:show_media_selector, false)
|> assign(:media_selection_target, nil)}
end
# Helper functions
# The background colour lands in a `<style>` element on every public auth
# page, so it is refused on write as well as sanitised on read
# (`PhoenixKitWeb.Components.AuthPageWrapper`). Rejecting here is what tells
# the operator their value was not stored; the read-side guard is what keeps
# the page safe regardless of what is already in the table.
defp validate_background_color(%{"auth_background_color" => value})
when is_binary(value) and value != "" do
if CssValue.color(value) == "" do
{:error,
"Background color must be a plain CSS color or gradient — " <>
"for example #1e293b, rgb(30 41 59) or linear-gradient(135deg, #667eea, #764ba2)"}
else
:ok
end
end
defp validate_background_color(_settings_params), do: :ok
# S009: the template never renders a real OAuth secret into `value=`
# (view-source can't leak it), so an untouched password field arrives here
# blank. Blindly assigning that would blank out `@settings` for the key on
# the next `validate_settings` event and wipe the stored secret on the
# next `save_settings` — this restores the currently-held value for any of
# `@oauth_secret_keys` that comes in blank, while a non-blank incoming
# value (the admin actively typing a new secret) always wins.
defp preserve_unset_secrets(new_params, current_settings) do
Enum.reduce(@oauth_secret_keys, new_params, fn key, params ->
case Map.get(params, key) do
value when value in [nil, ""] -> Map.put(params, key, Map.get(current_settings, key, ""))
_value -> params
end
end)
end
defp do_save_settings(socket, settings_params) do
settings_params = preserve_unset_secrets(settings_params, socket.assigns.settings)
case Settings.update_settings(settings_params) do
{:ok, updated_settings} ->
OAuthConfig.configure_providers()
changeset = Settings.change_settings(updated_settings)
socket =
socket
|> assign(:settings, updated_settings)
|> assign(:saved_settings, updated_settings)
|> assign(:changeset, changeset)
|> assign(:saving, false)
|> put_flash(:info, "Authorization settings updated successfully")
{:noreply, socket}
{:error, errors} ->
error_msg = format_error_message(errors)
socket =
socket
|> assign(:saving, false)
|> put_flash(:error, error_msg)
{:noreply, socket}
end
end
defp format_error_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Map.values()
|> List.flatten()
|> Enum.join(", ")
end
def signed_preview_url(file_uuid, variant) do
URLSigner.signed_url(file_uuid, variant)
end
def get_oauth_callback_url(settings, provider) do
site_url = settings["site_url"] || "https://example.com"
url_prefix = PhoenixKit.Config.get_url_prefix()
# "/" is the sentinel Config.get_url_prefix/0 returns for "no prefix"
# (see compute_url_prefix/0) — treat it the same as "" here, matching
# Routes.path/2 and UeberAuth.get_default_base_path/0.
base_prefix = if url_prefix == "/", do: "", else: url_prefix
"#{site_url}#{base_prefix}/users/auth/#{provider}/callback"
end
# S009: the three OAuth secret inputs render `value=""` unconditionally
# (see authorization.html.heex), so this placeholder is the only signal an
# admin gets that a secret is already stored, without ever putting the
# real value in the DOM.
def oauth_secret_placeholder(current_value, unset_placeholder) do
if current_value in [nil, ""] do
unset_placeholder
else
gettext("A secret is already configured — leave blank to keep the current value")
end
end
# Builds the credentials map OAuthConfig.test_connection/2 expects, from
# the unsaved in-memory form state (`socket.assigns.settings`) rather than
# the database — the "Test Credentials" button must validate what the
# admin just typed, not the last-persisted values.
defp oauth_credentials_from_settings(:google, settings) do
%{
client_id: settings["oauth_google_client_id"] || "",
client_secret: settings["oauth_google_client_secret"] || ""
}
end
defp oauth_credentials_from_settings(:github, settings) do
%{
client_id: settings["oauth_github_client_id"] || "",
client_secret: settings["oauth_github_client_secret"] || ""
}
end
defp oauth_credentials_from_settings(:facebook, settings) do
%{
app_id: settings["oauth_facebook_app_id"] || "",
app_secret: settings["oauth_facebook_app_secret"] || ""
}
end
@doc """
Collapsible per-provider OAuth setup guide: the callback-URL box with a copy
button, the provider-specific console steps (slot), and the reverse-proxy
notice. One component instead of four hand-copied ~95-line blocks.
"""
attr :callback_url, :string, required: true
attr :copy_hint, :string, required: true, doc: "e.g. \"Copy this URL to Google Cloud Console\""
slot :steps, required: true, doc: "provider-specific <li> instruction items"
def oauth_setup_instructions(assigns) do
~H"""
<div class="collapse collapse-arrow bg-base-200 mt-4">
<input type="checkbox" class="peer" />
<div class="collapse-title text-sm font-medium">
{gettext("Setup Instructions")}
</div>
<div class="collapse-content text-sm space-y-4">
<%!-- Callback URL --%>
<div class="bg-base-100 border border-base-300 rounded-lg p-4">
<div class="flex items-start gap-3">
<.icon
name="hero-information-circle"
class="stroke-current shrink-0 h-5 w-5 text-info mt-0.5"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-base-content mb-2">
{gettext("Callback URL")}
</div>
<div class="flex items-center gap-2">
<code class="flex-1 px-3 py-2 bg-base-200 text-base-content border border-base-300 rounded font-mono text-xs break-all">
{@callback_url}
</code>
<button
type="button"
class="btn btn-sm btn-square btn-outline"
onclick={"navigator.clipboard.writeText('#{@callback_url}')"}
title={gettext("Copy to clipboard")}
>
<.icon name="hero-clipboard" class="h-4 w-4" />
</button>
</div>
<div class="text-xs text-base-content/60 mt-2">{@copy_hint}</div>
</div>
</div>
</div>
<%!-- Provider-specific steps --%>
<div class="space-y-3">
<ol class="list-decimal list-inside space-y-2 text-base-content/80">
{render_slot(@steps)}
</ol>
</div>
<%!-- Reverse Proxy Notice --%>
<div class="bg-base-200 border border-base-300 rounded-lg p-3 text-xs">
<div class="flex items-start gap-2">
<.icon name="hero-information-circle" class="h-4 w-4 text-info shrink-0 mt-0.5" />
<div class="text-base-content/80">
<strong class="text-base-content">
{gettext("Reverse Proxy Users:")}
</strong>
{gettext("If behind nginx/apache, ensure")}
<code class="px-1 py-0.5 bg-base-300 text-base-content rounded text-xs">
X-Forwarded-Proto
</code>
{gettext("header is set to")}
<code class="px-1 py-0.5 bg-base-300 text-base-content rounded text-xs">
https
</code>
</div>
</div>
</div>
</div>
</div>
"""
end
end