Memorable Password Generation with LiveView
Updates 2021-06-08. A public service update to only create the debouncer on the second call to mount, when the socket connects, to avoid a process leak.
tl;dr tips and tricks:
- What happens to DOM changed with own Javascript rather than LiveView
- Debouncing when
phx-debounce
is not enough
Also you can try out the LiveView password generator here
This week
This week so far I’ve been coding in Elixir, and not doing a great job of logging. Thursday and Friday I am mostly attending the virtual Elixir Conf EU.
Last week we got to a point with deploying to AWS that worked but was a little bit messy. I’ll talk about possible iterations on this at the end of the post.
Instead of spending another while on the deployment, though, I worked on something to deploy. It was based on my old app Correct Horse Battery Staple based on the eponymous XKCD comic that generates secure but memorable passwords from random words. The original app is in Rails and uses a database to store the pool of words from which to randomly generate passwords.
Correct Horse Battery Staple - Elixir Edition
This time the front end is in Phoenix Live View and rather than a database, the pool of words is stored in an ETS table. I’m using an Umbrella App because I am fond of making separate concerns really obvious. I find it helps clarify the separations. The app was created with
mix phx.new correcthorse --live --umbrella --no-ecto
LiveView is an pleasure to develop in. The simplicity reminds me of how I felt in the early days of Rails: disbelief that I could achieve so much with so little code. The entire interactive front-end is handled from this module and the presentation from this (mostly html) template.
Live view event taster
For a quick taster we’ll walk-through clicking the “Generate another” button.
The phx-click
attribute in the template is all we need to bind the event.
<button phx-click="generate-password">Generate another</button>
Then all we need to do is add an event handler to our module.
def handle_event("generate-password", _, socket) do
{:noreply, assign_new_password(socket)}
end
defp assign_new_password(socket) do
%{min_words: min_words, min_chars: min_chars} = socket.assigns
assign_password(
socket,
Password.words(min_words, min_chars)
)
end
defp assign_password(socket, wordlist) do
assign(socket,
wordlist: wordlist,
password: generated_password(wordlist, socket)
)
end
The minimum data is sent back to the page to update the template <%= @password %>
.
<input type="text" value="<%= @password %>"/>
What happens to DOM changed outwith LiveView?
I added a little bit of local javascript to copy the password to the clipboard (with clipboard-copy
). The experience felt a bit flat and confusing, without some feedback to indicate success.
I unhid an element with the text “copied”.
copied.classList.remove("hidden")
Without any other code the change is undone on the receipt of any LiveView change event from the server, so it gets hidden as soon as there is some change. This fortunate side effect is exactly the behaviour I wanted, but it might be an annoyance in other circumstances.
Hand-rolled debouncing
There are two range controls that determine the minimum number of words in the password, and the minimum number of characters in those words. Changing these should should regenerate the password, but only when we’ve finished sliding the controls. This is exactly what the phx-debounce
tag is made for, but there’s a problem: I also want to indicate the value of the control during the change, as below.
Fortunately we are using Elixir where not only are the easy things easy, but the other stuff is usually also easy.
We can create a little debouncing Genserver. Then we can start it, with our processes’ _pid_
and a 500 millisecond debounce value; we assign its pid to the socket on mounting.
def mount(_params, _session, socket) do
socket = if connected?(socket) do
{:ok, debouncer} = Debouncer.start_link(self(), 500)
assign(socket, _debouncer: debouncer)
else
socket
end
Then when we get notification of a range update we let the debouncer know, by calling bounce/2
with the pid and generate_new_password
, as well as updating the range values.
def handle_event("password-generation-details-changed", params, socket) do
%{_debouncer: debouncer} = socket.assigns
Debouncer.bounce(debouncer, :generate_new_password)
{min_words, min_chars} = extract_min_words_chars(params)
{:noreply, assign(socket, min_words: min_words, min_chars: min_chars)}
end
If no bounce/2
has been called on the debouncer for the 500 milliseconds, then the :generate_new_password
message is sent back to the LiveView. We generate the new password while handling that message.
def handle_info(:generate_new_password, socket) do
{:noreply, assign_new_password(socket)}
end
What wizardry is this? Let’s peek behind the debouncer’s curtain.
It relies on some very useful built-in GenServer functionality. Returning an optional timeout value in the tuple from one of the event handling callbacks (eg handle_info/2
) causes a :timeout
message to be sent to the GenServer unless a message is received to the processes’ queue before the timeout expires.
defstruct receiver: nil, timeout: 0, last_message: nil
@type t :: %__MODULE__{
receiver: pid,
timeout: timeout(),
last_message: any()
}
The GenServer’s state holds
- the pid of the receiver, ie something to receive a message. Remember we pass in
self()
when it is started in our LiveView timeout
- the 500 milliseconds we passed inlast_message
which is the message to send to the receiver.
def bounce(pid, message) when message != :timeout do
send(pid, message)
end
def handle_info(message, s = %{timeout: timeout}) do
{:noreply, %{s | last_message: message}, timeout}
end
When bounce
is called then this sends a message to the bouncer. When the message is handled, the last_message is saved to the state and the timeout
is returned as the third element in the tuple. The :timeout
message is received unless another bounce/2
occurs within the timeout period.
While handling the timeout we send the last_message (:generate_new_password
) back to our LiveView.
def handle_info(:timeout, s = %{receiver: receiver, last_message: last_message}) do
send(receiver, last_message)
{:noreply, %{s | last_message: nil}}
end
Testing
I have not been great about testing driving the LiveView. If I were to retrofit some tests, it would make sense to decouple the backend with a behaviour for stubs or mocks.
The debouncer is tested, as is the back-end.
Backend - generating the passwords
I will write this up in a later post.
Next functionality (possibly)
The app is pretty complete. I would quite to add an option for an alternative, more fun, pool of words extracted from a few books from Project Gutenberg.
Next projects
Possibly:
- A cheaper deploy without the load balancer, perhaps using DNSimple to manage SSL certificates
- Deploying by user Packer to build a custom AMI that include the release (and possibly ssl certificates).
- A package to generating the release scripts and Terraform etc .. for quick start project deployment.
- Something with Nerves which I haven’t used for a while, and miss.