Until now, I thought I had a reasonably good understanding on the lifecycle of a Liveview, turns out I’ve been pretty wrong.
Here’s my case:
Fresh Liveview app generated, latest versions of the libraries, then mix phx.gen.auth....
Everything works great. Then added a simple liveview page:
def render(assigns) do
~H"""
<%= if assigns[:current_user] do %>
<%= @current_user.email %>
<% else %>
No user
<% end %>
"""
end
Echoes out No user in the page. But I’m logged in, and the current_user.email is displayed at the top of the page by the code generated by gen.auth in root.html.heex.
And yes, if I replace the if with if :current_user, the liveview bombs out with key not found. And yes, I have triple checked that the plug :fetch_current_user is in the browser pipeline in the router.
Also, if I add the same snippet:
<%= if @current_user do %>
<%= @current_user.email %>
<% else %>
No user
<% end %>
to the standard home.html.heex, works like a charm.
Somehow, it looks like the socket given to the home controller and the socket given to the liveview are different.
So, my questions are:
why it’s working in home and not in test, even though the request goes through the same pipeline? Why current_user is not in the socket assigns? Where do I actually can use @current_user ?
more generic, how do I access @current_user in a liveview?
am I wrong in believing that the socket first goes through the browser pipeline, then the mount in the liveview is called with the updated socket?
It took me way too long to understand this stuff. Here are 3 crucial pieces of info that I wish I had understood 3 years ago.
Your LiveView is mounted twice! One time without a websocket and the second time with a websocket. This is why the mount function gets called twice when you visit a page.
The two mounts happen in different “processes”. I’m talking about Erlang VM processes here. Like when you run self() and it returns a PID, this will be different if you run it in mount.
The assigns on the conn (which is a Plug.Conn struct) has nothing to do with the socket assigns. Rip this bandaid off now. Conn assigns and socket assigns are completely independent.
Point 3 is relevant to your situation.
You need something like this:
defmodule MyAppWeb.OnMounts.AssignUser do
@moduledoc """
Session hook for setting the `@current_user` assign on the socket.
If a **valid** `user_id` or `user_token` is set in the session, the user will be assigned, otherwise `@current_user` will be `nil`.
"""
import Phoenix.Component
alias MyApp.Accounts.User
alias MyApp.UserTokens
alias Phoenix.LiveView.Socket
@spec on_mount(atom(), map(), map(), Socket.t()) :: {:cont, Socket.t()} | {:halt, Socket.t()}
def on_mount(:default, _params, _session, %{assigns: %{current_user: %User{}}} = socket) do
{:cont, socket}
end
def on_mount(:default, _params, session, socket) do
user_token = Map.get(session, "user_token")
current_user = UserTokens.fetch_user_for_session_token(user_token)
current_user_id = if current_user, do: current_user.id
Logger.metadata(current_user_id: current_user_id)
socket
|> assign(:current_user, current_user)
|> then(&{:cont, &1})
end
end
Then in your live view modules you just
on_mount MyAppWeb.OnMounts.AssignUser
Although moving that to myapp_web.ex where you have a def live_view do is probably a nicer way.
Finally, note that I am using the token in the session. Don’t put your user in the session! Don’t put your user ID in the session! Just do the duplicate work of getting the user for that token.
I would not put the %User{} struct in the session because its just too big.
I would not put the user_id in the session because the “session” is actually just a cookie on the user’s device. In theory, they could tamper with it. A token can expire but a user ID can not expire.
You can definitely design your system so that it would be safe to put the user_id in the session cookie. Encrypt the cookie, rotate the encryption salt regularly, never expose database IDs…all of these and more.
However, it is easier and safer to simply not put the user_id in the session.