Created
December 9, 2024 12:17
-
-
Save nileshtrivedi/fe35f9ffee98121583dd1fd102a817dd to your computer and use it in GitHub Desktop.
Attempt to make a single-file multi-user liveview-based ToDo application
This file contains 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
# postgres should be running | |
# it should have a table called todos | |
# See Migrations module on how to create the table | |
# The application can be started with | |
# JWT_SECRET=6fa91f11ca785e9a73d340ea8d3d03ed SECRET_KEY_BASE=6fa91f11ca785e9a73d340ea8d3d03ed6fa91f11ca785e9a73d340ea8d3d03ed elixir app.exs | |
# To access the app with authentication, visit this URL that includes the JWT token: | |
# http://localhost:4000/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.keYoDT2sQMzTicz_1oqmfduw0HYrwnxc4iwoq9Vr-hQ | |
# Current issues: | |
# LiveView's second mount() does not include session data | |
# phoenix_playground does not support on_mount callback: https://github.com/phoenix-playground/phoenix_playground/issues/9 | |
# Trying to use a custom endpoint suppresses errors because: (ArgumentError) no "500" html template defined for Demo.ErrorView | |
Mix.install([ | |
{:phoenix_playground, "== 0.1.6"}, | |
{:phoenix_ecto, "== 4.6.3"}, | |
{:ecto_sql, "== 3.12.1"}, | |
{:postgrex, "== 0.19.3"}, | |
{:jason, "~> 1.4"}, | |
{:jose, "~> 1.11"} | |
]) | |
defmodule MyRepo do | |
use Ecto.Repo, otp_app: :myapp, adapter: Ecto.Adapters.Postgres | |
end | |
defmodule Todo do | |
use Ecto.Schema | |
schema "todos" do | |
field :name, :string | |
field :status, :string, default: "pending" | |
field :user_id, :string | |
timestamps() | |
end | |
def changeset(todo, attrs \\ %{}) do | |
todo | |
|> Ecto.Changeset.cast(attrs, [:name, :status, :user_id]) | |
|> Ecto.Changeset.validate_required([:name, :user_id]) | |
|> Ecto.Changeset.validate_format(:user_id, ~r/^[\w-]+$/, message: "Invalid user ID") | |
end | |
end | |
Application.put_env(:myapp, MyRepo, | |
database: "postgres", | |
username: "postgres", | |
password: "", | |
hostname: "localhost", | |
port: 5432 | |
) | |
defmodule HomeLive do | |
use Phoenix.LiveView | |
def mount(_params, _session, socket) do | |
user_id = "dummyuser" | |
count = MyRepo.aggregate("todos", :count) | |
{:ok, assign(socket, count: count, user_id: 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">Welcome to the Todo App</h1> | |
<p>We have {@count} todos. You are {@user_id}</p> | |
<p class="text-gray-700">Please visit the <a href="/app" class="text-blue-500 underline hover:text-blue-600">app</a> to continue</p> | |
</div> | |
""" | |
end | |
end | |
defmodule TodoLive do | |
use Phoenix.LiveView | |
import Ecto.Query | |
def get_user_todos(user_id) do | |
MyRepo.all(from t in Todo, where: t.user_id == ^user_id) | |
end | |
def mount(_params, _session, socket) do | |
user_id = "foobar" | |
tasks = get_user_todos(user_id) | |
{:ok, assign(socket, todos: tasks, user_id: user_id, new_todo_name: "drink water")} | |
end | |
def handle_event("create_todo", %{"name" => name}, socket) do | |
# Trim and validate todo name | |
name = String.trim(name) | |
if name == "" do | |
{:noreply, socket} | |
else | |
# Create new todo with current user's ID | |
{:ok, _todo} = | |
%Todo{} | |
|> Todo.changeset(%{ | |
name: name, | |
user_id: socket.assigns.user_id | |
}) | |
|> MyRepo.insert() | |
# 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 = MyRepo.get_by!(Todo, [id: id, user_id: socket.assigns.user_id]) | |
# Toggle status | |
new_status = if todo.status == "pending", do: "done", else: "pending" | |
# Update todo | |
{:ok, _todo} = | |
todo | |
|> Todo.changeset(%{status: new_status}) | |
|> MyRepo.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 = MyRepo.get_by!(Todo, [id: id, user_id: socket.assigns.user_id]) | |
MyRepo.delete(todo) | |
# 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.name %> | |
</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> | |
<p class="text-gray-700"><a href="/" class="text-blue-500 underline hover:text-blue-600">homepage</a></p> | |
</div> | |
""" | |
end | |
end | |
{:ok, _} = Application.ensure_all_started(:postgrex) | |
{:ok, _} = Application.ensure_all_started(:ecto) | |
defmodule AuthPlug do | |
import Plug.Conn | |
defp verify_token(jwt) do | |
jwk = %{ | |
"kty" => "oct", | |
"k" => :jose_base64url.encode(System.get_env("JWT_SECRET")) | |
} | |
case JOSE.JWT.verify_strict(jwk, ["HS256"], jwt) do | |
{true, claims, _} -> {:ok, claims.fields["sub"]} | |
_ -> :error | |
end | |
end | |
def init(opts), do: opts | |
def call(conn, _opts) do | |
if user_id = get_session(conn, :user_id) do | |
IO.inspect "Found user_id=#{user_id} in session" | |
# assign(conn, :user_id, user_id) | |
conn | |
else | |
IO.inspect "Checking JWT" | |
jwt = conn.query_params["jwt"] | |
case verify_token(jwt) do | |
{:ok, sub} -> | |
IO.inspect "Verified sub=#{sub}" | |
conn | |
|> put_session(:user_id, sub) | |
|> put_session(:jwt, jwt) | |
:error -> | |
conn | |
|> put_resp_content_type("text/plain") | |
|> send_resp(401, "Unauthorized") | |
|> halt() | |
end | |
end | |
end | |
end | |
defmodule Demo.Router do | |
use Phoenix.Router | |
import Phoenix.LiveView.Router | |
pipeline :browser do | |
plug :accepts, ["html"] | |
plug :put_secret_key_base | |
plug :fetch_session | |
plug :put_root_layout, html: {PhoenixPlayground.Layout, :root} | |
plug :put_secure_browser_headers | |
plug AuthPlug | |
end | |
scope "/" do | |
pipe_through :browser | |
live "/", HomeLive | |
live "/app", TodoLive | |
end | |
def put_secret_key_base(conn, _) do | |
put_in conn.secret_key_base, System.get_env("SECRET_KEY_BASE") | |
end | |
end | |
defmodule Demo.Endpoint do | |
use Phoenix.Endpoint, otp_app: :phoenix_playground | |
plug Plug.Logger | |
socket "/live", Phoenix.LiveView.Socket | |
plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" | |
plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" | |
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket | |
plug Phoenix.LiveReloader | |
plug Phoenix.CodeReloader, reloader: &PhoenixPlayground.CodeReloader.reload/2 | |
plug Demo.Router | |
end | |
# Create todos table if not exists | |
defmodule Migrations do | |
def migrate do | |
create_todos_table() | |
end | |
defp create_todos_table do | |
MyRepo.query!(""" | |
CREATE TABLE IF NOT EXISTS todos ( | |
id SERIAL PRIMARY KEY, | |
name VARCHAR(255) NOT NULL, | |
status VARCHAR(20) DEFAULT 'pending', | |
inserted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | |
) | |
""") | |
end | |
end | |
# Run migrations | |
# MyRepo.start_link() | |
# Migrations.migrate() | |
PhoenixPlayground.start( | |
open_browser: false, | |
live_reload: true, | |
child_specs: [ | |
{MyRepo, []} | |
], | |
# endpoint: Demo.Endpoint, | |
# endpoint_options: [debug_errors: true] | |
plug: Demo.Router | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment