Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e1248bd
show cross platform (extra button)
peaceful-james Aug 31, 2025
80b9d20
improve display text stuff
peaceful-james Aug 31, 2025
beb9570
show usb icon
peaceful-james Aug 31, 2025
f1d2f13
fix tests
peaceful-james Aug 31, 2025
9d3204a
make timeout configurable
peaceful-james Aug 31, 2025
bc968b7
oops dont commit that lol
peaceful-james Aug 31, 2025
3b7a463
fix dupe event bug
peaceful-james Aug 31, 2025
a4a9cac
stop icons shrinking with long button labels
peaceful-james Aug 31, 2025
c321092
prettier
peaceful-james Aug 31, 2025
e4e9b39
remove compile-time regex
peaceful-james Sep 15, 2025
174d6ce
allow users to control which buttons are rendered
peaceful-james Sep 15, 2025
6267133
demo both buttons in the generated html
peaceful-james Sep 15, 2025
3fadb47
Update lib/webauthn_components/registration_component.ex
peaceful-james Sep 17, 2025
1170278
Update lib/wac_gen/javascript.ex
peaceful-james Sep 17, 2025
19821ab
remove opinionated button container elements
peaceful-james Sep 17, 2025
f247d11
use correct default id assign
peaceful-james Sep 17, 2025
fda12b1
move auth attach dependant assigns to own fxn
peaceful-james Sep 17, 2025
cd941b9
use normal button element
peaceful-james Sep 17, 2025
18284d3
remove unused import
peaceful-james Sep 17, 2025
4de6330
let users style the display text
peaceful-james Sep 17, 2025
72d5882
Use display_text for button title
peaceful-james Sep 17, 2025
45e691a
remove display_text_class
peaceful-james Sep 17, 2025
4069c37
fix tests
peaceful-james Sep 17, 2025
01381d7
sneak in incidental, unrelated fix
peaceful-james Sep 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions lib/wac_gen/app_html.ex
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
defmodule Wac.Gen.AppHtml do
@moduledoc false

@header_regex ~r/\<header.*\<\/header\>/mis

def update_app_html(assigns) do
web_snake_case = Keyword.fetch!(assigns, :web_snake_case)
file_path = Path.join(["lib", web_snake_case, "components", "layouts", "app.html.heex"])

modified_file_contents =
file_path
|> File.read!()
|> String.replace(@header_regex, navbar_component(assigns))
|> String.replace(header_regex(), navbar_component(assigns))

File.write!(file_path, modified_file_contents)
end

defp header_regex, do: ~r/\<header.*\<\/header\>/mis

defp navbar_component(assigns) do
web_pascal_case = Keyword.fetch!(assigns, :web_pascal_case)

Expand Down
13 changes: 8 additions & 5 deletions lib/wac_gen/javascript.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
defmodule Wac.Gen.Javascript do
@moduledoc false

@import_regex Regex.compile!("(import {\s?LiveSocket\s?} from \"phoenix_live_view\";?)")
@socket_regex Regex.compile!("(params: {\s?_csrf_token: csrfToken\s?})")

@import_hooks """
import {
SupportHook,
Expand All @@ -22,9 +19,15 @@ defmodule Wac.Gen.Javascript do
updated_contents =
javascript_path
|> File.read!()
|> String.replace(@import_regex, "\\1\n#{@import_hooks}")
|> String.replace(@socket_regex, "\\1,\n#{@socket_hooks}")
|> String.replace(import_regex(), "\\1\n#{@import_hooks}")
|> String.replace(socket_regex(), "\\1,\n#{@socket_hooks}")

File.write!(javascript_path, updated_contents)
end

defp import_regex do
Regex.compile!("(import {\s?LiveSocket\s?} from \"phoenix_live_view\";?)")
end

defp socket_regex, do: Regex.compile!("(params: {\s?_csrf_token: csrfToken\s?})")
end
2 changes: 1 addition & 1 deletion lib/webauthn_components/authentication_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ defmodule WebauthnComponents.AuthenticationComponent do
{
:ok,
socket
|> assign(:challenge, fn -> nil end)
|> assign_new(:challenge, fn -> nil end)
|> assign_new(:id, fn -> "authentication-component" end)
|> assign_new(:class, fn -> "" end)
|> assign_new(:disabled, fn -> nil end)
Expand Down
26 changes: 25 additions & 1 deletion lib/webauthn_components/icon_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ defmodule WebauthnComponents.IconComponents do
@moduledoc false
use Phoenix.Component

attr :type, :atom, required: true, values: [:key, :usb]

def icon(assigns) do
~H"""
<.icon_key :if={@type == :key} />
<.icon_usb :if={@type == :usb} />
"""
end

@doc false
def icon_key(assigns) do
~H"""
Expand All @@ -11,7 +20,7 @@ defmodule WebauthnComponents.IconComponents do
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-full h-full"
class="w-full h-full min-w-4 min-h-4"
>
<path
stroke-linecap="round"
Expand All @@ -22,6 +31,21 @@ defmodule WebauthnComponents.IconComponents do
"""
end

@doc false
def icon_usb(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-full h-full min-w-4 min-h-4"
>
<path d="M 23.5,12 L 21,10 L 21,11 L 8,11 L 10.5,6.5 C 10.75,6 11.125,5.5 11.5,5.5 C 12.75,5.5 13.25,5.5 13.5,5.5 C 13.75,7 14.5,8 15.5,8 C 16.75,8 17.5,7 17.5,5.5 C 17.5,4 16.75,3 15.5,3 C 14.5,3 13.75,4 13.5,5.5 L 11.5,5.5 C 11,5.5 10.25,6.25 10,6.75 L 7,11 L 5,11 C 4.75,9 3.5,7.5 2,7.5 C 0.75,7.5 0,9.25 0,12 C 0,14.75 0.75,16.5 2,16.5 C 3.5,16.5 4.75,15 5,13 L 7,13 L 10,17.25 C 10.25,17.75 11,18.5 11.5,18.5 L 13.5,18.5 L 13.5,20 L 16,20 L 16,16 L 13.5,16 L 13.5,17.5 L 11.5,17.5 C 11.125,17.5 10.75,17 10.5,16.5 L 8,13 L 21,13 L 21,14 L 23.5,12 z" />
</svg>
"""
end

@doc false
def icon_info_circle(assigns) do
~H"""
Expand Down
116 changes: 79 additions & 37 deletions lib/webauthn_components/registration_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ defmodule WebauthnComponents.RegistrationComponent do

- `@user`: (**Required**) A `WebauthnComponents.WebauthnUser` struct.
- `@challenge`: (Internal) A `Wax.Challenge` struct created by the component, used to create a new credential request in the client.
- `@display_text` (Optional) The text displayed inside the button. Defaults to "Sign Up".
- `@app`: (**Required**) The name of your application or service. This is displayed to the user during registration.
- `@authenticator_attachment` (Optional) The type of authenticator to use. Either `:platform` or `:cross_platform`. Defaults to `:platform`.
- `@display_text` (Optional) The text displayed inside the "platform" button. Defaults to "Sign Up" if authenticator attachment is `:platform`, or "Sign Up With Connected Device" if `:cross_platform`.
- `@icon_type` (Optional) The icon displayed inside the button. Either `:key` or `:usb`. Defaults to `:key` if authenticator attachment is `:platform`, or `:usb` if `:cross_platform`.
- `@show_icon?` (Optional) Controls visibility of the key icon. Defaults to `true`.
- `@class` (Optional) CSS classes for overriding the default button style.
- `@disabled` (Optional) Set to `true` when the `SupportHook` indicates WebAuthn is not supported or enabled by the browser. Defaults to `false`.
- `@id` (Optional) An HTML element ID.
- `@timeout` (Optional) The timeout in milliseconds for the registration operation. Defaults to `60_000` (60 seconds).
- `@resident_key` (Optional) Set to `:preferred` or `:discouraged` to allow non-passkey credentials. Defaults to `:required`.
- `@check_uvpa_available` (Optional) Set to `true` to check if the user has a platform authenticator available. Defaults to `false`. See the User Verifying Platform Authenticator section for more information.
- `@uvpa_error_message` (Optional) The message displayed when the user does not have a UVPA available. Defaults to "Registration unavailable. Your device does not support passkeys. Please install a passkey authenticator."
Expand Down Expand Up @@ -61,24 +65,22 @@ defmodule WebauthnComponents.RegistrationComponent do
"""
use Phoenix.LiveComponent
import WebauthnComponents.IconComponents
import WebauthnComponents.BaseComponents
alias WebauthnComponents.WebauthnUser

def mount(socket) do
{
:ok,
socket
|> assign(:challenge, fn -> nil end)
|> assign_new(:id, fn -> "registration-component" end)
|> assign_new(:class, fn -> "" end)
|> assign_new(:timeout, fn -> 60_000 end)
|> assign_new(:webauthn_user, fn -> nil end)
|> assign_new(:disabled, fn -> false end)
|> assign_new(:resident_key, fn -> :required end)
|> assign_new(:check_uvpa_available, fn -> false end)
|> assign_new(:uvpa_error_message, fn ->
"Registration unavailable. Your device does not support passkeys. Please install a passkey authenticator."
end)
|> assign_new(:display_text, fn -> "Sign Up" end)
|> assign_new(:show_icon?, fn -> true end)
|> assign_new(:relying_party, fn -> nil end)
}
Expand All @@ -98,11 +100,11 @@ defmodule WebauthnComponents.RegistrationComponent do
end

def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
}
socket
|> assign(assigns)
|> assign_new(:authenticator_attachment, fn -> :platform end)
|> assign_authenticator_attachment_dependant_assigns()
|> then(&{:ok, &1})
end

def render(assigns) do
Expand All @@ -111,28 +113,37 @@ defmodule WebauthnComponents.RegistrationComponent do
end

~H"""
<span>
<.button
id={@id}
phx-hook="RegistrationHook"
phx-target={@myself}
phx-click="register"
data-check_uvpa_available={if @check_uvpa_available, do: "true"}
data-uvpa_error_message={@uvpa_error_message}
class={@class}
title="Create a new account"
disabled={@disabled}
>
<span :if={@show_icon?} class="w-4 aspect-square opacity-70"><.icon_key /></span>
<span><%= @display_text %></span>
</.button>
</span>
<button
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could go either way on sticking with the base button component or using the raw HTML button. However there are two issues:

  1. AuthenticationComponent still uses the base component.
  2. This change is out of scope for supporting cross-platform credentials.

In projects, I do tend to apply more styling to HTML elements in CSS to reduce component maintenance and complexity. A discussion about sensible defaults would be good before making this change.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #99 to start the conversation for this topic.

id={@id}
type="button"
phx-hook="RegistrationHook"
phx-target={@myself}
phx-click="register"
data-check_uvpa_available={if @check_uvpa_available, do: "true"}
data-uvpa_error_message={@uvpa_error_message}
class={@class}
title={@display_text}
disabled={@disabled}
>
<span :if={@show_icon?} class="w-4 aspect-square opacity-70">
<.icon type={@icon_type} />
</span>
<span><%= @display_text %></span>
</button>
"""
end

def handle_event("register", _params, socket) do
%{assigns: assigns, endpoint: endpoint} = socket
%{app: app_name, id: id, resident_key: resident_key, webauthn_user: webauthn_user} = assigns

%{
app: app_name,
authenticator_attachment: authenticator_attachment,
id: id,
resident_key: resident_key,
webauthn_user: webauthn_user,
timeout: timeout
} = assigns

if not is_struct(webauthn_user, WebauthnUser) do
raise "user must be a WebauthnComponents.WebauthnUser struct."
Expand All @@ -148,19 +159,26 @@ defmodule WebauthnComponents.RegistrationComponent do
trusted_attestation_types: [:none, :basic]
)

authenticator_attachment_string =
case authenticator_attachment do
:platform -> "platform"
:cross_platform -> "cross-platform"
end

challenge_data = %{
attestation: attestation,
challenge: Base.encode64(challenge.bytes, padding: false),
excludeCredentials: [],
id: id,
residentKey: resident_key,
requireResidentKey: resident_key == :required,
rp: %{
id: challenge.rp_id,
name: app_name
"attestation" => attestation,
"authenticatorAttachment" => authenticator_attachment_string,
"challenge" => Base.encode64(challenge.bytes, padding: false),
"excludeCredentials" => [],
"id" => id,
"residentKey" => resident_key,
"requireResidentKey" => resident_key == :required,
"rp" => %{
"id" => challenge.rp_id,
"name" => app_name
},
timeout: 60_000,
user: webauthn_user
"timeout" => timeout,
"user" => webauthn_user
}

{
Expand Down Expand Up @@ -208,4 +226,28 @@ defmodule WebauthnComponents.RegistrationComponent do
send(self(), {:invalid_event, event, payload})
{:noreply, socket}
end

defp assign_authenticator_attachment_dependant_assigns(socket) do
%{authenticator_attachment: authenticator_attachment} = socket.assigns

socket
|> assign_new(:id, fn -> default_id(authenticator_attachment) end)
|> assign_new(:display_text, fn -> default_display_text(authenticator_attachment) end)
|> assign_new(:icon_type, fn -> default_icon_type(authenticator_attachment) end)
end

defp default_id(authenticator_attachment) do
"registration-component-#{authenticator_attachment}"
end

defp default_display_text(:platform) do
"Sign Up With Passkey"
end

defp default_display_text(:cross_platform) do
"Sign Up With Connected Device"
end

defp default_icon_type(:platform), do: :key
defp default_icon_type(:cross_platform), do: :usb
end
11 changes: 7 additions & 4 deletions priv/static/registration_hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ export const RegistrationHook = {
});
}

this.handleEvent("registration-challenge", (event) =>
this.handleRegistration(event, this)
);
this.handleEvent("registration-challenge", (event) => {
if (event.id == this.el.id) {
this.handleRegistration(event, this);
}
});
},
async checkUserVerifyingPlatformAuthenticatorAvailable(
context,
Expand All @@ -32,6 +34,7 @@ export const RegistrationHook = {
try {
const {
attestation,
authenticatorAttachment,
challenge,
excludeCredentials,
residentKey,
Expand All @@ -47,7 +50,7 @@ export const RegistrationHook = {
const publicKey: PublicKeyCredentialCreationOptions = {
attestation,
authenticatorSelection: {
authenticatorAttachment: "platform",
authenticatorAttachment: authenticatorAttachment,
residentKey: residentKey,
requireResidentKey: requireResidentKey,
},
Expand Down
7 changes: 6 additions & 1 deletion templates/live_views/registration_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ defmodule <%= inspect @web_pascal_case %>.RegistrationLive do
|> Map.put(:name, email)
|> Map.put(:display_name, email)

send_update(RegistrationComponent, id: "registration-component", webauthn_user: webauthn_user)
for authenticator_attachment <- [:platform, :cross_platform] do
send_update(RegistrationComponent,
id: "registration-component-#{authenticator_attachment}",
webauthn_user: webauthn_user
)
end

{
:noreply,
Expand Down
26 changes: 22 additions & 4 deletions templates/live_views/registration_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,31 @@
>
<p>Create a <strong>new</strong> account:</p>

<.input type="email" field={form[:email]} label="Email" phx-debounce="250" autocomplete="username webauthn" />
<.input
type="email"
field={form[:email]}
label="Email"
phx-debounce="250"
autocomplete="username webauthn"
/>
<.live_component
:for={
{authenticator_attachment, display_text} <- [
platform: dpgettext("registration", "buttons", "Register using a passkey"),
cross_platform:
dpgettext(
"registration",
"buttons",
"Register using a passkey with an external device (e.g. phone, security key, etc.)"
)
]
}
authenticator_attachment={authenticator_attachment}
display_text={display_text}
id={"registration-component-#{authenticator_attachment}"}
disabled={@form.source.valid? == false}
module={RegistrationComponent}
id="registration-component"
app={<%= inspect @app_pascal_case %>}
app={inspect(@app_pascal_case)}
check_uvpa_available={false}
class={[
"bg-gray-200 hover:bg-gray-300 hover:text-black rounded transition",
Expand All @@ -31,7 +50,6 @@
]}
/>


<.link navigate={~p"/sign-in"} class="underline">Sign into an existing account</.link>
</fieldset>

Expand Down
5 changes: 3 additions & 2 deletions test/webauthn_components/registration_component_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ defmodule WebauthnComponents.RegistrationComponentTest do
assert clicked_element =~ "phx-click=\"register\""

assert_push_event(view, "registration-challenge", %{
id: "registration-component",
user: ^webauthn_user
"id" => "registration-component",
"user" => ^webauthn_user,
"authenticatorAttachment" => "platform"
})
end
end
Expand Down
Loading