Packages
ash_authentication_phoenix
3.0.0-rc.7
3.0.0-rc.9
3.0.0-rc.8
3.0.0-rc.7
3.0.0-rc.6
3.0.0-rc.4
3.0.0-rc.3
3.0.0-rc.2
3.0.0-rc.1
3.0.0-rc.0
2.17.2
2.17.1
2.17.0
2.16.0
2.15.0
2.14.1
2.14.0
2.13.1
2.13.0
2.12.2
2.12.1
2.12.0
2.11.0
2.10.5
2.10.4
2.10.3
2.10.2
2.10.1
2.10.0
2.9.0
2.8.0
2.7.0
2.6.3
2.6.2
2.6.1
2.6.0
2.5.4
2.5.3
2.5.2
2.5.1
2.5.0
2.4.8
2.4.7
2.4.6
2.4.5
2.4.4
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.11
2.1.10
2.1.9
2.1.8
2.1.7
2.1.6
2.1.5
2.1.4
2.1.3
2.1.2
2.1.1
2.1.0
2.0.2
2.0.1
2.0.0
2.0.0-rc.2
2.0.0-rc.1
2.0.0-rc.0
1.9.4
1.9.3
1.9.2
1.9.1
1.9.0
1.8.7
1.8.6
1.8.5
1.8.4
1.8.3
1.8.2
1.8.1
1.8.0
1.7.3
1.7.2
1.7.1
1.7.0
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
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.1
1.3.0
1.2.0
1.1.0
1.0.1
1.0.0
Phoenix integration for Ash Authentication
Current section
Files
Jump to
Current section
Files
lib/ash_authentication_phoenix/components/webauthn/manage_credentials.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Phoenix.Components.WebAuthn.ManageCredentials do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the management panel root `div`.",
heading_text: "Heading text for the panel.",
heading_class: "CSS class for the heading.",
credential_list_class: "CSS class for the credential list.",
credential_item_class: "CSS class for each credential row.",
add_button_text: "Text for the add credential button.",
add_button_class: "CSS class for the add button.",
delete_button_text: "Text for the delete button.",
delete_button_class: "CSS class for the delete button.",
rename_button_text: "Text for the rename button.",
rename_button_class: "CSS class for the rename button.",
save_button_text: "Text for the save label button.",
cancel_button_text: "Text for the cancel rename button.",
empty_state_text: "Text shown when no credentials exist.",
last_credential_warning: "Warning when trying to delete the last credential.",
label_input_class: "CSS class for the label input.",
timestamp_class: "CSS class for timestamp text.",
continue_button_text:
"Text for the continue button shown after at least one credential is registered.",
continue_button_class: "CSS class for the continue button."
@moduledoc """
Credential management panel for authenticated users.
Displays all registered security keys/passkeys with options to rename,
delete, and add new credentials.
Prevents deletion of the last credential.
All credential operations go through `AshAuthentication.Strategy.WebAuthn.Actions`
— the Ash resource layer is never bypassed.
## Props
* `strategy` - The WebAuthn strategy configuration. Required.
* `current_user` - The authenticated user. Required.
* `overrides` - A list of override modules.
#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""
use AshAuthentication.Phoenix.Web, :live_component
alias AshAuthentication.Phoenix.WebAuthn, as: PhoenixWebAuthn
alias AshAuthentication.Strategy.WebAuthn
# alias Phoenix.LiveView.{Rendered, Socket}
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end)
|> assign_new(:gettext_fn, fn -> nil end)
|> assign_new(:editing_id, fn -> nil end)
|> assign_new(:editing_label, fn -> "" end)
|> assign_new(:error_message, fn -> nil end)
|> assign_new(:adding, fn -> false end)
|> assign_new(:current_tenant, fn -> nil end)
|> assign_new(:continue_path, fn -> nil end)
unless assigns[:current_user] do
raise ArgumentError, "ManageCredentials requires a :current_user assign"
end
socket = assign_new(socket, :credentials, fn -> fetch_credentials(socket) end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class={override_for(@overrides, :root_class)} id={@id}>
<h2 class={override_for(@overrides, :heading_class)}>
{_gettext(override_for(@overrides, :heading_text, "Your Security Keys"))}
</h2>
<%= if @error_message do %>
<div class="text-red-600 text-sm mb-4">{_gettext(@error_message)}</div>
<% end %>
<%= if @credentials == [] do %>
<p>{_gettext(override_for(@overrides, :empty_state_text, "No security keys registered."))}</p>
<% else %>
<ul class={override_for(@overrides, :credential_list_class)}>
<%= for credential <- @credentials do %>
<li class={override_for(@overrides, :credential_item_class)}>
<%= if @editing_id == credential.id do %>
<form phx-submit="save-label" phx-target={@myself}>
<input type="hidden" name="credential_id" value={credential.id} />
<input
type="text"
name="label"
value={@editing_label}
class={override_for(@overrides, :label_input_class)}
autofocus
/>
<button type="submit">
{_gettext(override_for(@overrides, :save_button_text, "Save"))}
</button>
<button type="button" phx-click="cancel-edit" phx-target={@myself}>
{_gettext(override_for(@overrides, :cancel_button_text, "Cancel"))}
</button>
</form>
<% else %>
<div>
<strong>{credential.label || "Security Key"}</strong>
<span class={override_for(@overrides, :timestamp_class)}>
<%= if added = Map.get(credential, :inserted_at) do %>
Added: {Calendar.strftime(added, "%B %d, %Y")}
<% end %>
<%= if credential.last_used_at do %>
| Last used: {Calendar.strftime(credential.last_used_at, "%B %d, %Y %H:%M")}
<% end %>
</span>
</div>
<div>
<button
phx-click="edit-label"
phx-value-id={credential.id}
phx-value-label={credential.label}
phx-target={@myself}
class={override_for(@overrides, :rename_button_class)}
>
{_gettext(override_for(@overrides, :rename_button_text, "Rename"))}
</button>
<button
phx-click="delete-credential"
phx-value-id={credential.id}
phx-target={@myself}
class={override_for(@overrides, :delete_button_class)}
data-confirm={_gettext("Are you sure you want to remove this security key?")}
>
{_gettext(override_for(@overrides, :delete_button_text, "Delete"))}
</button>
</div>
<% end %>
</li>
<% end %>
</ul>
<% end %>
<div id={"#{@id}-add-key"} phx-hook="WebAuthnRegistrationHook">
<button
phx-click="add-credential"
phx-target={@myself}
class={override_for(@overrides, :add_button_class)}
disabled={@adding}
>
{_gettext(override_for(@overrides, :add_button_text, "+ Add another security key"))}
</button>
</div>
<%= if @continue_path && @credentials != [] do %>
<a
href={@continue_path}
class={override_for(@overrides, :continue_button_class)}
>
{_gettext(override_for(@overrides, :continue_button_text, "Continue"))}
</a>
<% end %>
</div>
"""
end
@impl true
def handle_event("edit-label", %{"id" => id, "label" => label}, socket) do
{:noreply, assign(socket, editing_id: id, editing_label: label || "")}
end
def handle_event("cancel-edit", _params, socket) do
{:noreply, assign(socket, editing_id: nil, editing_label: "")}
end
def handle_event("save-label", %{"credential_id" => id, "label" => label}, socket) do
if Enum.any?(socket.assigns.credentials, &(to_string(&1.id) == id)) do
strategy = socket.assigns.strategy
case WebAuthn.Actions.update_credential_label(strategy, id, label,
tenant: socket.assigns.current_tenant
) do
{:ok, _} ->
socket =
socket
|> assign(editing_id: nil, editing_label: "", error_message: nil)
|> load_credentials()
{:noreply, socket}
{:error, _} ->
{:noreply, assign(socket, error_message: "Failed to rename credential.")}
end
else
{:noreply, assign(socket, error_message: "Credential not found.")}
end
end
def handle_event("delete-credential", %{"id" => id}, socket) do
if Enum.any?(socket.assigns.credentials, &(to_string(&1.id) == id)) do
strategy = socket.assigns.strategy
user = socket.assigns.current_user
case WebAuthn.Actions.delete_credential(strategy, user, id,
tenant: socket.assigns.current_tenant
) do
:ok ->
socket = socket |> assign(error_message: nil) |> load_credentials()
{:noreply, socket}
{:error, _} ->
warning =
override_for(
socket.assigns.overrides,
:last_credential_warning,
"Cannot delete your last security key. You would be locked out."
)
{:noreply, assign(socket, error_message: warning)}
end
else
{:noreply, assign(socket, error_message: "Credential not found.")}
end
end
def handle_event("add-credential", _params, socket) do
strategy = socket.assigns.strategy
tenant = socket.assigns.current_tenant
origin = PhoenixWebAuthn.origin_from_socket(socket)
{:ok, challenge} =
WebAuthn.Actions.registration_challenge(strategy, tenant, origin: origin)
rp_id = WebAuthn.Helpers.resolve_rp_id(strategy, tenant)
rp_name = WebAuthn.Helpers.resolve_rp_name(strategy, tenant)
user_id = Base.url_encode64(:crypto.strong_rand_bytes(64), padding: false)
user = socket.assigns.current_user
socket =
socket
|> assign(:add_challenge, challenge)
|> assign(:adding, true)
|> Phoenix.LiveView.push_event("registration-challenge", %{
challenge: Base.url_encode64(challenge.bytes, padding: false),
rp_id: rp_id,
rp_name: rp_name,
user_id: user_id,
user_name: to_string(Map.get(user, strategy.identity_field)),
timeout: strategy.timeout,
attestation: strategy.attestation,
authenticator_attachment:
if(strategy.authenticator_attachment,
do: to_string(strategy.authenticator_attachment),
else: nil
),
user_verification: strategy.user_verification,
resident_key: to_string(strategy.resident_key)
})
{:noreply, socket}
end
def handle_event("registration-attestation", params, socket) do
strategy = socket.assigns.strategy
challenge = socket.assigns.add_challenge
user = socket.assigns.current_user
tenant = socket.assigns.current_tenant
add_params = %{
"attestation_object" => params["attestation_object"],
"client_data_json" => params["client_data_json"],
"label" => "New Key"
}
case WebAuthn.Actions.add_credential(strategy, add_params,
challenge: challenge,
user: user,
tenant: tenant
) do
{:ok, _credential} ->
socket =
socket
|> assign(adding: false, add_challenge: nil, error_message: nil)
|> load_credentials()
{:noreply, socket}
{:error, _} ->
{:noreply,
assign(socket,
adding: false,
add_challenge: nil,
error_message: "Failed to register new key."
)}
end
end
def handle_event("registration-error", _params, socket) do
{:noreply,
assign(socket,
adding: false,
add_challenge: nil,
error_message: "Registration was cancelled."
)}
end
defp load_credentials(socket) do
assign(socket, :credentials, fetch_credentials(socket))
end
defp fetch_credentials(socket) do
strategy = socket.assigns.strategy
user = socket.assigns.current_user
case WebAuthn.Actions.list_credentials(strategy, user,
tenant: socket.assigns[:current_tenant]
) do
{:ok, credentials} -> credentials
{:error, _} -> []
end
end
end