Skip to content

Instantly share code, notes, and snippets.

@nileshtrivedi
Created June 11, 2025 11:16
Show Gist options
  • Save nileshtrivedi/d8067e9c946bc125aaa48ffd41ae1370 to your computer and use it in GitHub Desktop.
Save nileshtrivedi/d8067e9c946bc125aaa48ffd41ae1370 to your computer and use it in GitHub Desktop.
single-file todo list app with ash framework and elixir
Mix.install([
{:ash, "== 3.5.17"},
{:ash_authentication, "== 4.9.0"},
{:ash_authentication_phoenix, "== 2.8.0"},
{:phoenix_playground, "== 0.1.6"},
{:picosat_elixir, "== 0.2.3"},
{:jason, "== 1.4.4"},
{:bcrypt_elixir, "== 3.3.2"}
], consolidate_protocols: false)
defmodule MyApp do
end
defmodule MyApp.Support do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource MyApp.Support.User
resource MyApp.Support.Todo
end
end
defmodule MyApp.Support.User do
use Ash.Resource,
domain: MyApp.Support,
data_layer: Ash.DataLayer.Ets,
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
attributes do
uuid_primary_key :id
attribute :username, :string, allow_nil?: false, public?: true
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true, public?: false
end
identities do
identity :unique_username, [:username], pre_check?: true
end
actions do
defaults [:read]
end
authentication do
strategies do
password :password do
identity_field :username
confirmation_required? false
sign_in_tokens_enabled? false
end
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy always() do
forbid_if always()
end
end
relationships do
has_many :todos, MyApp.Support.Todo
end
end
defmodule MyApp.Support.Todo do
# This turns this module into a resource
use Ash.Resource,
domain: MyApp.Support,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
actions do
defaults [:read, :destroy]
create :add do
accept [:task, :user_id]
change set_attribute(:status, "pending")
end
update :toggle do
accept []
change atomic_update(:status, expr(if status == :pending, do: :done, else: :pending))
end
end
attributes do
uuid_primary_key :id
attribute :user_id, :uuid do
allow_nil? false
public? true
end
attribute :task, :string do
allow_nil? false
public? true
end
attribute :status, :atom do
constraints one_of: [:pending, :done]
default :pending
allow_nil? false
public? true
end
end
relationships do
belongs_to :user, MyApp.Support.User
end
policies do
policy do
authorize_if always()
end
end
end
defmodule TodoLive do
use Phoenix.LiveView
require Ash.Query
def get_user_todos(user_id) do
MyApp.Support.Todo
|> Ash.Query.filter(user_id == ^user_id)
|> Ash.read!()
end
def mount(_params, session, socket) do
todos = get_user_todos(session.user_id)
{:ok, assign(socket, todos: todos, user_id: session.user_id, new_todo_name: "drink water")}
end
def handle_event("create_todo", %{"name" => name}, socket) do
if name == "" do
{:noreply, socket}
else
MyApp.Support.Todo
|> Ash.Changeset.for_create(:add, %{task: String.trim(name), user_id: "foobar"})
|> Ash.create!()
# Update socket with new list of todos
socket =
socket
|> assign(:todos, get_user_todos(socket.assigns.user_id))
|> assign(:new_todo_name, "")
{:noreply, socket}
end
end
def handle_event("toggle_todo", %{"id" => id}, socket) do
# Find the todo and ensure it belongs to current user
todo = MyApp.Support.Todo
|> Ash.Query.filter(user_id == ^socket.assigns.user_id)
|> Ash.Query.filter(id == ^id)
|> Ash.read_one!()
# Update todo
{:ok, _todo} =
todo
|> Ash.Changeset.for_update(:toggle)
|> Ash.update!()
# Refresh todos
{:noreply, assign(socket, :todos, get_user_todos(socket.assigns.user_id))}
end
def handle_event("delete_todo", %{"id" => id}, socket) do
# Delete todo, ensuring it belongs to current user
todo = MyApp.Support.Todo
|> Ash.Query.filter(user_id == ^socket.assigns.user_id)
|> Ash.Query.filter(id == ^id)
|> Ash.read_one!()
todo
|> Ash.Changeset.for_destroy(:destroy)
|> Ash.destroy!()
# Refresh todos
{:noreply, assign(socket, :todos, get_user_todos(socket.assigns.user_id))}
end
def render(assigns) do
~H"""
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<div class="max-w-md mx-auto mt-10 p-6 bg-gray-200 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">Todo List for <%= @user_id %></h1>
<form phx-submit="create_todo" class="mb-4 flex">
<input
type="text"
name="name"
value={@new_todo_name}
placeholder="Enter a new todo"
class="flex-grow mr-2 px-2 py-1 border rounded"
/>
<button
type="submit"
class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600"
>
Add Todo
</button>
</form>
<ul>
<%= for todo <- @todos do %>
<li class="flex items-center justify-between py-2 border-b">
<label class="flex items-center gap-2">
<input
type="checkbox"
phx-click="toggle_todo"
phx-value-id={todo.id}
checked={todo.status == "done"}
class="mr-2"
/>
<span class={if todo.status == "done", do: "line-through text-gray-500", else: ""}>
<%= todo.task %>
</span>
<span>by <%= todo.user_id %></span>
</label>
<button
phx-click="delete_todo"
phx-value-id={todo.id}
class="text-red-500 hover:text-red-700"
>
</button>
</li>
<% end %>
</ul>
</div>
"""
end
end
defmodule AuthPlug do
use AshAuthentication.Plug, otp_app: :my_app
def handle_success(conn, _activity, user, token) do
if is_api_request?(conn) do
conn
|> send_resp(200, Jason.encode!(%{
authentication: %{
success: true,
token: token
}
}))
else
conn
|> store_in_session(user)
|> send_resp(200, EEx.eval_string("""
<h2>Welcome back <%= @user.email %></h2>
""", user: user))
end
end
def handle_failure(conn, _activity, _reason) do
if is_api_request?(conn) do
conn
|> send_resp(401, Jason.encode!(%{
authentication: %{
success: false
}
}))
else
conn
|> send_resp(401, "<h2>Incorrect email or password</h2>")
end
end
defp is_api_request?(conn), do: "application/json" in get_req_header(conn, "accept")
end
defmodule MyApp.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
use AshAuthentication.Phoenix.Router
pipeline :browser do
plug :accepts, ["html"]
plug :put_secret_key_base
plug :fetch_session
plug :load_from_session
plug :put_root_layout, html: {PhoenixPlayground.Layout, :root}
plug :put_secure_browser_headers
end
scope "/" do
pipe_through :browser
sign_in_route auth_routes_prefix: "/auth"
reset_route auth_routes_prefix: "/auth"
live "/", TodoLive, :index
end
def put_secret_key_base(conn, _) do
put_in conn.secret_key_base, System.get_env("SECRET_KEY_BASE")
end
end
# Configuration for AshAuthentication LiveView integration
Application.put_env(:my_app, AshAuthentication.Phoenix.LiveSession,
on_mount: {AshAuthentication.Phoenix.LiveSession, :mount_if_logged_in, []}
)
# Configuration for the Password strategy's Phoenix forms
Application.put_env(:my_app, AshAuthentication.Phoenix.Password, [
sign_in_form: [
module: AshAuthentication.Phoenix.Password.SignInLive,
api: MyApp.Support
],
register_form: [
module: AshAuthentication.Phoenix.Password.RegisterLive,
api: MyApp.Support
]
])
PhoenixPlayground.start(
open_browser: true,
live_reload: true,
child_specs: [],
plug: MyApp.Router
)
@tripathi456
Copy link

tripathi456 commented Jun 11, 2025

instead of

  actions do
    defaults [:read]
  end

do

  actions do
    default_accept :*
    defaults [:read]
  end

@nileshtrivedi
Copy link
Author

One of the issues is session data not being available in liveview:

17:45:20.309 [error] ** (KeyError) key :user_id not found in: %{}
    app.exs:135: TodoLive.mount/3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment