From 12d4d36fbe00955abb2cf0f8a3c5d474ac80c6ad Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Fri, 16 May 2025 13:53:21 +0200 Subject: [PATCH 01/18] authentication added --- REQUIREMENTS-002.md | 121 ++++++++++++++++- apps/lora/lib/lora/accounts.ex | 53 ++++++++ .../lib/lora/accounts/accounts_behaviour.ex | 11 ++ apps/lora/lib/lora/accounts/ets_adapter.ex | 84 ++++++++++++ apps/lora/lib/lora/accounts/player.ex | 37 ++++++ apps/lora/lib/lora/application.ex | 4 + apps/lora/mix.exs | 6 +- apps/lora_web/lib/lora_web.ex | 1 + .../lora_web/controllers/auth_controller.ex | 85 ++++++++++++ apps/lora_web/lib/lora_web/endpoint.ex | 4 +- apps/lora_web/lib/lora_web/live/game_live.ex | 12 +- apps/lora_web/lib/lora_web/live/live_auth.ex | 35 +++++ apps/lora_web/lib/lora_web/live/lobby_live.ex | 78 +++++++---- .../lib/lora_web/live/lobby_live.html.heex | 77 +++++++---- .../lib/lora_web/plugs/current_player.ex | 34 +++++ .../lib/lora_web/plugs/require_auth.ex | 45 +++++++ apps/lora_web/lib/lora_web/router.ex | 45 ++++--- apps/lora_web/test/lora_web/auth_test.exs | 122 ++++++++++++++++++ config/config.exs | 12 ++ mix.lock | 4 + 20 files changed, 790 insertions(+), 80 deletions(-) create mode 100644 apps/lora/lib/lora/accounts.ex create mode 100644 apps/lora/lib/lora/accounts/accounts_behaviour.ex create mode 100644 apps/lora/lib/lora/accounts/ets_adapter.ex create mode 100644 apps/lora/lib/lora/accounts/player.ex create mode 100644 apps/lora_web/lib/lora_web/controllers/auth_controller.ex create mode 100644 apps/lora_web/lib/lora_web/live/live_auth.ex create mode 100644 apps/lora_web/lib/lora_web/plugs/current_player.ex create mode 100644 apps/lora_web/lib/lora_web/plugs/require_auth.ex create mode 100644 apps/lora_web/test/lora_web/auth_test.exs diff --git a/REQUIREMENTS-002.md b/REQUIREMENTS-002.md index cb5c911..32243db 100644 --- a/REQUIREMENTS-002.md +++ b/REQUIREMENTS-002.md @@ -1,5 +1,120 @@ -# Phase 2 Software Reuqirements Sepcification +# Lora Game – Authentication and Enhanced Lobby SRS (v 0.2-draft) -## Scope -This change should not include any function change, only cosmetic +## 1. Scope +Add user authentication and improve lobby flow for the existing **Lora** game MVP. Authentication relies on **Ueberauth + Auth0** with no durable persistence. Anonymous visitors may browse the lobby list, but **must authenticate** before creating or joining a game. +## 2. Definitions +| Term | Meaning | +| --- | --- | +| **Anonymous Visitor** | Unauthenticated user browsing the lobby page. | +| **Authenticated Player** | User logged in through Auth0 and cached in ETS. | +| **State Param** | OAuth2 `state` value that carries the **target redirect path** (e.g. `/game/XYZ123`) through the Auth0 round-trip. | +| **Callback URL** | `/auth/auth0/callback` handled by Ueberauth after Auth0 login. | + +## 3. Actors +- **Visitor** – accesses the lobby without logging in. +- **Player** – authenticated user who can create or join a game. +- **Auth0** – external identity provider (Universal Login). +- **System** – Phoenix app with Ueberauth plug chain and in-memory session store. + +## 4. Functional Requirements + +### 4.1 Authentication +| ID | Requirement | +| --- | --- | +| **AUTH-A-01** | Integrate `ueberauth` and `ueberauth_auth0`. | +| **AUTH-A-02** | `/auth/auth0` initiates login; `/auth/auth0/callback` processes Auth0 response. | +| **AUTH-A-03** | Use Auth0 Universal Login (redirect flow, no embedded widget). | +| **AUTH-A-04** | On successful callback, extract `sub`, `name`, `email` into `%Player{}`. | +| **AUTH-A-05** | Store `%Player{}` in ETS via `Lora.Accounts` context facade. | +| **AUTH-A-06** | Maintain Phoenix session cookie with `player_id` referencing ETS record. | +| **AUTH-A-07** | Provide `/logout` route that clears session and purges ETS entry. | + +### 4.2 Lobby Flow +| ID | Requirement | +| --- | --- | +| **AUTH-L-01** | `/lobby` lists open game codes and a **Create Game** button (visible to all). | +| **AUTH-L-02** | *Join* or *Create* triggers an auth guard; unauthenticated users are redirected to `/auth/auth0?state=`. | +| **AUTH-L-03** | After callback the app reads `conn.query_params["state"]` (decoded) to decide next redirect. | +| **AUTH-L-04** | If `state` is `/lobby#create`, create a fresh game and redirect to its URL. | +| **AUTH-L-05** | If `state` is `/game/`, attempt to join; on success redirect there, else flash error and return to lobby. | +| **AUTH-L-06** | Authenticated user’s name appears in lobby header with logout dropdown. | + +### 4.3 Security & Session +| ID | Requirement | +| --- | --- | +| **AUTH-S-01** | Use encrypted Phoenix session cookies (`encrypt: true`). | +| **AUTH-S-02** | ETS player entries expire 30 min after last touch via periodic sweep. | +| **AUTH-S-03** | Ensure CSRF protection on `/auth/auth0/callback` (Ueberauth default). | + +## 5. Non-Functional Requirements +| ID | Requirement | +| --- | --- | +| **AUTH-NFR-P-01** | Use `ueberauth` ≥ 0.11 and `ueberauth_auth0` ≥ 0.9. | +| **AUTH-NFR-P-02** | Hide persistence behind `Lora.Accounts` behaviour so future adapters (Ecto, microservice) can be swapped without touching controllers or LiveViews. | +| **AUTH-NFR-P-03** | Typical added latency for auth round-trip **< 1500 ms**. | +| **AUTH-NFR-Q-01** | ExUnit tests mocking Auth0 responses: success, invalid state, expired session, join failure. | + +## 6. Architecture Changes +- **Plug Pipeline**: `:browser` now includes `Ueberauth` and `LoraWeb.Plugs.RequireAuth`. +- **Context**: `Lora.Accounts` provides `get_player/1`, `store_player/1`, `delete_player/1` with ETS adapter. +- **LiveViews**: `LobbyLive` handles auth redirects; `GameLive` mounts only when `current_player` assigned. +- **ETS Table**: `:players`, key = Auth0 `sub`, value = `%Player{}` plus `inserted_at` timestamp. + +## 7. Auth0 Configuration + +**Allowed Callback URLs** +``` +https://your-host/auth/auth0/callback +http://localhost:4000/auth/auth0/callback +``` + +**Allowed Logout URLs** +https://your-host/ +http://localhost:4000/ + +**State Parameter examples (URL-encoded)** +``` +state=%2Flobby%23create # create a new game after login +state=%2Fgame%2FXYZ123 # join existing game /game/XYZ123 +``` + +## 8. Sequence Diagram (textual) +1. Visitor clicks **Join game XYZ123**. +2. `LobbyLive` pushes redirect to `/auth/auth0?state=%2Fgame%2FXYZ123`. +3. Browser loads Auth0 Universal Login. +4. User authenticates. +5. Auth0 redirects to `/auth/auth0/callback?code=…&state=%2Fgame%2FXYZ123`. +6. Ueberauth exchanges code and obtains profile. +7. Controller stores player in ETS, sets session `player_id`. +8. Controller redirects user to `/game/XYZ123`. + + +```mermaid +sequenceDiagram + participant V as Visitor (Browser) + participant LL as LobbyLive + participant Auth0 as Auth0 + participant C as Callback Controller + participant ETS as ETS Store + V->>LL: Click Join /game/XYZ123 + LL-->>V: 302 to /auth/auth0?state=/game/XYZ123 + V->>Auth0: GET Universal Login + Auth0-->>V: Login Form + V->>Auth0: Credentials + Auth0-->>V: 302 to /auth/auth0/callback?code=...&state=/game/XYZ123 + V->>C: GET callback with code & state + C->>Auth0: Exchange code for tokens + Auth0-->>C: ID token & profile + C->>ETS: store_player(profile) + C-->>V: 302 to /game/XYZ123 (set session cookie) + V->>GameLive: WebSocket join /game/XYZ123 +``` + +## 9. Deliverables +1. Updated Mix dependencies (`ueberauth`, `ueberauth_auth0`). +2. `Lora.Accounts` context with ETS adapter and behaviour spec. +3. Router updates for auth routes. +4. `LoraWeb.Plugs.RequireAuth`. +5. Modified `LobbyLive` and templates. +6. Test suite for auth flow. \ No newline at end of file diff --git a/apps/lora/lib/lora/accounts.ex b/apps/lora/lib/lora/accounts.ex new file mode 100644 index 0000000..e55c9e2 --- /dev/null +++ b/apps/lora/lib/lora/accounts.ex @@ -0,0 +1,53 @@ +defmodule Lora.Accounts do + @moduledoc """ + The Accounts context responsible for player authentication and management. + """ + + alias Lora.Accounts.Player + + # Use the ETS adapter as the default implementation + @adapter Lora.Accounts.ETSAdapter + + @doc """ + Initialize the accounts system. + """ + def init do + @adapter.init() + end + + @doc """ + Get a player by ID. + """ + def get_player(player_id) do + @adapter.get_player(player_id) + end + + @doc """ + Store a player. + """ + def store_player(%Player{} = player) do + @adapter.store_player(player) + end + + @doc """ + Delete a player. + """ + def delete_player(player_id) do + @adapter.delete_player(player_id) + end + + @doc """ + Update the timestamp for a player to prevent session expiry. + """ + def touch_player(player_id) do + @adapter.touch_player(player_id) + end + + @doc """ + Create a player from Auth0 authentication information. + """ + def create_player_from_auth(%Ueberauth.Auth{} = auth) do + player = Player.from_auth(auth) + store_player(player) + end +end diff --git a/apps/lora/lib/lora/accounts/accounts_behaviour.ex b/apps/lora/lib/lora/accounts/accounts_behaviour.ex new file mode 100644 index 0000000..5d2e761 --- /dev/null +++ b/apps/lora/lib/lora/accounts/accounts_behaviour.ex @@ -0,0 +1,11 @@ +defmodule Lora.Accounts.AccountsBehaviour do + @moduledoc """ + Behaviour for the Accounts context to allow swapping implementations. + """ + alias Lora.Accounts.Player + + @callback get_player(player_id :: String.t()) :: {:ok, Player.t()} | {:error, String.t()} + @callback store_player(player :: Player.t()) :: {:ok, Player.t()} | {:error, String.t()} + @callback delete_player(player_id :: String.t()) :: :ok | {:error, String.t()} + @callback touch_player(player_id :: String.t()) :: :ok | {:error, String.t()} +end diff --git a/apps/lora/lib/lora/accounts/ets_adapter.ex b/apps/lora/lib/lora/accounts/ets_adapter.ex new file mode 100644 index 0000000..96a4311 --- /dev/null +++ b/apps/lora/lib/lora/accounts/ets_adapter.ex @@ -0,0 +1,84 @@ +defmodule Lora.Accounts.ETSAdapter do + @moduledoc """ + ETS-based implementation of the Accounts context. + """ + @behaviour Lora.Accounts.AccountsBehaviour + + alias Lora.Accounts.Player + require Logger + + @table_name :players + @expiry_time_seconds 30 * 60 # 30 minutes + + @doc """ + Initialize the ETS table for players. + """ + def init do + :ets.new(@table_name, [:set, :public, :named_table]) + schedule_cleanup() + :ok + end + + @impl true + def get_player(player_id) when is_binary(player_id) do + case :ets.lookup(@table_name, player_id) do + [{^player_id, player}] -> + {:ok, player} + + [] -> + {:error, "Player not found"} + end + end + + @impl true + def store_player(%Player{} = player) do + true = :ets.insert(@table_name, {player.sub, player}) + {:ok, player} + end + + @impl true + def delete_player(player_id) when is_binary(player_id) do + true = :ets.delete(@table_name, player_id) + :ok + end + + @impl true + def touch_player(player_id) when is_binary(player_id) do + case get_player(player_id) do + {:ok, player} -> + player = %Player{player | inserted_at: DateTime.utc_now()} + store_player(player) + :ok + + error -> + error + end + end # Schedule a periodic cleanup of expired sessions + defp schedule_cleanup do + # Since we're not a GenServer, we'll use a Task instead + Task.start(fn -> + Process.sleep(60_000) # Wait 1 minute + cleanup_expired_players() + schedule_cleanup() + end) + end + + # Clean up expired player entries + defp cleanup_expired_players do + now = DateTime.utc_now() + expiry_threshold = DateTime.add(now, -@expiry_time_seconds, :second) + + # Perform the cleanup by iterating through the table + :ets.foldl( + fn {id, player}, acc -> + if DateTime.compare(player.inserted_at, expiry_threshold) == :lt do + Logger.info("Removing expired player session for #{player.name}") + :ets.delete(@table_name, id) + end + acc + end, + nil, + @table_name + ) + end +end diff --git a/apps/lora/lib/lora/accounts/player.ex b/apps/lora/lib/lora/accounts/player.ex new file mode 100644 index 0000000..9b05a82 --- /dev/null +++ b/apps/lora/lib/lora/accounts/player.ex @@ -0,0 +1,37 @@ +defmodule Lora.Accounts.Player do + @moduledoc """ + Schema for a player in the system. + """ + + @type t :: %__MODULE__{ + id: String.t(), + sub: String.t(), + name: String.t(), + email: String.t(), + inserted_at: DateTime.t() + } + + @derive {Jason.Encoder, only: [:id, :name, :email]} + defstruct [:id, :sub, :name, :email, :inserted_at] + + @doc """ + Create a player from Auth0 information. + """ + def from_auth(%Ueberauth.Auth{} = auth) do + %__MODULE__{ + id: auth.uid, + sub: auth.uid, + name: get_name_from_auth(auth), + email: get_email_from_auth(auth), + inserted_at: DateTime.utc_now() + } + end + + defp get_name_from_auth(%{info: %{name: name}}) when not is_nil(name), do: name + defp get_name_from_auth(%{info: %{nickname: nickname}}) when not is_nil(nickname), do: nickname + defp get_name_from_auth(%{info: %{first_name: first_name}}) when not is_nil(first_name), do: first_name + defp get_name_from_auth(_), do: "Anonymous Player" + + defp get_email_from_auth(%{info: %{email: email}}) when not is_nil(email), do: email + defp get_email_from_auth(_), do: nil +end diff --git a/apps/lora/lib/lora/application.ex b/apps/lora/lib/lora/application.ex index ad50513..bf9eae2 100644 --- a/apps/lora/lib/lora/application.ex +++ b/apps/lora/lib/lora/application.ex @@ -18,6 +18,10 @@ defmodule Lora.Application do {Phoenix.PubSub, name: Lora.PubSub} ] + # Initialize the Accounts ETS table + :ok = Lora.Accounts.init() + IO.puts("Accounts ETS table initialized successfully") + # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Lora.Supervisor] diff --git a/apps/lora/mix.exs b/apps/lora/mix.exs index 328da22..f6d1858 100644 --- a/apps/lora/mix.exs +++ b/apps/lora/mix.exs @@ -25,7 +25,7 @@ defmodule Lora.MixProject do def application do [ mod: {Lora.Application, []}, - extra_applications: [:logger, :runtime_tools] + extra_applications: [:logger, :runtime_tools, :ueberauth] ] end @@ -42,7 +42,9 @@ defmodule Lora.MixProject do {:phoenix_pubsub, "~> 2.1"}, {:jason, "~> 1.2"}, {:swoosh, "~> 1.5"}, - {:finch, "~> 0.13"} + {:finch, "~> 0.13"}, + {:ueberauth, "~> 0.10.8"}, + {:ueberauth_auth0, "~> 2.0"} ] end diff --git a/apps/lora_web/lib/lora_web.ex b/apps/lora_web/lib/lora_web.ex index 62a900b..aca64f4 100644 --- a/apps/lora_web/lib/lora_web.ex +++ b/apps/lora_web/lib/lora_web.ex @@ -55,6 +55,7 @@ defmodule LoraWeb do use Phoenix.LiveView, layout: {LoraWeb.Layouts, :app} + on_mount {LoraWeb.LiveAuth, :default} unquote(html_helpers()) end end diff --git a/apps/lora_web/lib/lora_web/controllers/auth_controller.ex b/apps/lora_web/lib/lora_web/controllers/auth_controller.ex new file mode 100644 index 0000000..c408444 --- /dev/null +++ b/apps/lora_web/lib/lora_web/controllers/auth_controller.ex @@ -0,0 +1,85 @@ +defmodule LoraWeb.AuthController do + use LoraWeb, :controller + alias Lora.Accounts + require Logger + + plug Ueberauth + + @doc """ + Callback handler for Auth0 authentication. + """ + def callback(%{assigns: %{ueberauth_auth: auth}} = conn, params) do + # Create and store player in ETS + Logger.debug("AUTH CALLBACK - auth: #{inspect(auth)}") + + {:ok, player} = Accounts.create_player_from_auth(auth) + Logger.debug("AUTH CALLBACK - player created: #{inspect(player)}") + + # Store player ID in session + conn = put_session(conn, "player_id", player.sub) + Logger.debug("AUTH CALLBACK - set player_id in session: #{player.sub}") + + # Handle the redirect based on the state parameter + state = params["state"] + + cond do + state == "/lobby#create" -> + # Create a new game and redirect to it + case Lora.create_game(player.sub, player.name) do + {:ok, game_id} -> + redirect(conn, to: ~p"/game/#{game_id}") + + {:error, _reason} -> + conn + |> put_flash(:error, "Failed to create a new game") + |> redirect(to: ~p"/") + end + + String.starts_with?(state, "/game/") -> + # Try to join the game specified in the state + game_id = String.replace_prefix(state, "/game/", "") + + case Lora.join_game(game_id, player.sub, player.name) do + {:ok, _game} -> + redirect(conn, to: state) + + {:error, reason} -> + conn + |> put_flash(:error, reason) + |> redirect(to: ~p"/") + end + + true -> + # Default: redirect to the saved return path or root + redirect_path = get_session(conn, :return_to) || "/" + + conn + |> delete_session(:return_to) + |> redirect(to: redirect_path) + end + end + + def callback(%{assigns: %{ueberauth_failure: _failure}} = conn, _params) do + conn + |> put_flash(:error, "Authentication failed") + |> redirect(to: ~p"/") + end + + @doc """ + Logout the current user. + """ + def delete(conn, _params) do + # Get the player ID from the session + player_id = get_session(conn, "player_id") + + # Delete the player from ETS if they exist + if player_id do + Accounts.delete_player(player_id) + end + + # Clear the session + conn + |> clear_session() + |> redirect(to: ~p"/") + end +end diff --git a/apps/lora_web/lib/lora_web/endpoint.ex b/apps/lora_web/lib/lora_web/endpoint.ex index 67a7dd8..c6c0b91 100644 --- a/apps/lora_web/lib/lora_web/endpoint.ex +++ b/apps/lora_web/lib/lora_web/endpoint.ex @@ -3,11 +3,13 @@ defmodule LoraWeb.Endpoint do # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. + # We also encrypt it for enhanced security. @session_options [ store: :cookie, key: "_lora_web_key", signing_salt: "5rsudttO", + encryption_salt: "YamqSfwz", + encrypt: true, same_site: "Lax" ] diff --git a/apps/lora_web/lib/lora_web/live/game_live.ex b/apps/lora_web/lib/lora_web/live/game_live.ex index c9dceb6..43dd685 100644 --- a/apps/lora_web/lib/lora_web/live/game_live.ex +++ b/apps/lora_web/lib/lora_web/live/game_live.ex @@ -12,14 +12,16 @@ defmodule LoraWeb.GameLive do import LoraWeb.GameUtils @impl true - def mount(%{"id" => game_id}, session, socket) do - player_id = Map.fetch!(session, "player_id") - player_name = session["player_name"] || player_id + def mount(%{"id" => game_id}, _session, socket) do + # Get player info from the current_player assign (set by RequireAuth plug) + current_player = socket.assigns[:current_player] - if is_nil(player_id) do - Logger.error("Missing player information in session or socket assigns") + if is_nil(current_player) do + Logger.error("Missing player information in socket assigns") {:ok, redirect_to_lobby(socket, "Missing player information")} else + player_id = current_player.sub + player_name = current_player.name if connected?(socket) do # Subscribe to game updates PubSub.subscribe(Lora.PubSub, "game:#{game_id}") diff --git a/apps/lora_web/lib/lora_web/live/live_auth.ex b/apps/lora_web/lib/lora_web/live/live_auth.ex new file mode 100644 index 0000000..ffa7d9d --- /dev/null +++ b/apps/lora_web/lib/lora_web/live/live_auth.ex @@ -0,0 +1,35 @@ +defmodule LoraWeb.LiveAuth do + @moduledoc """ + LiveView hook to handle authentication for LiveViews. + """ + import Phoenix.Component + + alias Lora.Accounts + require Logger + + def on_mount(:default, _params, session, socket) do + player_id = Map.get(session, "player_id") + Logger.debug("player_id from session: #{inspect(player_id)}") + + if player_id do + case Accounts.get_player(player_id) do + {:ok, player} -> + Logger.debug("player found: #{inspect(player)}") + # Touch the player session to extend it + Accounts.touch_player(player_id) + + # Debug - inspect socket before and after assign + socket = assign(socket, :current_player, player) + + {:cont, socket} + + {:error, reason} -> + Logger.debug("Player not found: #{reason}") + {:cont, assign(socket, :current_player, nil)} + end + else + Logger.debug("no player_id in session") + {:cont, assign(socket, :current_player, nil)} + end + end +end diff --git a/apps/lora_web/lib/lora_web/live/lobby_live.ex b/apps/lora_web/lib/lora_web/live/lobby_live.ex index 3934124..bb66632 100644 --- a/apps/lora_web/lib/lora_web/live/lobby_live.ex +++ b/apps/lora_web/lib/lora_web/live/lobby_live.ex @@ -6,58 +6,79 @@ defmodule LoraWeb.LobbyLive do @impl true @spec mount(any(), map(), Phoenix.LiveView.Socket.t()) :: {:ok, map(), [{:temporary_assigns, [...]}, ...]} - def mount(_params, session, socket) do - # Generate a unique player ID if not already present in session - player_id = Map.fetch!(session, "player_id") + def mount(_params, _session, socket) do + # At this point, LiveAuth hook has already assigned :current_player + player_id = if socket.assigns.current_player, do: socket.assigns.current_player.sub, else: nil + player_name = if socket.assigns.current_player, do: socket.assigns.current_player.name, else: "" socket = socket |> assign(:player_id, player_id) - |> assign(:player_name, "") + |> assign(:player_name, player_name) |> assign(:game_code, "") |> assign(:error_message, nil) - # Store player_id in session {:ok, socket, temporary_assigns: [error_message: nil]} end @impl true - def handle_event("create_game", %{"create_player" => %{"name" => name}}, socket) do - if valid_name?(name) do - player_id = socket.assigns.player_id - - case Lora.create_game(player_id, name) do - {:ok, game_id} -> - {:noreply, redirect_to_game(socket, game_id, name)} - - {:error, reason} -> - {:noreply, assign(socket, error_message: "Failed to create game: #{reason}")} - end - else - {:noreply, assign(socket, error_message: "Please enter a valid name (3-20 characters)")} + def handle_event("create_game", params, socket) do + IO.puts("CREATE GAME CALLED with params: #{inspect(params)}") + IO.puts("Full socket assigns: #{inspect(socket.assigns)}") + + current_player = Map.get(socket.assigns, :current_player) + IO.puts("Current player extracted: #{inspect(current_player)}") + + cond do + is_nil(current_player) -> + # Redirect to Auth0 login with state parameter for game creation + state = URI.encode_www_form("/lobby#create") + IO.puts("No current player, redirecting to Auth0 with state: #{state}") + {:noreply, redirect(socket, to: "/auth/auth0?state=#{state}")} + + is_map(current_player) && Map.has_key?(current_player, :sub) && Map.has_key?(current_player, :name) -> + player_id = current_player.sub + name = current_player.name + + IO.puts("Attempting to create game for player: #{player_id}, #{name}") + + case Lora.create_game(player_id, name) do + {:ok, game_id} -> + IO.puts("Game created successfully with ID: #{game_id}") + {:noreply, redirect_to_game(socket, game_id, name)} + + {:error, reason} -> + IO.puts("Game creation failed: #{reason}") + {:noreply, assign(socket, error_message: "Failed to create game: #{reason}")} + end + + true -> + # Handle case where current_player is defined but incomplete + IO.puts("Invalid current_player data structure: #{inspect(current_player)}") + {:noreply, assign(socket, error_message: "Authentication data is invalid. Please try logging in again.")} end end @impl true def handle_event( "join_game", - %{"join_player" => %{"name" => name, "game_code" => game_code}}, + %{"join_player" => %{"game_code" => game_code}}, socket ) do game_code = String.trim(game_code) - player_id = socket.assigns.player_id cond do - not valid_name?(name) -> - {:noreply, assign(socket, error_message: "Please enter a valid name (3-20 characters)")} - not valid_game_code?(game_code) -> {:noreply, assign(socket, error_message: "Please enter a valid game code (6 characters)")} not Lora.game_exists?(game_code) -> {:noreply, assign(socket, error_message: "Game not found")} - true -> + socket.assigns.current_player -> + # User is authenticated, proceed with joining + player_id = socket.assigns.current_player.sub + name = socket.assigns.current_player.name + case Lora.join_game(game_code, player_id, name) do {:ok, _game} -> {:noreply, redirect_to_game(socket, game_code, name)} @@ -65,6 +86,11 @@ defmodule LoraWeb.LobbyLive do {:error, reason} -> {:noreply, assign(socket, error_message: reason)} end + + true -> + # Redirect to Auth0 login with state parameter for joining this game + state = URI.encode_www_form("/game/#{game_code}") + {:noreply, redirect(socket, to: "/auth/auth0?state=#{state}")} end end @@ -89,10 +115,6 @@ defmodule LoraWeb.LobbyLive do # Helper functions - defp valid_name?(name) do - String.length(String.trim(name)) >= 3 && String.length(String.trim(name)) <= 20 - end - defp valid_game_code?(code) do String.length(String.trim(code)) == 6 end diff --git a/apps/lora_web/lib/lora_web/live/lobby_live.html.heex b/apps/lora_web/lib/lora_web/live/lobby_live.html.heex index d6ce7fa..dbcbaaf 100644 --- a/apps/lora_web/lib/lora_web/live/lobby_live.html.heex +++ b/apps/lora_web/lib/lora_web/live/lobby_live.html.heex @@ -1,5 +1,43 @@
-

Lora Card Game

+
+

Lora Card Game

+ + <%= if @current_player do %> +
+ +
+ <.link + href={~p"/auth/logout"} + method="delete" + class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" + > + Sign Out + +
+
+ <% else %> + <.link + href={~p"/auth/auth0"} + class="text-indigo-600 hover:text-indigo-800 font-medium" + > + Sign In + + <% end %> +
<%= if @error_message do %>
@@ -10,25 +48,19 @@

Create a New Game

+ + <%= if @current_player do %> +
+

You'll join as: <%= @current_player.name %>

+
+ <% end %> + <.form - :let={f} id="create-game-form" for={%{}} - as={:create_player} phx-submit="create_game" - phx-change="validate" class="space-y-4" > -
- - <.input - field={f[:name]} - value={@player_name} - placeholder="Enter your name" - required={true} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - /> -
<.button type="submit" @@ -42,6 +74,13 @@

Join an Existing Game

+ + <%= if @current_player do %> +
+

You'll join as: <%= @current_player.name %>

+
+ <% end %> + <.form :let={f} id="join-game-form" @@ -51,16 +90,6 @@ phx-change="validate" class="space-y-4" > -
- - <.input - field={f[:name]} - value={@player_name} - placeholder="Enter your name" - required={true} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - /> -
<.input diff --git a/apps/lora_web/lib/lora_web/plugs/current_player.ex b/apps/lora_web/lib/lora_web/plugs/current_player.ex new file mode 100644 index 0000000..6c29119 --- /dev/null +++ b/apps/lora_web/lib/lora_web/plugs/current_player.ex @@ -0,0 +1,34 @@ +defmodule LoraWeb.Plugs.CurrentPlayer do + @moduledoc """ + Plug to assign the current player to the connection if authenticated. + + This plug doesn't enforce authentication but makes the player info available + if the user is authenticated. + """ + import Plug.Conn + + alias Lora.Accounts + + def init(opts), do: opts + + def call(conn, _opts) do + player_id = get_session(conn, "player_id") + + if player_id do + case Accounts.get_player(player_id) do + {:ok, player} -> + # User is authenticated, touch their session to prevent expiry + Accounts.touch_player(player_id) + assign(conn, :current_player, player) + + {:error, _} -> + # Player not found in ETS, remove from session + conn + |> delete_session("player_id") + |> assign(:current_player, nil) + end + else + assign(conn, :current_player, nil) + end + end +end diff --git a/apps/lora_web/lib/lora_web/plugs/require_auth.ex b/apps/lora_web/lib/lora_web/plugs/require_auth.ex new file mode 100644 index 0000000..58c10b8 --- /dev/null +++ b/apps/lora_web/lib/lora_web/plugs/require_auth.ex @@ -0,0 +1,45 @@ +defmodule LoraWeb.Plugs.RequireAuth do + @moduledoc """ + Plug to enforce authentication for certain routes. + + This plug ensures users are authenticated before accessing protected routes. + If not authenticated, it stores the intended path and redirects to the Auth0 login page. + """ + import Plug.Conn + import Phoenix.Controller + use LoraWeb, :verified_routes + + alias Lora.Accounts + + def init(opts), do: opts + + def call(conn, _opts) do + player_id = get_session(conn, "player_id") + + if player_id && player_exists?(player_id) do + # User is authenticated, touch their session to prevent expiry + Accounts.touch_player(player_id) + + # Add current_player to the connection assigns + {:ok, player} = Accounts.get_player(player_id) + assign(conn, :current_player, player) + else + # Save the current path for redirecting after login + target_path = conn.request_path + encoded_target_path = URI.encode_www_form(target_path) + + conn + |> put_session(:return_to, target_path) + |> redirect(to: ~p"/auth/auth0?state=#{encoded_target_path}") + |> halt() + end + end + + # Check if the player exists in the ETS store + defp player_exists?(player_id) do + case Accounts.get_player(player_id) do + {:ok, _player} -> true + {:error, _} -> false + end + end +end diff --git a/apps/lora_web/lib/lora_web/router.ex b/apps/lora_web/lib/lora_web/router.ex index f973719..abc6bd4 100644 --- a/apps/lora_web/lib/lora_web/router.ex +++ b/apps/lora_web/lib/lora_web/router.ex @@ -8,21 +8,46 @@ defmodule LoraWeb.Router do plug :put_root_layout, html: {LoraWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers - plug :ensure_player_id_generated + plug LoraWeb.Plugs.CurrentPlayer end pipeline :api do plug :accepts, ["json"] end + # Add authentication required pipeline + pipeline :auth_required do + plug LoraWeb.Plugs.RequireAuth + end + + # Unauthenticated routes scope "/", LoraWeb do pipe_through :browser # Replace the default route with our lobby - live "/", LobbyLive, :index + live_session :default, on_mount: {LoraWeb.LiveAuth, :default} do + live "/", LobbyLive, :index + end + end + + # Authentication routes + scope "/auth", LoraWeb do + pipe_through :browser + + # Auth0 routes handled by Ueberauth + get "/:provider", AuthController, :request + get "/:provider/callback", AuthController, :callback + delete "/logout", AuthController, :delete + end + + # Routes that require authentication + scope "/", LoraWeb do + pipe_through [:browser, :auth_required] # Game routes - live "/game/:id", GameLive, :show + live_session :authenticated, on_mount: {LoraWeb.LiveAuth, :default} do + live "/game/:id", GameLive, :show + end end # Other scopes may use custom stacks. @@ -47,18 +72,4 @@ defmodule LoraWeb.Router do end end - # Ensure a player ID is generated and stored in the session - defp ensure_player_id_generated(conn, _opts) do - if get_session(conn, "player_id") do - conn - else - player_id = generate_player_id() - put_session(conn, "player_id", player_id) - end - end - - # Generate a random player ID - used in both production and tests - defp generate_player_id do - :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) - end end diff --git a/apps/lora_web/test/lora_web/auth_test.exs b/apps/lora_web/test/lora_web/auth_test.exs new file mode 100644 index 0000000..4d46c1d --- /dev/null +++ b/apps/lora_web/test/lora_web/auth_test.exs @@ -0,0 +1,122 @@ +defmodule LoraWeb.AuthTest do + use LoraWeb.ConnCase + + import Mock + + alias Lora.Accounts + alias Lora.Accounts.Player + + # Mock Auth0 response + @auth0_response %Ueberauth.Auth{ + provider: :auth0, + strategy: Ueberauth.Strategy.Auth0, + uid: "auth0|12345678", + info: %{ + name: "Test User", + email: "test@example.com", + nickname: "testuser" + }, + credentials: %{ + token: "abc123", + expires: true, + expires_at: 1622222222, + refresh_token: "def456" + }, + extra: %{} + } + + # Setup Accounts ETS table for tests + setup do + Accounts.init() + :ok + end + + describe "auth flow" do + test "successful authentication redirects to the requested route", %{conn: conn} do + # Simulate Auth0 callback + with_mock Ueberauth.Strategy.Auth0, [ + handle_callback!: fn _conn -> @auth0_response end + ] do + conn = + conn + |> Plug.Test.init_test_session(%{}) + |> get("/auth/auth0/callback", %{"state" => "/lobby#create"}) + + # Check that we have a session with player_id and a redirect to the game URL + assert redirected_to(conn) =~ "/game/" + assert get_session(conn, "player_id") == "auth0|12345678" + + # Verify player was stored in ETS + {:ok, player} = Accounts.get_player("auth0|12345678") + assert player.name == "Test User" + assert player.email == "test@example.com" + assert player.sub == "auth0|12345678" + end + end + + test "failed authentication redirects to lobby with error", %{conn: conn} do + # Simulate failed Auth0 callback + conn = + conn + |> Plug.Test.init_test_session(%{}) + |> assign(:ueberauth_failure, %{errors: [%{message: "Invalid credentials"}]}) + |> get("/auth/auth0/callback") + + # Check for redirect to lobby and error flash + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) == "Authentication failed" + end + + test "logout removes the player from ETS and session", %{conn: conn} do + # Setup a player in the system + player = %Player{id: "test-id", sub: "test-id", name: "Test User", email: "test@example.com", inserted_at: DateTime.utc_now()} + {:ok, _} = Accounts.store_player(player) + + # Logout with a session containing the player_id + conn = + conn + |> Plug.Test.init_test_session(%{"player_id" => "test-id"}) + |> delete("/auth/logout") + + # Verify the session is cleared and we're redirected to lobby + assert redirected_to(conn) == "/" + refute get_session(conn, "player_id") + + # Verify the player was removed from ETS + assert {:error, _} = Accounts.get_player("test-id") + end + end + + describe "authentication guards" do + test "RequireAuth redirects unauthenticated users to login", %{conn: conn} do + conn = + conn + |> get("/game/123456") + + # The RequireAuth plug should redirect to Auth0 login + assert redirected_to(conn) =~ "/auth/auth0" + assert URI.decode(redirected_to(conn)) =~ "state=/game/123456" + end + + test "authenticated users can access protected routes", %{conn: conn} do + # Setup a player in the system + player = %Player{id: "test-id", sub: "test-id", name: "Test User", email: "test@example.com", inserted_at: DateTime.utc_now()} + {:ok, _} = Accounts.store_player(player) + + # Mock the game exists function to allow the request + with_mock Lora, [ + get_game_state: fn _game_id -> {:ok, %{players: []}} end, + player_reconnect: fn _game_id, _player_id, _pid -> :ok end, + add_player: fn _game_id, _player_id, _player_name -> {:ok, %{players: []}} end + ] do + conn = + conn + |> Plug.Test.init_test_session(%{"player_id" => "test-id"}) + |> get("/game/123456") + + # Should not redirect + assert html_response(conn, 200) + end + end + end +end diff --git a/config/config.exs b/config/config.exs index 9ab2344..863599e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -32,6 +32,18 @@ config :lora_web, LoraWeb.Endpoint, pubsub_server: Lora.PubSub, live_view: [signing_salt: "ss8bVTun"] +# Configure Ueberauth +config :ueberauth, Ueberauth, + providers: [ + auth0: {Ueberauth.Strategy.Auth0, []} + ] + +# Configure Ueberauth Auth0 provider +config :ueberauth, Ueberauth.Strategy.Auth0.OAuth, + domain: System.get_env("AUTH0_DOMAIN", "tri-mudraca-dev.eu.auth0.com"), + client_id: System.get_env("AUTH0_CLIENT_ID", "ty88vQoOuTjekRCQPalFDsnK0eL0gyog"), + client_secret: System.get_env("AUTH0_CLIENT_SECRET", "8_I9bfVRxjbFekL8ZQkXGq6w23TdcT0juSBedoPeVtJaYYheO6wwy8pZ1L1sURfR") + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/mix.lock b/mix.lock index 02af0f2..2a433cd 100644 --- a/mix.lock +++ b/mix.lock @@ -25,6 +25,7 @@ "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -42,8 +43,11 @@ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, + "tesla": {:hex, :tesla, "1.14.1", "71c5b031b4e089c0fbfb2b362e24b4478465773ae4ef569760a8c2899ad1e73c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c1dde8140a49a3bef5bb622356e77ac5a24ad0c8091f12c3b7fc1077ce797155"}, "thousand_island": {:hex, :thousand_island, "1.3.13", "d598c609172275f7b1648c9f6eddf900e42312b09bfc2f2020358f926ee00d39", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5a34bdf24ae2f965ddf7ba1a416f3111cfe7df50de8d66f6310e01fc2e80b02a"}, "tidewave": {:hex, :tidewave, "0.1.6", "f07514ee2c348c2e682a2632309ac6d8ec425392bfb803955a6bb19ca5508e2f", [:mix], [{:circular_buffer, "~> 0.4", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.47 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "3708592f325e1f54b99b215cd8c38f726732451bf5cfa16d73584793f99d9da4"}, + "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, + "ueberauth_auth0": {:hex, :ueberauth_auth0, "2.1.0", "0632d5844049fa2f26823f15e1120aa32f27df6f27ce515a4b04641736594bf4", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "8d3b30fa27c95c9e82c30c4afb016251405706d2e9627e603c3c9787fd1314fc"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, } From 2e8af2f1658ac4299f306f3b955c06cee99583af Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Fri, 16 May 2025 14:57:24 +0200 Subject: [PATCH 02/18] better lobby --- apps/lora/lib/lora.ex | 76 +++++ apps/lora/lib/lora/game_supervisor.ex | 7 + .../lora_web/components/core_components.ex | 90 +++--- .../lora_web/components/layouts/app.html.heex | 96 +++--- .../lib/lora_web/live/game_live.html.heex | 8 +- apps/lora_web/lib/lora_web/live/lobby_live.ex | 59 ++++ .../lib/lora_web/live/lobby_live.html.heex | 281 +++++++++++------- 7 files changed, 437 insertions(+), 180 deletions(-) diff --git a/apps/lora/lib/lora.ex b/apps/lora/lib/lora.ex index 1dda9d1..fd64174 100644 --- a/apps/lora/lib/lora.ex +++ b/apps/lora/lib/lora.ex @@ -81,4 +81,80 @@ defmodule Lora do def generate_player_id do :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) end + + @doc """ + Lists all open games that are waiting for players to join. + + Returns a list of game structs with basic information. + """ + def list_open_games do + # Get all active game IDs from supervisor + game_ids = GameSupervisor.list_games() + + # Fetch the state of each game and filter for those that are still accepting players + game_ids + |> Enum.map(fn id -> + case get_game_state(id) do + {:ok, game} -> {id, game} + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.filter(fn {_id, game} -> + # Games are "open" if they are in lobby phase + # and have fewer than 4 players + game.phase == :lobby and length(game.players) < 4 + end) + |> Enum.map(fn {id, game} -> + %{ + id: id, + players: Enum.map(game.players, & &1.name), + player_count: length(game.players), + created_at: Map.get(game, :created_at, DateTime.utc_now()), + creator: List.first(game.players).name + } + end) + end + + @doc """ + Lists all games that a player is actively participating in. + + Returns a list of game structs with basic information. + """ + def list_player_active_games(player_id) do + # Get all active game IDs from supervisor + game_ids = GameSupervisor.list_games() + + # Fetch the state of each game and filter for those containing the player + game_ids + |> Enum.map(fn id -> + case get_game_state(id) do + {:ok, game} -> {id, game} + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.filter(fn {_id, game} -> + # Check if player is in this game + Enum.any?(game.players, fn p -> p.id == player_id end) + end) + |> Enum.map(fn {id, game} -> + player = Enum.find(game.players, fn p -> p.id == player_id end) + opponent_names = game.players + |> Enum.reject(fn p -> p.id == player_id end) + |> Enum.map(& &1.name) + + %{ + id: id, + players: Enum.map(game.players, & &1.name), + player_count: length(game.players), + playing: game.phase != :lobby, + last_activity: Map.get(game, :last_activity, Map.get(game, :created_at, DateTime.utc_now())), + your_turn: Map.get(game, :current_player_idx, nil) == Enum.find_index(game.players, fn p -> p.id == player_id end), + your_cards: Map.get(player, :hand, []), + opponents: opponent_names, + created_at: Map.get(game, :created_at, DateTime.utc_now()) + } + end) + end end diff --git a/apps/lora/lib/lora/game_supervisor.ex b/apps/lora/lib/lora/game_supervisor.ex index 1163930..44e6479 100644 --- a/apps/lora/lib/lora/game_supervisor.ex +++ b/apps/lora/lib/lora/game_supervisor.ex @@ -16,6 +16,13 @@ defmodule Lora.GameSupervisor do DynamicSupervisor.init(strategy: :one_for_one) end + @doc """ + Lists all active game IDs by querying the registry. + """ + def list_games do + Registry.select(Lora.GameRegistry, [{{:"$1", :_, :_}, [], [:"$1"]}]) + end + @doc """ Creates a new game with a random 6-character ID and starts its server. Also adds the creator as the first player. diff --git a/apps/lora_web/lib/lora_web/components/core_components.ex b/apps/lora_web/lib/lora_web/components/core_components.ex index e64b835..a5cfa72 100644 --- a/apps/lora_web/lib/lora_web/components/core_components.ex +++ b/apps/lora_web/lib/lora_web/components/core_components.ex @@ -243,8 +243,7 @@ defmodule LoraWeb.CoreComponents do
@@ -347,7 +346,7 @@ defmodule LoraWeb.CoreComponents do - - - - - + +
+ +
<.flash_group flash={@flash} /> -{@inner_content} +
+
+ {@inner_content} +
+
diff --git a/apps/lora_web/lib/lora_web/live/game_live.html.heex b/apps/lora_web/lib/lora_web/live/game_live.html.heex index b5a3625..a65e5e3 100644 --- a/apps/lora_web/lib/lora_web/live/game_live.html.heex +++ b/apps/lora_web/lib/lora_web/live/game_live.html.heex @@ -1,4 +1,4 @@ -
+
<%= if @loading do %>
@@ -57,7 +57,7 @@ <% opponent_seat_top = opponent_seats.top %>
- <.player_plate +
- <.player_plate +
- <.player_plate + Enum.sort_by(&(&1.created_at), :desc) + + # Get player's active games if logged in + active_games = + if player_id do + Lora.list_player_active_games(player_id) |> Enum.sort_by(&(&1.last_activity), :desc) + else + [] + end + socket = socket |> assign(:player_id, player_id) |> assign(:player_name, player_name) |> assign(:game_code, "") |> assign(:error_message, nil) + |> assign(:open_games, open_games) + |> assign(:active_games, active_games) + + if connected?(socket), do: Process.send_after(self(), :update_games, 10000) {:ok, socket, temporary_assigns: [error_message: nil]} end + @impl true + def handle_info(:update_games, socket) do + player_id = if socket.assigns.current_player, do: socket.assigns.current_player.sub, else: nil + + # Get updated game lists + open_games = Lora.list_open_games() |> Enum.sort_by(&(&1.created_at), :desc) + + active_games = + if player_id do + Lora.list_player_active_games(player_id) |> Enum.sort_by(&(&1.last_activity), :desc) + else + [] + end + + socket = socket + |> assign(:open_games, open_games) + |> assign(:active_games, active_games) + + Process.send_after(self(), :update_games, 10000) + + {:noreply, socket} + end + @impl true def handle_event("create_game", params, socket) do IO.puts("CREATE GAME CALLED with params: #{inspect(params)}") @@ -127,4 +165,25 @@ defmodule LoraWeb.LobbyLive do push_navigate(socket, to: ~p"/game/#{game_id}", replace: false) end + + def time_ago(datetime) do + # Ensure we're working with DateTime structs + datetime = case datetime do + %DateTime{} -> datetime + %NaiveDateTime{} -> DateTime.from_naive!(datetime, "Etc/UTC") + _ -> + # If it's something else, default to now + DateTime.utc_now() + end + + diff = DateTime.diff(DateTime.utc_now(), datetime) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)} minutes ago" + diff < 86400 -> "#{div(diff, 3600)} hours ago" + diff < 2_592_000 -> "#{div(diff, 86400)} days ago" + true -> Calendar.strftime(datetime, "%Y-%m-%d") + end + end end diff --git a/apps/lora_web/lib/lora_web/live/lobby_live.html.heex b/apps/lora_web/lib/lora_web/live/lobby_live.html.heex index dbcbaaf..54e5269 100644 --- a/apps/lora_web/lib/lora_web/live/lobby_live.html.heex +++ b/apps/lora_web/lib/lora_web/live/lobby_live.html.heex @@ -1,119 +1,192 @@ -
-
-

Lora Card Game

- - <%= if @current_player do %> -
- -
- <.link - href={~p"/auth/logout"} - method="delete" - class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" - > - Sign Out - + {@error_message}
-
- <% else %> - <.link - href={~p"/auth/auth0"} - class="text-indigo-600 hover:text-indigo-800 font-medium" - > - Sign In - - <% end %> -
- - <%= if @error_message do %> -
-

{@error_message}

-
- <% end %> + <% end %> -
-
-

Create a New Game

- - <%= if @current_player do %> -
-

You'll join as: <%= @current_player.name %>

+
+
+
+

Create a New Game

+ + <%= if @current_player do %> +
+ Playing as: <%= @current_player.name %> +
+ + <.form + id="create-game-form" + for={%{}} + phx-submit="create_game" + class="space-y-4" + > +
+ +
+ + <% else %> +
+ + + + Please sign in to create a game +
+ <% end %> +
- <% end %> + +
+
+

Join an Existing Game

+ + <%= if @current_player do %> +
+ Playing as: <%= @current_player.name %> +
+ <% else %> +
+ + + + Sign in to join games +
+ <% end %> - <.form - id="create-game-form" - for={%{}} - phx-submit="create_game" - class="space-y-4" - > -
- <.button - type="submit" - class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" - > - Create Game - + <.form + :let={f} + id="join-game-form" + for={%{}} + as={:join_player} + phx-submit="join_game" + phx-change="validate" + > +
+ + <.input + field={f[:game_code]} + value={@game_code} + placeholder="Enter 6-character code" + required={true} + maxlength={6} + class="input input-bordered w-full uppercase" + /> +
+
+ +
+ +
- +
+
-
-

Join an Existing Game

+ +
+
+

Open Games

- <%= if @current_player do %> -
-

You'll join as: <%= @current_player.name %>

-
- <% end %> - - <.form - :let={f} - id="join-game-form" - for={%{}} - as={:join_player} - phx-submit="join_game" - phx-change="validate" - class="space-y-4" - > -
- - <.input - field={f[:game_code]} - value={@game_code} - placeholder="Enter 6-character code" - required={true} - maxlength={6} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 uppercase" - /> + <%= if Enum.empty?(@open_games) do %> +
+ + + + No open games available. Why not create one?
-
- <.button - type="submit" - class="w-full bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2" - > - Join Game - + <% else %> +
+ <%= for game <- @open_games do %> +
+
+
+

+ Game: <%= game.id %> +
<%= game.player_count %>/4
+

+
+
+

Created by: <%= game.creator %>

+

Created <%= time_ago(game.created_at) %>

+
+
+ <.form :let={f} for={%{}} as={:join_game} phx-submit="join_game"> + <.input type="hidden" field={f[:game_code]} value={game.id} /> + + +
+
+
+ <% end %>
- + <% end %>
-
-

Lora Card Game - Serbian Variant - 4 players, 32-card deck

-
+ + <%= if @current_player && !Enum.empty?(@active_games) do %> +
+
+

Your Active Games

+
+ <%= for game <- @active_games do %> +
+
+
+

+ Game: <%= game.id %> + <%= if game.your_turn do %> +
Your Turn!
+ <% end %> +

+
+ +
+

Players: <%= Enum.join(game.players, ", ") %> (<%= game.player_count %>/4)

+

+ <%= if game.playing do %> + Game in progress + <% else %> + Waiting for players + <% end %> + Last activity: <%= time_ago(game.last_activity) %> +

+
+ +
+ <.link + navigate={~p"/game/#{game.id}"} + class="btn btn-sm btn-primary" + > + Resume + +
+
+
+ <% end %> +
+
+
+ <% end %> + +
+ +
From cc8f692660a79e767d602bb8adec9ab7a2227c0b Mon Sep 17 00:00:00 2001 From: Milan Jaric <327155+mjaric@users.noreply.github.com> Date: Fri, 16 May 2025 15:36:36 +0200 Subject: [PATCH 03/18] better style --- apps/lora_web/assets/tailwind.config.js | 2 +- .../lora_web/components/core_components.ex | 37 --------------- .../components/current_player_components.ex | 14 +++--- .../lora_web/components/layouts/app.html.heex | 6 +-- .../components/layouts/root.html.heex | 2 +- .../lora_web/components/player_components.ex | 47 +++++++++++-------- apps/lora_web/lib/lora_web/live/lobby_live.ex | 2 +- 7 files changed, 41 insertions(+), 69 deletions(-) diff --git a/apps/lora_web/assets/tailwind.config.js b/apps/lora_web/assets/tailwind.config.js index 5d4ca0e..9191b5b 100644 --- a/apps/lora_web/assets/tailwind.config.js +++ b/apps/lora_web/assets/tailwind.config.js @@ -12,7 +12,7 @@ module.exports = { "../lib/lora_web/**/*.*ex" ], daisyui: { - themes: false, // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"] + themes: true, // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"] darkTheme: "dark", // name of one of the included themes for dark mode base: true, // applies background color and foreground color for root element by default styled: true, // include daisyUI colors and design decisions for all components diff --git a/apps/lora_web/lib/lora_web/components/core_components.ex b/apps/lora_web/lib/lora_web/components/core_components.ex index a5cfa72..a1ca852 100644 --- a/apps/lora_web/lib/lora_web/components/core_components.ex +++ b/apps/lora_web/lib/lora_web/components/core_components.ex @@ -608,43 +608,6 @@ defmodule LoraWeb.CoreComponents do """ end - @doc """ - Game player information, renders score, name and presence. Also contains the flag if it is player turn. - """ - attr :name, :string, required: true - attr :score, :integer, default: 0 - attr :is_turn, :boolean, default: false - attr :is_online, :boolean, default: false - - def player_plate(assigns) do - ~H""" -
-
-
-
<%= @score %>
-

- <%= @name %> - <%= if @is_online do %> - Online - <% end %> -

- <%= if @is_turn do %> -
- <% end %> -
-
-
- """ - end - ## JS Commands def show(js \\ %JS{}, selector) do diff --git a/apps/lora_web/lib/lora_web/components/current_player_components.ex b/apps/lora_web/lib/lora_web/components/current_player_components.ex index 3e9f758..7b8d1e8 100644 --- a/apps/lora_web/lib/lora_web/components/current_player_components.ex +++ b/apps/lora_web/lib/lora_web/components/current_player_components.ex @@ -14,16 +14,16 @@ defmodule LoraWeb.CurrentPlayerComponents do
- {@player.name} + {@player.name}
- (Seat {String.trim("#{@player.seat}")}) + (Seat {String.trim("#{@player.seat}")}) <%= if @game.dealer_seat == @player.seat do %> <% end %> -
- +
+ {Map.get(@game.scores, @player.seat, 0)}
- +

Your Hand

diff --git a/apps/lora_web/lib/lora_web/components/layouts/app.html.heex b/apps/lora_web/lib/lora_web/components/layouts/app.html.heex index dd5bff4..9fa3ccb 100644 --- a/apps/lora_web/lib/lora_web/components/layouts/app.html.heex +++ b/apps/lora_web/lib/lora_web/components/layouts/app.html.heex @@ -1,5 +1,5 @@
-