Created
June 11, 2025 11:16
-
-
Save nileshtrivedi/d8067e9c946bc125aaa48ffd41ae1370 to your computer and use it in GitHub Desktop.
single-file todo list app with ash framework and elixir
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) |
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
instead of
do