Phoenix 1.7.11 with LiveView 0.20.9 - Authorization Intro

Article 1 - Simple backend Authorization Support for Static- and Live-Pages

In this article I will explore a simple authorization technique, restricting access to pages - for both static and live pages.

The source code can be found at: https://github.com/btihen-dev/phoenix_authorize

Article Series

Getting started

check / install newest erlang & elixir

# ERLANG First
# see available versions
asdf list all erlang
# see installed versions
asdf list erlang
# asdf install desired version
asdf install erlang 26.2.2
asdf local erlang 26.2.2

# Elixir (OTP must match erlang version)
asdf list all elixir
asdf list elixir
asdf install elixir 1.16.1-otp-26
#                              ^^ match erlang version
asdf local elixir 1.16.1-otp-26

ensure postgresql (or sqlite3)

https://www.sqlite.org/download.html
https://postgresapp.com/de/downloads.html

create project

mix archive.install hex phx_new
mix phx.new authorize
cd authorize
mix deps.update --all
git init
git add .
git commit -m "initial commit"

you can find the repo at: https://github/btihen_code/phoenix_authorize

Binary IDs

if you prefer UUID keys to serial IDs then you can easily do that with the following changes. This can be very helpful if you need to sync with external frontend or other external apps

update config/config.ex from:

# config/config.ex
config :authorize,
  ecto_repos: [Authorize.Repo],
  generators: [timestamp_type: :utc_datetime]

to:

# config/config.ex
config :authorize,
  ecto_repos: [Authorize.Repo],
  generators: [timestamp_type: :utc_datetime, binary_id: true]

now lets create the database:

mix ecto.create
iex -S mix phx.server

using phx.gen.auth

To help keep our code better organized all out user access / authentication code will be in the ‘Access’ namespace using --web Access

mix phx.gen.auth Accounts User users --web Access
mix deps.get
mix ecto.migrate
# start phoenix and create a user
iex -S mix phx.server

Now you should be logged in. Let’s see if the binary ID worked (using the iex cli)

import Ecto.Query
alias Authorize.Repo
alias Authorize.Accounts
alias Authorize.Accounts.User

# from Context file
Accounts.get_user_by_email("nyima@example.com")

# one user
Repo.get_by(User, email: email)

# all users
Repo.all(User)

we can see we have a uuid for an id and it works cool.

git add .
git commit -m "added users and authorization"

create a seeds file

organize user into core data

I like to organize the code into areas that is associated with usage - so, in this case, I will move users into ‘core’ - this is for code & data shared by all aspects of the application:

# create the core lib folder
mkdir lib/authorize/core/
# create the core test folder
mkdir test/authorize/core
mkdir test/support/fixtures/core

# move your lib code into the new area
mv lib/authorize/account* lib/authorize/core/.
# move the test and support code into 'core'
mv test/authorize/accounts* test/authorize/core/.
mv test/support/fixtures/accounts* test/support/fixtures/core/.

replace every Authorize.Accounts with Authorize.Core.Accounts

Lets make sure everything works

mix test

iex -S mix phx.server
# make a new user and login and logout

git add .
git commit -m "add users to the 'core'"

Now the file structure should look like:

$ tree -I _build -I deps
.
├── README.md
├── assets
├── config
├── lib
│   ├── authorize
│   │   ├── admin
│   │   │   └── authorized.ex
│   │   ├── application.ex
│   │   ├── core
│   │   │   ├── accounts
│   │   │   │   ├── user.ex
│   │   │   │   ├── user_notifier.ex
│   │   │   │   └── user_token.ex
│   │   │   └── accounts.ex
│   │   ├── mailer.ex
│   │   └── repo.ex
│   ├── authorize.ex
│   ├── authorize_web
│   │   ├── access
│   │   │   └── user_auth.ex
│   │   ├── components
│   │   │   ├── core_components.ex
│   │   │   ├── layouts
│   │   │   │   ├── app.html.heex
│   │   │   │   └── root.html.heex
│   │   │   └── layouts.ex
│   │   ├── controllers
│   │   │   ├── access
│   │   │   │   └── user_session_controller.ex
│   │   │   ├── error_html.ex
│   │   │   ├── error_json.ex
│   │   │   ├── page_controller.ex
│   │   │   ├── page_html
│   │   │   │   └── home.html.heex
│   │   │   └── page_html.ex
│   │   ├── endpoint.ex
│   │   ├── gettext.ex
│   │   ├── live
│   │   │   └── access
│   │   │       ├── user_confirmation_instructions_live.ex
│   │   │       ├── user_confirmation_live.ex
│   │   │       ├── user_forgot_password_live.ex
│   │   │       ├── user_login_live.ex
│   │   │       ├── user_registration_live.ex
│   │   │       ├── user_reset_password_live.ex
│   │   │       └── user_settings_live.ex
│   │   ├── router.ex
│   │   └── telemetry.ex
│   └── authorize_web.ex
├── mix.exs
├── mix.lock
├── priv
└── test
    ├── authorize
    │   └── core
    │       └── accounts_test.exs
    ├── authorize_web
    │   ├── access
    │   │   └── user_auth_test.exs
    │   ├── controllers
    │   │   ├── access
    │   │   │   └── user_session_controller_test.exs
    │   │   ├── error_html_test.exs
    │   │   ├── error_json_test.exs
    │   │   └── page_controller_test.exs
    │   └── live
    │       └── access
    │           ├── user_confirmation_instructions_live_test.exs
    │           ├── user_confirmation_live_test.exs
    │           ├── user_forgot_password_live_test.exs
    │           ├── user_login_live_test.exs
    │           ├── user_registration_live_test.exs
    │           ├── user_reset_password_live_test.exs
    │           └── user_settings_live_test.exs
    ├── support
    │   ├── conn_case.ex
    │   ├── data_case.ex
    │   └── fixtures
    │       └── core
    │           └── accounts_fixtures.ex
    └── test_helper.exs

System Emails

you can find (in Dev) any emails that would have been sent for user confirmation or password reminders, etc. at:

http://localhost:4000/dev/mailbox

Here you can read and test the messages - without them being sent to real people.

Authorization and Admin Panel

Let’s make an Admin Panel - where we control who has access to what areas of code.

User Migration

We will start by adding a ‘roles’ field (an array of roles). We will start with ‘user’ and ‘admin’ - maybe more later. So we start with the migration, which will look like:

mix ecto.gen.migration add_roles_to_user

# priv/repo/migrations/20240224134441_add_roles_to_user.exs
defmodule Vitali.Repo.Migrations.AddRolesToUser do
  use Ecto.Migration

  def change do
    alter table("users") do
      add :roles, {:array, :string}, default: ["user"], null: false
    end
  end
end

mix ecto.migrate

Now our existing user (& all new users) should have the roles: ["user"] let’s check in iex:

iex -S mix phx.server

alias Authorize.Core.Accounts

# from Context file
Accounts.get_user_by_email("nyima@example.com")


#Authorize.Core.Accounts.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
  email: "c",
  confirmed_at: nil,
  inserted_at: ~U[2024-02-24 10:31:40Z],
  updated_at: ~U[2024-02-24 10:31:40Z],
  ...
>

hmm - not what I expected - I was hoping to see the roles - lets check the DB to see if the migration worked:

$ psql -d authorize_dev

# list record vertically
authorize_dev=# \x

# show users
authorize_dev=# select * from users;
-[ RECORD 1 ]---+-------------------------------------------------------------
id              | 1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3
email           | nyima@example.com
hashed_password | $2b$12$nHO8KooIVj7CIjEKWm5CsOXlp0ruIdHmZvUV2VvP6rLivFR24b4/C
confirmed_at    |
inserted_at     | 2024-02-24 10:31:40
updated_at      | 2024-02-24 10:31:40
roles           | {user}

# exit psql
\q

excellent we have the new roles and the default is ["user"] (in elixir) as you can seen in postgres land it is stored as {user}.

Update User schema

So the problem is on the elixir side - we forgot to update the user schema after the migration change. We need to add our new field (column) using: field :roles, {:array, :string}, default: ["user"]

# lib/authorize/core/accounts/user.ex
defmodule Authorize.Core.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
    # add the roles using the following;
    field :roles, {:array, :string}, default: ["user"]

    timestamps(type: :utc_datetime)
  end
  # ...
  # changesets (to update later)
  # ...
end

now let’s see if User in Phoenix/Elixir has the roles

iex -S mix phx.server
# or if already within iex
recompile

alias Authorize.Core.Accounts

# from Context file
user = Accounts.get_user_by_email("nyima@example.com")

Authorize.Core.Accounts.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
  email: "nyima@example.com",
  confirmed_at: nil,
  roles: ["user"],
  inserted_at: ~U[2024-02-24 10:31:40Z],
  updated_at: ~U[2024-02-24 10:31:40Z],
  ...
>

# to access the info use:
user.roles

["user"]

Nice, now we have what is expected in our users.

git add .
git commit -m "add roles to Users"

Study the User Authentication routes

Let’s look to see how this is done for the login pages (## Authentication routes) see lib/authorize_web/router.ex:64:

# lib/authorize_web/router.ex
defmodule AuthorizeWeb.Router do
  use AuthorizeWeb, :router
  # ...
  scope "/access", AuthorizeWeb.Access, as: :access do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{AuthorizeWeb.Access.UserAuth, :ensure_authenticated}] do
      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
    end
  end
  # ...
end

We can see that standard routes are protected using: pipe_through [:browser, :require_authenticated_user] adds the require_authenticated_user plug in user_auth

Looking at the live session named: require_authenticated_user uses

live_session :require_authenticated_user,
      on_mount: [{AuthorizeWeb.Access.UserAuth, :ensure_authenticated}] do

which adds an on_mount filter called ensure_authenticated in the user_auth file.

Go find and look at the code require_authenticated_user and ensure_authenticated added by the auth_generator in the user_auth file. These are the basis of the code we will write in the following section.

Build a restricted Admin Panel (for logged in users)

Since our new page page has no new resources we will make the UsersLive page within the ‘Admin’ area.

# lets make an admin area within liveview
mkdir lib/authorize_web/live/admin
# create the file needed
touch lib/authorize_web/live/admin/admin_roles_live.ex
# starter template code
cat <<EOF > lib/authorize_web/live/admin/admin_roles_live.ex
defmodule AuthorizeWeb.Admin.AdminRolesLive do
  use Phoenix.LiveView

  @impl true
  def render(assigns) do
    ~H"""
    <h1>Admin.UsersLive</h1>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end
EOF

Lets start by simply testing our page by adding a simple route - then we will add restrictions:

we want a scope of “/admin” - so to make it only available to logged we can add the following to the end of the routes file:

# lib/authorize_web/router.ex
defmodule AuthorizeWeb.Router do
  use AuthorizeWeb, :router
  # ...
  ## Admin Routes (We need to add the scope 'Admin' here!)
  scope "/admin", AuthorizeWeb.Admin do
    pipe_through [:browser]

    live("/admin_roles", AdminRolesLive, :index)
  end
end
Add Authentication requirement (via plug routing)

hopefully you can now get to: http://localhost:4000/admin/admin_roles

we need to add this to the routes - we will start by just making sure it can only be accessed by logged in users.

let’s protect the standard routing (plug) - with:

  scope "/admin", AuthorizeWeb.Admin do
    pipe_through [:browser, :require_authenticated_user]

    live("/admin_roles", AdminRolesLive, :index)
  end

hopefully now if you open an ‘incognito’ non-logged in browser you are not able to access this page and are redirected to the login / signin page.

Add Authentication requirement (via liveview session)

we want a scope of “/admin” - so to make it only available to logged we can add the following to the end of the routes file:

# lib/authorize_web/router.ex
defmodule AuthorizeWeb.Router do
  use AuthorizeWeb, :router
  # ...
  ## Admin Routes (We need to add the scope 'Admin' here!)
  scope "/admin", AuthorizeWeb.Admin do
    pipe_through [:browser, :require_authenticated_user]

    # session name `:live_admin` - can be what you want but must MUST be unique
    # otherwise you get the error: `attempting to redefine live_session`
    live_session :live_admin,
      on_mount: [{AuthorizeWeb.Access.UserAuth, :ensure_authenticated}] do
      live("/admin_roles", AdminRolesLive, :index)
    end
  end
end

lets see if we can get to this page http://localhost:4000/admin/users when logged in and not while logged out

Restrict Admin Page to Admins only

# lib/authorize/core/accounts/user.ex
def admin?(user), do: "admin" in user.roles || user.email == "nyima@example.com"
# lib/authorize_web/access/user_auth.ex

  # new static route plug
  def require_admin_user(conn, _opts) do
    if Authorize.Core.Accounts.User.admin?(conn.assigns.current_user) do
      conn
    else
      conn
      |> put_flash(:error, "You must be an admin to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/")
      |> halt()
    end
  end

  # new liveview session mount check
  def on_mount(:ensure_admin, _params, _session, socket) do
    if Authorize.Core.Accounts.User.admin?(socket.assigns.current_user) do
      {:cont, socket}
    else
      socket =
        socket
        |> Phoenix.LiveView.put_flash(:error, "You must be admin to access this page.")
        |> Phoenix.LiveView.redirect(to: ~p"/")

      {:halt, socket}
    end
  end
# lib/authorize_web/router.ex
  scope "/admin", AuthorizeWeb.Admin, as: :admin do
    pipe_through [:browser, :require_authenticated_user, :require_admin_user]

    live_session :live_admin,
      on_mount: [{AuthorizeWeb.Access.UserAuth, :ensure_authenticated}, {AuthorizeWeb.Access.UserAuth, :ensure_admin}] do
      live("/admin_roles", AdminRolesLive, :index)
      # add other admin live routes as needed
    end
  end
end

Now we have an admin page that requires an Admin!

git add .
git commit -m "added an admin page restricted to admins"

add a grant_admin_changeset

# lib/authorize/core/accounts/user.ex
  def admin_roles_changeset(user, attrs, _opts \\ []) do
    allowed_roles = ["admin", "user"] # allowed roles here

    user
    |> cast(attrs, [:roles])
    |> validate_required([:roles])
    |> validate_roles(:roles, allowed_roles)
  end

  defp validate_roles(changeset, field, allowed_roles) do
    roles = get_field(changeset, field)

    if Enum.all?(roles, fn role -> role in allowed_roles end) do
      changeset
    else
      add_error(changeset, field, "has invalid roles")
    end
  end

Lets now try these changes out in iex (and figure out what we need for code to put into Accounts):

import Ecto.Query
alias Authorize.Repo
alias Authorize.Core.Accounts
alias Authorize.Core.Accounts.User

# from Context file
user = Accounts.get_user_by_email("nyima@example.com")
#Authorize.Core.Accounts.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
  email: "nyima@example.com",
  confirmed_at: nil,
  roles: ["user"],
  inserted_at: ~U[2024-02-24 10:31:40Z],
  updated_at: ~U[2024-02-24 10:31:40Z],
  ...
>

# now that we have a user lets add "admin" to the roles
new_roles = ["admin" | user.roles]
["admin", "user"]

# feed our user and our new roles into the changeset and see if we get a valid or error changeset back;
changeset = User.admin_roles_changeset(user, %{roles: new_roles})
#Ecto.Changeset<
  action: nil,
  changes: %{roles: ["admin", "user"]},
  errors: [],
  data: #Authorize.Core.Accounts.User<>,
  valid?: true
>

# looks good lets save / update our user
{:ok, user} =
  user |> User.admin_roles_changeset(%{roles: new_roles}) |> Repo.update()

user
#Authorize.Core.Accounts.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
  email: "nyima@example.com",
  confirmed_at: nil,
  roles: ["admin", "user"],
  inserted_at: ~U[2024-02-24 10:31:40Z],
  updated_at: ~U[2024-02-25 14:26:32Z],
  ...
>

# let's also be sure we can't add other groups 'boss'
new_roles = ["boss" | user.roles]
["boss", "admin", "user"]
changeset =
  user |> User.admin_roles_changeset(%{roles: new_roles}) |> Repo.update()
{:error,
 #Ecto.Changeset<
   action: :update,
   changes: %{roles: ["boss", "admin", "user"]},
   errors: [roles: {"has invalid roles", []}],
   data: #Authorize.Core.Accounts.User<>,
   valid?: false
 >}

Sweet this works as expected - now we can make a context / boundary for our Admin area lets make the folder and file:

mkdir lib/authorize/admin
touch lib/authorize/admin/authorization.ex
cat <<EOF > lib/authorize/admin/authorization.ex
defmodule Authorize.Admin.Authorized do
  import Ecto.Query, warn: false
  alias Authorize.Repo
  alias Authorize.Core.Accounts
  alias Authorize.Core.Accounts.User

  def grant_admin(user) do
    new_roles =
      ["admin" | user.roles]
      |> Enum.uniq()

    user
    |> User.admin_roles_changeset(%{roles: new_roles})
    |> Repo.update()
  end

  def revoke_admin(user) do
    user
    |> User.admin_roles_changeset(%{roles: user.roles -- ["admin"]})
    |> Repo.update()
  end
end
EOF

Lets now try our new code in iex:

iex -S mix phx.server

import Ecto.Query
alias Authorize.Repo
alias Authorize.Core.Accounts
alias Authorize.Admin.Authorized

# from Context file
user = Accounts.get_user_by_email("nyima@example.com")
#Authorize.Core.Accounts.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
  email: "nyima@example.com",
  confirmed_at: nil,
  roles: ["admin", "user"],
  inserted_at: ~U[2024-02-24 10:31:40Z],
  updated_at: ~U[2024-02-25 14:26:32Z],
  ...
>

{:ok, user} = Authorized.revoke_admin(user)
{:ok,
 #Authorize.Core.Accounts.User<
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
   email: "nyima@example.com",
   confirmed_at: nil,
   roles: ["user"],
   inserted_at: ~U[2024-02-24 10:31:40Z],
   updated_at: ~U[2024-02-25 14:34:38Z],
   ...
 >}

{:ok, user} = Authorized.grant_admin(user)
{:ok,
 #Authorize.Core.Accounts.User<
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: "1349f6b9-e3f1-4d7a-813b-d1f1aa49fbe3",
   email: "nyima@example.com",
   confirmed_at: nil,
   roles: ["admin", "user"],
   inserted_at: ~U[2024-02-24 10:31:40Z],
   updated_at: ~U[2024-02-25 14:34:46Z],
   ...
 >}

Nice this works well. Let’s go back and properly build the User.admin? code to check if we have an admin and not hard-coded by the email address.

# lib/authorize/core/accounts/user.ex

# from:
def admin?(user), do: user.email == "nyima@example.com"
# to
def admin?(user), do: "admin" in user.roles

Now lets test our access to http://localhost:4000/admin/admin_roles via the account that has the admin role and without.

We can make a few additional accounts in the seeds file to simplify testing if you wish:

# priv/repo/seeds.exs
alias Authorize.Core.Accounts
alias Authorize.Admin.Authorized

users = [
  %{email: "batman@example.com", password: "P4ssword-f0r-You"},
  %{email: "wolverine@example.com", password: "P4ssword-f0r-You"},
  %{email: "hulk@example.com", password: "P4ssword-f0r-You"},
  %{email: "drmanhattan@example.com", password: "P4ssword-f0r-You"},
  %{email: "ironman@example.com", password: "P4ssword-f0r-You"}
]

Enum.map(users, fn user -> Accounts.register_user(user) end)

batman = Accounts.get_user_by_email("batman@example.com")
Authorized.grant_admin(batman)
hulk = Accounts.get_user_by_email("hulk@example.com")
Authorized.grant_admin(hulk)

you can run the seeds with: mix run priv/repo/seeds.exs

Build a working admin page

we now can make our new admin page do something useful.

since want to control admin status we will need all the users

So let’s start by adding a function list_users in the Admin.Authorized context (to help with user privacy concerns):

# lib/authorize/admin/authorization.ex
defmodule Authorize.Admin.Authorized do
  # ...
  # sorted by email - unsorted is just `Repo.all(User)`
  def list_users(), do: Repo.all(from u in User, order_by: [asc: u.email])
  # ...
end

you can check this works on the cli with:

iex -S mix phx.server

alias Authorize.Admin.Authorized

users = Authorized.list_users()

# should now have the list of all accounts from the seeds file

so let’s add the users to on mount to the admin_roles_live page:

defmodule AuthorizeWeb.Admin.AdminRolesLive do
  use Phoenix.LiveView

  alias Authorize.Core.Accounts
  alias Authorize.Admin.Authorized
  # ...
  @impl true
  def mount(_params, _session, socket) do
    all_users = Authorized.list_users()
    socket_w_users = assign(socket, users: all_users)

    {:ok, socket_w_users}
  end
  # ...
end

Now that we have the users we need to list users by changing def render in admin_roles_live to:

# lib/authorize_web/live/admin/admin_roles_live.ex
  @impl true
  def render(assigns) do
    ~H"""
    <h1 class="text-4xl text-center">Admin</h1>
    <p class="text-center">total users: <%= @users |> Enum.count() %></p>
    <div style="margin-top: 20px;">
      <table class="mx-auto">
        <tr>
          <th class="px-4 py-2">Email</th>
          <th class="px-4 py-2">Roles</th>
          <th class="px-4 py-2">Action</th>
        </tr>
        <%= for user <- @users do %>
          <tr class={if rem(Enum.find_index(@users, &(&1 == user)), 2) == 0, do: "bg-gray-100"}>
            <%= if User.admin?(user) do %>
              <td class="px-4 py-2 font-bold text-red-800"><%= user.email %></td>
            <% else %>
              <td class="px-4 py-2"><%= user.email %></td>
            <% end %>
            <td class="px-4 py-2"><%= user.roles |> Enum.join(", ") %></td>
            <td class="px-4 py-2 text-right">
              <!-- no need to gran admin to an admin -->
              <button
                  :if={!User.admin?(user)}
                  class="mr-2 bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
                Grant
              </button>
              <!-- don't allow current user to disable self & only to those who are already admins -->
              <button
                  :if={@current_user.id != user.id && User.admin?(user)}
                  class="bg-orange-500 hover:bg-orange-700 text-white font-bold py-2 px-4 rounded">
                Revoke
              </button>
              <!-- do not allow current user to delete ones own account -->
              <button
                  :if={@current_user.id != user.id}
                  class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
                  onclick="return confirm('Are you sure you want to delete this item?');">
                Delete
              </button>
            </td>
          </tr>
        <% end %>
      </table>
    </div>
    """
  end

now lets make the buttons reactive - we will add a grant, revoke & delete event_handers:

# lib/authorize_web/live/admin/admin_roles_live.ex
defmodule AuthorizeWeb.Admin.AdminRolesLive do
  use Phoenix.LiveView
  alias Authorize.Core.Accounts
  alias Authorize.Core.Accounts.User
  alias Authorize.Admin.Authorized

  # ...

  @impl true
  def handle_event("grant", %{"id" => id}, socket) do
    Authorized.grant_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("revoke", %{"id" => id}, socket) do
    Authorized.revoke_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    Authorized.delete_user(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

end

oops - we get an error:

** (KeyError) key :roles not found in: 562

If you are using the dot syntax, such as map.field, make sure the left-hand side of the dot is a map
    (authorize 0.1.0) lib/authorize/core/accounts.ex:19: Authorize.Admin.Authorized.grant_admin/1

we can fix this with pattern matching on the id and the calling the original function:

# lib/authorize/core/accounts.ex
  def grant_admin(uuid) when is_binary(uuid), do: grant_admin(get_user!(uuid))
  def grant_admin(%User{} = user) do
    new_roles = ["admin" | user.roles] |> Enum.uniq()
    user
    |> User.admin_roles_changeset(%{roles:  new_roles})
    |> Repo.update()
  end

  def revoke_admin(uuid) when is_binary(uuid), do: revoke_admin(get_user!(uuid))
  def revoke_admin(%User{} = user) do
    user
    |> User.admin_roles_changeset(%{roles: user.roles -- ["admin"]})
    |> Repo.update()
  end

NOTE: Since I am using a binary uuid - I don’t need to convert the string from the frontend to an integer - I was using the traditional integer the code would look more like:

  def grant_admin(id_string) when is_binary(id_string) do
    {id, _text} = Integer.parse(id_string)
    user = get_user!(id)
    grant_admin(user)
  end

Now we need to update the buttons with: phx-click="eventName (event triggered) and phx-value-id={user.id} (data to send to event handler) so now our buttons will look like:

# lib/authorize_web/live/admin/admin_roles_live.ex
            <td class="px-4 py-2 text-right">
              <button
                :if={!User.admin?(user)}
                phx-click="grant"
                phx-value-id={user.id}
                class="mr-2 bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
              >
                Grant
              </button>

              <button
                :if={@current_user.id != user.id && User.admin?(user)}
                phx-click="revoke"
                phx-value-id={user.id}
                class="bg-orange-500 hover:bg-orange-700 text-white font-bold py-2 px-4 rounded"
              >
                Revoke
              </button>

              <button
                :if={@current_user.id != user.id}
                phx-click="delete"
                phx-value-id={user.id}
                class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
                onclick="return confirm('Are you sure you want to delete this item?');"
              >
                Delete
              </button>
            </td>

or all together is should now look like:

# lib/authorize_web/live/admin/admin_roles_live.ex
defmodule AuthorizeWeb.Admin.AdminRolesLive do
  use Phoenix.LiveView
  alias Authorize.Core.Accounts
  alias Authorize.Core.Accounts.User
  alias Authorize.Admin.Authorized

  @impl true
  def render(assigns) do
    ~H"""
    <h1 class="text-4xl text-center">Admin</h1>
    <p class="text-center">total users: <%= @users |> Enum.count() %></p>
    <div style="margin-top: 20px;">
      <table class="mx-auto">
        <tr>
          <th class="px-4 py-2">Email</th>
          <th class="px-4 py-2">Roles</th>
          <th class="px-4 py-2">Action</th>
        </tr>
        <%= for user <- @users do %>
          <tr class={if rem(Enum.find_index(@users, &(&1 == user)), 2) == 0, do: "bg-gray-100"}>
            <%= if User.admin?(user) do %>
              <td class="px-4 py-2 font-bold text-red-800"><%= user.email %></td>
            <% else %>
              <td class="px-4 py-2"><%= user.email %></td>
            <% end %>
            <td class="px-4 py-2"><%= user.roles |> Enum.join(", ") %></td>
            <td class="px-4 py-2 text-right">
              <!-- no need to gran admin to an admin -->
              <button
                :if={!User.admin?(user)}
                phx-click="grant"
                phx-value-id={user.id}
                class="mr-2 bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
              >
                Grant
              </button>
              <!-- don't show revoke to current user and only to those who are already admins -->
              <button
                :if={@current_user.id != user.id && User.admin?(user)}
                phx-click="revoke"
                phx-value-id={user.id}
                class="bg-orange-500 hover:bg-orange-700 text-white font-bold py-2 px-4 rounded"
              >
                Revoke
              </button>
              <button
                :if={@current_user.id != user.id}
                phx-click="delete"
                phx-value-id={user.id}
                class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
                onclick="return confirm('Are you sure you want to delete this item?');"
              >
                Delete
              </button>
            </td>
          </tr>
        <% end %>
      </table>
    </div>
    """
  end

  @impl true
  def mount(_params, _session, socket) dos
    {:ok, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("grant", %{"id" => id}, socket) do
    Authorized.grant_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("revoke", %{"id" => id}, socket) do
    Authorized.revoke_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    Authorized.delete_user(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end
end

Now Our Admin page should look like:

admin page image

Making the LiveView (live) - ’near realtime updates’

To do this we will use Phoenix built-in PubSub.

First we need to build our PubSub Channel in Authorized:

# lib/authorize/admin/authorized.ex
  # ...
  # Authorized PubSub
  def subscribe("authorized:admin_role_updates") do
    Phoenix.PubSub.subscribe(Authorize.PubSub, "authorized:admin_role_updates")
  end

  def broadcast("authorized:admin_role_updates") do
    Phoenix.PubSub.broadcast(
      Authorize.PubSub,
      "authorized:admin_role_updates",
      {:admins_updated, list_users()}
    )
  end

Now that we have build our channels where we can subscribe and publish - we need to add the publish to the grant, revoke and delete admin functions - so that changes will be seen - the easiest thing to do would look like:

# lib/authorize/admin/authorized.ex
  # ...

  # admin management
  def delete_user(id) do
    user = Repo.get!(User, id)
    updated_user = Repo.delete(user)
    broadcast("authorized:admin_role_updates")
    updated_user
  end

  def grant_admin(uuid) when is_binary(uuid), do: grant_admin(Accounts.get_user!(uuid))

  def grant_admin(%User{} = user) do
    new_roles =
      ["admin" | user.roles]
      |> Enum.uniq()

    updated_user =
      user
      |> User.admin_roles_changeset(%{roles: new_roles})
      |> Repo.update()

     broadcast("authorized:admin_role_updates")
     updated_user
    end
  end

  def revoke_admin(uuid) when is_binary(uuid), do: revoke_admin(Accounts.get_user!(uuid))

  def revoke_admin(%User{} = user) do
    updated =
      user
      |> User.admin_roles_changeset(%{roles: user.roles -- ["admin"]})
      |> Repo.update()

     broadcast("authorized:admin_role_updates")
     updated_user

    case updated do
      {:ok, user} ->
        # Broadcast the update (new)
        broadcast("authorized:admin_role_updates")
        # return user (like before)
        {:ok, user}

      {:error, changeset} ->
        {:error, changeset}
    end
  end

NOTE: if you thought there could be problems with load and thus only want to send the broadcast when there is really a change (or perhaps you want to handle and not just return the errors) - you could update the code to look like:

# lib/authorize/admin/authorized.ex
  def revoke_admin(%User{} = user) do
    updated_user =
      user
      |> User.admin_roles_changeset(%{roles: user.roles -- ["admin"]})
      |> Repo.update()

    case updated_user do
      {:ok, user} ->
        # Broadcast the update (new)
        broadcast("authorized:admin_role_updates")
        # return user (like before)
        {:ok, user}

      {:error, changeset} ->
        # return(or handle) error
        {:error, changeset}
    end

its actually more idiomatic (or at least my preference) to rewrite this using a with instead to handle the two cases:

# lib/authorize/admin/authorized.ex
  def grant_admin(%User{} = user) do
    new_roles =
      ["admin" | user.roles]
      |> Enum.uniq()

    with {:ok, user} <-
      user
      |> User.admin_roles_changeset(%{roles: new_roles})
      |> Repo.update() do
        # Broadcast the update on success
        broadcast("authorized:admin_role_updates")
        {:ok, user}
    else
      {:error, changeset} ->
        # Handle the error case
        {:error, changeset}
    end
  end

All the Authorized changes should now look like:

all the changes together now look like:

# lib/authorize/admin/authorized.ex
defmodule Authorize.Admin.Authorized do
  import Ecto.Query, warn: false
  alias Authorize.Repo
  alias Authorize.Core.Accounts
  alias Authorize.Core.Accounts.User

  # Authorized PubSub
  def subscribe("authorized:admin_role_updates") do
    Phoenix.PubSub.subscribe(Authorize.PubSub, "authorized:admin_role_updates")
  end

  def broadcast("authorized:admin_role_updates") do
    Phoenix.PubSub.broadcast(
      Authorize.PubSub,
      "authorized:admin_role_updates",
      {:admins_updated, list_users()}
    )
  end

  # unsorted
  # def list_users(), do: Repo.all(User)
  def list_users(), do: Repo.all(from u in User, order_by: [asc: u.email])

  def delete_user(id) do
    user = Repo.get!(User, id)

    with {:ok, user} <- Repo.delete(user) do
      # Broadcast the update only on success
      broadcast("authorized:admin_role_updates")
      {:ok, user}
    else
      {:error, changeset} ->
        # return(or handle) error
        {:error, changeset}
    end
  end

  # admin management
  def grant_admin(uuid) when is_binary(uuid), do: grant_admin(Accounts.get_user!(uuid))

  def grant_admin(%User{} = user) do
    new_roles =
      ["admin" | user.roles]
      |> Enum.uniq()

    with {:ok, user} <-
           user
           |> User.admin_roles_changeset(%{roles: new_roles})
           |> Repo.update() do
      # Broadcast the update only on success
      broadcast("authorized:admin_role_updates")
      {:ok, user}
    else
      {:error, changeset} ->
        # return(or handle) error
        {:error, changeset}
    end
  end

  def revoke_admin(uuid) when is_binary(uuid), do: revoke_admin(Accounts.get_user!(uuid))

  def revoke_admin(%User{} = user) do
    new_roles = user.roles -- ["admin"]

    with {:ok, user} <-
           user
           |> User.admin_roles_changeset(%{roles: new_roles})
           |> Repo.update() do
      # Broadcast the update only on success
      broadcast("authorized:admin_role_updates")
      # return user like normal
      {:ok, user}
    else
      {:error, changeset} ->
        # return(or handle) error
        {:error, changeset}
    end
  end
end

To enable this ‘PubSub’ we have to register on_mount (after connected to websockets - not on the first traditional mount) using: if connected?(socket), do: Accounts.subscribe("accounts:admin_updates") and add a ‘handle_info` function to our Admin LivePage to receive the broadcasts and update the page this would then look like:

# lib/authorize_web/live/admin/admin_roles_live.ex
  # ...s

  @impl true
  def mount(_params, _session, socket) do
    # Subscribe to a PubSub topic (if connected - mount happen twice -
    # once for initial load and once to do liveView socket connection)
    if connected?(socket), do: Accounts.subscribe("accounts:admin_updates")

    {:ok, assign(socket, users: Accounts.list_users())}
  end

  # handle info ALWAYS Come after mount and before handle_events!
  def handle_info({:admins_updated, users}, socket) do
    socket = assign(socket, users: users)
    {:noreply, socket}
  end
  # ...

And all the LivePage changes will now look like:

defmodule AuthorizeWeb.Admin.AdminRolesLive do
  use Phoenix.LiveView
  alias Authorize.Core.Accounts
  alias Authorize.Core.Accounts.User
  alias Authorize.Admin.Authorized

  @impl true
  def render(assigns) do
    ~H"""
    <h1 class="text-4xl text-center">Admin</h1>
    <p class="text-center">total users: <%= @users |> Enum.count() %></p>
    <div style="margin-top: 20px;">
      <table class="mx-auto">
        <tr>
          <th class="px-4 py-2">Email</th>
          <th class="px-4 py-2">Roles</th>
          <th class="px-4 py-2">Action</th>
        </tr>
        <%= for user <- @users do %>
          <tr class={if rem(Enum.find_index(@users, &(&1 == user)), 2) == 0, do: "bg-gray-100"}>
            <%= if User.admin?(user) do %>
              <td class="px-4 py-2 font-bold text-red-800"><%= user.email %></td>
            <% else %>
              <td class="px-4 py-2"><%= user.email %></td>
            <% end %>
            <td class="px-4 py-2"><%= user.roles |> Enum.join(", ") %></td>
            <td class="px-4 py-2 text-right">
              <!-- no need to gran admin to an admin -->
              <button
                :if={!User.admin?(user)}
                phx-click="grant"
                phx-value-id={user.id}
                class="mr-2 bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
              >
                Grant
              </button>
              <!-- don't show revoke to current user and only to those who are already admins -->
              <button
                :if={@current_user.id != user.id && User.admin?(user)}
                phx-click="revoke"
                phx-value-id={user.id}
                class="bg-orange-500 hover:bg-orange-700 text-white font-bold py-2 px-4 rounded"
              >
                Revoke
              </button>
              <button
                :if={@current_user.id != user.id}
                phx-click="delete"
                phx-value-id={user.id}
                class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
                onclick="return confirm('Are you sure you want to delete this item?');"
              >
                Delete
              </button>
            </td>
          </tr>
        <% end %>
      </table>
    </div>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    # Subscribe to a PubSub topic (if connected - mount happen twice -
    # once for initial load and once to do liveView socket connection)
    if connected?(socket), do: Authorized.subscribe("authorized:admin_role_updates")

    {:ok, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_info({:admins_updated, users}, socket) do
    socket = assign(socket, users: users)
    {:noreply, socket}
  end

  @impl true
  def handle_event("grant", %{"id" => id}, socket) do
    Authorized.grant_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("revoke", %{"id" => id}, socket) do
    Authorized.revoke_admin(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    Authorized.delete_user(id)
    {:noreply, assign(socket, users: Authorized.list_users())}
  end
end

Now when you open two admin pages if you change one the other one is also updated!

two web browsers with the same data

Our File structure should now look like:

$ tree -I _build -I deps
.
├── README.md
├── assets
├── config
├── lib
│   ├── authorize
│   │   ├── admin
│   │   │   └── authorized.ex
│   │   ├── application.ex
│   │   ├── core
│   │   │   ├── accounts
│   │   │   │   ├── user.ex
│   │   │   │   ├── user_notifier.ex
│   │   │   │   └── user_token.ex
│   │   │   └── accounts.ex
│   │   ├── mailer.ex
│   │   └── repo.ex
│   ├── authorize.ex
│   ├── authorize_web
│   │   ├── access
│   │   │   └── user_auth.ex
│   │   ├── components
│   │   ├── controllers
│   │   │   └── access
│   │   │       └── user_session_controller.ex
│   │   ├── endpoint.ex
│   │   ├── gettext.ex
│   │   ├── live
│   │   │   ├── access
│   │   │   │   ├── user_confirmation_instructions_live.ex
│   │   │   │   ├── user_confirmation_live.ex
│   │   │   │   ├── user_forgot_password_live.ex
│   │   │   │   ├── user_login_live.ex
│   │   │   │   ├── user_registration_live.ex
│   │   │   │   ├── user_reset_password_live.ex
│   │   │   │   └── user_settings_live.ex
│   │   │   └── admin
│   │   │       └── admin_roles_live.ex
│   │   ├── router.ex
│   │   └── telemetry.ex
│   └── authorize_web.ex
├── mix.exs
├── mix.lock
├── priv
└── test

This feels well structured and easy to navigate.

Let’s take a snapshot with:

git add .
git commit -m "add live updates to admin page"
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature