From 0a8db9c21dd34f12b29a402214efa40940510507 Mon Sep 17 00:00:00 2001 From: Jeffrey Ward Date: Sat, 24 Jan 2026 19:13:18 -0500 Subject: [PATCH] Added auth. Put together basic pricing and order API calls. --- traderex/config/runtime.exs | 4 + traderex/lib/core/balance_history.ex | 89 ++++++++ traderex/lib/core/client.ex | 153 +++++++++++++ traderex/lib/core/event.ex | 74 ++++++ traderex/lib/core/order.ex | 39 ++++ traderex/lib/core/quote.ex | 99 ++++++++ traderex/lib/traderex_web/auth.ex | 48 ++++ .../controllers/auth_controller.ex | 23 ++ .../lib/traderex_web/live/dashboard_live.ex | 212 +++++++++++++++++- traderex/lib/traderex_web/live/login_live.ex | 38 ++++ .../lib/traderex_web/plugs/require_auth.ex | 20 ++ traderex/lib/traderex_web/router.ex | 12 + .../traderex_web/live/dashboard_live_test.exs | 29 ++- .../traderex_web/live/login_live_test.exs | 11 + 14 files changed, 844 insertions(+), 7 deletions(-) create mode 100644 traderex/lib/core/balance_history.ex create mode 100644 traderex/lib/core/event.ex create mode 100644 traderex/lib/core/order.ex create mode 100644 traderex/lib/core/quote.ex create mode 100644 traderex/lib/traderex_web/auth.ex create mode 100644 traderex/lib/traderex_web/controllers/auth_controller.ex create mode 100644 traderex/lib/traderex_web/live/login_live.ex create mode 100644 traderex/lib/traderex_web/plugs/require_auth.ex create mode 100644 traderex/test/traderex_web/live/login_live_test.exs diff --git a/traderex/config/runtime.exs b/traderex/config/runtime.exs index ff4c4a3..9b4914f 100644 --- a/traderex/config/runtime.exs +++ b/traderex/config/runtime.exs @@ -11,6 +11,10 @@ import Config config :traderex, :tradier_api_key, System.get_env("TRADIER_API_KEY") config :traderex, :tradier_account, System.get_env("TRADIER_ACCOUNT") +# Dashboard authentication +config :traderex, :dashboard_user, System.get_env("DASHBOARD_USER") +config :traderex, :dashboard_password, System.get_env("DASHBOARD_PASSWORD") + # ## Using releases # # If you use `mix release`, you need to explicitly enable the server diff --git a/traderex/lib/core/balance_history.ex b/traderex/lib/core/balance_history.ex new file mode 100644 index 0000000..38243af --- /dev/null +++ b/traderex/lib/core/balance_history.ex @@ -0,0 +1,89 @@ +defmodule Core.BalanceHistory do + @moduledoc """ + Represents historical balance data for a Tradier account. + """ + + defmodule Snapshot do + @moduledoc """ + Represents a single balance snapshot at a point in time. + """ + + @type t :: %__MODULE__{ + date: Date.t() | nil, + value: float() + } + + defstruct [:date, :value] + + @spec from_api(map()) :: t() + def from_api(data) when is_map(data) do + %__MODULE__{ + date: parse_date(data["date"]), + value: to_float(data["value"]) + } + end + + defp parse_date(nil), do: nil + + defp parse_date(date_string) when is_binary(date_string) do + case Date.from_iso8601(date_string) do + {:ok, date} -> date + {:error, _reason} -> nil + end + end + + defp to_float(value) when is_float(value), do: value + defp to_float(value) when is_integer(value), do: value / 1 + defp to_float(_), do: 0.0 + end + + @type t :: %__MODULE__{ + balances: [Snapshot.t()], + delta: float(), + delta_percent: float() + } + + defstruct [ + :balances, + :delta, + :delta_percent + ] + + @doc """ + Creates a BalanceHistory struct from a Tradier API historical-balances response. + + ## Example + + iex> data = %{"historical_balances" => %{"balances" => %{"balance" => [...]}, "delta" => 100.0, "delta_percent" => 1.5}} + iex> Core.BalanceHistory.from_api(data) + %Core.BalanceHistory{balances: [...], delta: 100.0, delta_percent: 1.5} + """ + @spec from_api(map()) :: t() + def from_api(%{"historical_balances" => data}) do + from_api(data) + end + + def from_api(data) when is_map(data) do + balances = + case data do + %{"balances" => %{"balance" => list}} when is_list(list) -> + Enum.map(list, &Snapshot.from_api/1) + + %{"balances" => %{"balance" => single}} when is_map(single) -> + [Snapshot.from_api(single)] + + _ -> + [] + end + + %__MODULE__{ + balances: balances, + delta: to_float(data["delta"]), + delta_percent: to_float(data["delta_percent"]) + } + end + + defp to_float(value) when is_float(value), do: value + defp to_float(value) when is_integer(value), do: value / 1 + defp to_float(_), do: 0.0 +end diff --git a/traderex/lib/core/client.ex b/traderex/lib/core/client.ex index f5f7ec4..8c8cd73 100644 --- a/traderex/lib/core/client.ex +++ b/traderex/lib/core/client.ex @@ -66,6 +66,144 @@ defmodule Core.Client do end end + @doc """ + Fetches account history events and returns a list of Event structs. + + ## Example + + {:ok, events} = Core.Client.get_history("6YA15850") + #=> [%Core.Event{type: "trade", symbol: "AAPL", ...}] + """ + @spec get_history(String.t()) :: {:ok, [Core.Event.t()]} | {:error, term()} + def get_history(account_id) do + case get("/accounts/#{account_id}/history") do + {:ok, body} -> {:ok, Core.Event.list_from_api(body)} + {:error, reason} -> {:error, reason} + end + end + + @balance_history_periods ~w(WEEK MONTH YTD YEAR YEAR_3 YEAR_5 ALL) + + @doc """ + Returns the valid period values for balance history. + """ + def balance_history_periods, do: @balance_history_periods + + @doc """ + Fetches historical balance data for an account. + + ## Options + + * `:period` - Time period for history. One of: `WEEK`, `MONTH`, `YTD`, `YEAR`, `YEAR_3`, `YEAR_5`, `ALL`. + Defaults to `MONTH`. + + ## Example + + {:ok, history} = Core.Client.get_balance_history("6YA15850", period: "YEAR") + history.delta + #=> 2780.66 + history.balances + #=> [%Core.BalanceHistory.Snapshot{date: ~D[2025-07-15], value: 113302.4}, ...] + """ + @spec get_balance_history(String.t(), keyword()) :: + {:ok, Core.BalanceHistory.t()} | {:error, term()} + def get_balance_history(account_id, opts \\ []) do + period = Keyword.get(opts, :period, "MONTH") + query = URI.encode_query(%{period: period}) + + case get("/accounts/#{account_id}/historical-balances?#{query}") do + {:ok, body} -> {:ok, Core.BalanceHistory.from_api(body)} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Fetches stock quotes for one or more symbols. + + ## Examples + + {:ok, quotes} = Core.Client.get_quotes("SPY") + #=> [%Core.Quote{symbol: "SPY", last: 273.47, ...}] + + {:ok, quotes} = Core.Client.get_quotes(["SPY", "AAPL"]) + #=> [%Core.Quote{symbol: "SPY", ...}, %Core.Quote{symbol: "AAPL", ...}] + """ + @spec get_quotes(String.t() | [String.t()]) :: {:ok, [Core.Quote.t()]} | {:error, term()} + def get_quotes(symbols) when is_list(symbols) do + symbols_str = Enum.join(symbols, ",") + get_quotes(symbols_str) + end + + def get_quotes(symbol) when is_binary(symbol) do + query = URI.encode_query(%{symbols: symbol}) + + case get("/markets/quotes?#{query}") do + {:ok, body} -> {:ok, Core.Quote.list_from_api(body)} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Places an order for an account. + + ## Options + + * `:symbol` - The stock symbol (required) + * `:quantity` - Number of shares (required) + * `:side` - Order side: "buy" or "sell" (required) + * `:type` - Order type: "market", "limit", "stop", "stop_limit" (required) + * `:price` - Limit price (required for limit orders) + * `:stop` - Stop price (required for stop orders) + * `:duration` - Order duration: "day", "gtc", "pre", "post" (default: "day") + * `:class` - Order class: "equity", "option", "multileg", "combo" (default: "equity") + * `:preview` - If true, validates order without submitting (default: true) + + ## Examples + + # Preview an order + {:ok, order} = Core.Client.place_order("6YA15850", + symbol: "SPY", + quantity: 10, + side: "buy", + type: "limit", + price: 500, + preview: true + ) + + # Submit the order + {:ok, order} = Core.Client.place_order("6YA15850", + symbol: "SPY", + quantity: 10, + side: "buy", + type: "limit", + price: 500, + preview: false + ) + """ + @spec place_order(String.t(), keyword()) :: {:ok, Core.Order.t()} | {:error, term()} + def place_order(account_id, opts) do + params = + %{ + class: Keyword.get(opts, :class, "equity"), + symbol: Keyword.fetch!(opts, :symbol), + quantity: Keyword.fetch!(opts, :quantity), + side: Keyword.fetch!(opts, :side), + type: Keyword.fetch!(opts, :type), + duration: Keyword.get(opts, :duration, "day"), + preview: Keyword.get(opts, :preview, true) + } + |> maybe_put(:price, Keyword.get(opts, :price)) + |> maybe_put(:stop, Keyword.get(opts, :stop)) + + case post("/accounts/#{account_id}/orders", params) do + {:ok, body} -> {:ok, Core.Order.from_api(body)} + {:error, reason} -> {:error, reason} + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp get(path) do url = @base_url <> path @@ -81,6 +219,21 @@ defmodule Core.Client do end end + defp post(path, params) do + url = @base_url <> path + + case Req.post(url, headers: headers(), form: params) do + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:http_error, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + defp headers do [ accept: "application/json", diff --git a/traderex/lib/core/event.ex b/traderex/lib/core/event.ex new file mode 100644 index 0000000..605b754 --- /dev/null +++ b/traderex/lib/core/event.ex @@ -0,0 +1,74 @@ +defmodule Core.Event do + @moduledoc """ + Represents an account history event from Tradier. + """ + + @type t :: %__MODULE__{ + date: DateTime.t() | nil, + type: String.t(), + symbol: String.t() | nil, + quantity: float() | nil, + price: float() | nil, + amount: float(), + description: String.t(), + commission: float() + } + + defstruct [ + :date, + :type, + :symbol, + :quantity, + :price, + :amount, + :description, + :commission + ] + + @doc """ + Creates an Event struct from a Tradier API event response map. + """ + @spec from_api(map()) :: t() + def from_api(data) when is_map(data) do + %__MODULE__{ + date: parse_datetime(data["date"]), + type: data["type"], + symbol: data["symbol"], + quantity: data["quantity"], + price: data["price"], + amount: data["amount"], + description: data["description"], + commission: data["commission"] + } + end + + @doc """ + Parses events from a Tradier API history response. + + The API returns events wrapped in a "history" key, and the "event" + value can be either a single map (one event) or a list (multiple events). + + Returns an empty list if there are no events. + """ + @spec list_from_api(map()) :: [t()] + def list_from_api(%{"history" => %{"event" => events}}) when is_list(events) do + Enum.map(events, &from_api/1) + end + + def list_from_api(%{"history" => %{"event" => event}}) when is_map(event) do + [from_api(event)] + end + + def list_from_api(%{"history" => "null"}), do: [] + def list_from_api(%{"history" => nil}), do: [] + def list_from_api(_), do: [] + + defp parse_datetime(nil), do: nil + + defp parse_datetime(datetime_string) when is_binary(datetime_string) do + case DateTime.from_iso8601(datetime_string) do + {:ok, datetime, _offset} -> datetime + {:error, _reason} -> nil + end + end +end diff --git a/traderex/lib/core/order.ex b/traderex/lib/core/order.ex new file mode 100644 index 0000000..a7239a2 --- /dev/null +++ b/traderex/lib/core/order.ex @@ -0,0 +1,39 @@ +defmodule Core.Order do + @moduledoc """ + Represents an order response from Tradier. + """ + + @type t :: %__MODULE__{ + id: integer() | nil, + status: String.t() | nil, + partner_id: String.t() | nil + } + + defstruct [ + :id, + :status, + :partner_id + ] + + @doc """ + Creates an Order struct from a Tradier API order response. + + ## Example + + iex> data = %{"order" => %{"id" => 123456, "status" => "ok", "partner_id" => "partner_12345"}} + iex> Core.Order.from_api(data) + %Core.Order{id: 123456, status: "ok", partner_id: "partner_12345"} + """ + @spec from_api(map()) :: t() + def from_api(%{"order" => order}) when is_map(order) do + from_api(order) + end + + def from_api(data) when is_map(data) do + %__MODULE__{ + id: data["id"], + status: data["status"], + partner_id: data["partner_id"] + } + end +end diff --git a/traderex/lib/core/quote.ex b/traderex/lib/core/quote.ex new file mode 100644 index 0000000..4f862e3 --- /dev/null +++ b/traderex/lib/core/quote.ex @@ -0,0 +1,99 @@ +defmodule Core.Quote do + @moduledoc """ + Represents a stock quote from Tradier. + """ + + @type t :: %__MODULE__{ + symbol: String.t(), + description: String.t() | nil, + exch: String.t() | nil, + type: String.t() | nil, + last: float() | nil, + change: float() | nil, + change_percentage: float() | nil, + volume: integer() | nil, + open: float() | nil, + high: float() | nil, + low: float() | nil, + close: float() | nil, + bid: float() | nil, + ask: float() | nil, + prevclose: float() | nil, + week_52_high: float() | nil, + week_52_low: float() | nil, + average_volume: integer() | nil + } + + defstruct [ + :symbol, + :description, + :exch, + :type, + :last, + :change, + :change_percentage, + :volume, + :open, + :high, + :low, + :close, + :bid, + :ask, + :prevclose, + :week_52_high, + :week_52_low, + :average_volume + ] + + @doc """ + Creates a Quote struct from a Tradier API quote response map. + """ + @spec from_api(map()) :: t() + def from_api(data) when is_map(data) do + %__MODULE__{ + symbol: data["symbol"], + description: data["description"], + exch: data["exch"], + type: data["type"], + last: to_float(data["last"]), + change: to_float(data["change"]), + change_percentage: to_float(data["change_percentage"]), + volume: data["volume"], + open: to_float(data["open"]), + high: to_float(data["high"]), + low: to_float(data["low"]), + close: to_float(data["close"]), + bid: to_float(data["bid"]), + ask: to_float(data["ask"]), + prevclose: to_float(data["prevclose"]), + week_52_high: to_float(data["week_52_high"]), + week_52_low: to_float(data["week_52_low"]), + average_volume: data["average_volume"] + } + end + + @doc """ + Parses quotes from a Tradier API quotes response. + + The API returns quotes wrapped in a "quotes" key, and the "quote" + value can be either a single map (one symbol) or a list (multiple symbols). + + Returns an empty list if there are no quotes. + """ + @spec list_from_api(map()) :: [t()] + def list_from_api(%{"quotes" => %{"quote" => quotes}}) when is_list(quotes) do + Enum.map(quotes, &from_api/1) + end + + def list_from_api(%{"quotes" => %{"quote" => quote}}) when is_map(quote) do + [from_api(quote)] + end + + def list_from_api(%{"quotes" => "null"}), do: [] + def list_from_api(%{"quotes" => nil}), do: [] + def list_from_api(_), do: [] + + defp to_float(value) when is_float(value), do: value + defp to_float(value) when is_integer(value), do: value / 1 + defp to_float(_), do: nil +end diff --git a/traderex/lib/traderex_web/auth.ex b/traderex/lib/traderex_web/auth.ex new file mode 100644 index 0000000..e0b101a --- /dev/null +++ b/traderex/lib/traderex_web/auth.ex @@ -0,0 +1,48 @@ +defmodule TraderexWeb.Auth do + @moduledoc """ + Simple authentication module using environment variables. + """ + + import Plug.Conn + + @doc """ + Verifies the given credentials against the configured environment variables. + """ + def verify_credentials(username, password) do + configured_user = Application.get_env(:traderex, :dashboard_user) + configured_password = Application.get_env(:traderex, :dashboard_password) + + if configured_user && configured_password && + Plug.Crypto.secure_compare(username, configured_user) && + Plug.Crypto.secure_compare(password, configured_password) do + :ok + else + :error + end + end + + @doc """ + Logs in the user by setting the session. + """ + def login(conn) do + conn + |> put_session(:authenticated, true) + |> configure_session(renew: true) + end + + @doc """ + Logs out the user by clearing the session. + """ + def logout(conn) do + conn + |> clear_session() + |> configure_session(drop: true) + end + + @doc """ + Checks if the user is authenticated. + """ + def authenticated?(conn) do + get_session(conn, :authenticated) == true + end +end diff --git a/traderex/lib/traderex_web/controllers/auth_controller.ex b/traderex/lib/traderex_web/controllers/auth_controller.ex new file mode 100644 index 0000000..53933df --- /dev/null +++ b/traderex/lib/traderex_web/controllers/auth_controller.ex @@ -0,0 +1,23 @@ +defmodule TraderexWeb.AuthController do + use TraderexWeb, :controller + + def login(conn, %{"credentials" => %{"username" => username, "password" => password}}) do + case TraderexWeb.Auth.verify_credentials(username, password) do + :ok -> + conn + |> TraderexWeb.Auth.login() + |> redirect(to: ~p"/") + + :error -> + conn + |> put_flash(:error, "Invalid username or password") + |> redirect(to: ~p"/login") + end + end + + def logout(conn, _params) do + conn + |> TraderexWeb.Auth.logout() + |> redirect(to: ~p"/login") + end +end diff --git a/traderex/lib/traderex_web/live/dashboard_live.ex b/traderex/lib/traderex_web/live/dashboard_live.ex index e6bc17c..785a4b2 100644 --- a/traderex/lib/traderex_web/live/dashboard_live.ex +++ b/traderex/lib/traderex_web/live/dashboard_live.ex @@ -9,6 +9,11 @@ defmodule TraderexWeb.DashboardLive do socket |> assign(:account, nil) |> assign(:positions, []) + |> assign(:events, []) + |> assign(:balance_history, nil) + |> assign(:balance_period, "MONTH") + |> assign(:balance_history_expanded, false) + |> assign(:events_expanded, false) |> assign(:loading, true) |> assign(:error, nil) @@ -22,25 +27,45 @@ defmodule TraderexWeb.DashboardLive do @impl true def handle_info(:load_data, socket) do account_id = Core.Client.account_id() + period = socket.assigns.balance_period + + results = { + Core.Client.get_account(account_id), + Core.Client.get_positions(account_id), + Core.Client.get_history(account_id), + Core.Client.get_balance_history(account_id, period: period) + } socket = - case {Core.Client.get_account(account_id), Core.Client.get_positions(account_id)} do - {{:ok, account}, {:ok, positions}} -> + case results do + {{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} -> socket |> assign(:account, account) |> assign(:positions, positions) + |> assign(:events, Enum.take(events, 5)) + |> assign(:balance_history, balance_history) |> assign(:loading, false) |> assign(:error, nil) - {{:error, reason}, _} -> + {{:error, reason}, _, _, _} -> socket |> assign(:loading, false) |> assign(:error, "Failed to load account: #{inspect(reason)}") - {_, {:error, reason}} -> + {_, {:error, reason}, _, _} -> socket |> assign(:loading, false) |> assign(:error, "Failed to load positions: #{inspect(reason)}") + + {_, _, {:error, reason}, _} -> + socket + |> assign(:loading, false) + |> assign(:error, "Failed to load history: #{inspect(reason)}") + + {_, _, _, {:error, reason}} -> + socket + |> assign(:loading, false) + |> assign(:error, "Failed to load balance history: #{inspect(reason)}") end schedule_refresh() @@ -51,6 +76,28 @@ defmodule TraderexWeb.DashboardLive do Process.send_after(self(), :load_data, @refresh_interval) end + @impl true + def handle_event("change_period", %{"period" => period}, socket) do + socket = + socket + |> assign(:balance_period, period) + |> assign(:balance_history, nil) + + send(self(), :load_data) + {:noreply, socket} + end + + @impl true + def handle_event("toggle_balance_history", _, socket) do + {:noreply, + assign(socket, :balance_history_expanded, !socket.assigns.balance_history_expanded)} + end + + @impl true + def handle_event("toggle_events", _, socket) do + {:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)} + end + @impl true def render(assigns) do ~H""" @@ -58,6 +105,14 @@ defmodule TraderexWeb.DashboardLive do <.header> Traderex Dashboard <:subtitle>Account Overview + <:actions> +
+ + <.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm"> + <.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout + +
+
@@ -112,6 +167,71 @@ defmodule TraderexWeb.DashboardLive do />
+
+
+
+

Balance History

+
+ +
+
+
+ +
+
+
+
{format_period_label(@balance_period)} Change
+
+ {format_currency(@balance_history.delta)} +
+
+
+
{format_period_label(@balance_period)} Change %
+
+ {format_percent(@balance_history.delta_percent)} +
+
+
+
+ + <.table + :if={@balance_history_expanded} + id="balance-history" + rows={Enum.take(@balance_history.balances, -10) |> Enum.reverse()} + row_id={&"balance-#{&1.date}"} + > + <:col :let={snapshot} label="Date"> + {format_date_only(snapshot.date)} + + <:col :let={snapshot} label="Value"> + {format_currency(snapshot.value)} + + +
+
+
+

Positions

@@ -142,6 +262,51 @@ defmodule TraderexWeb.DashboardLive do
+ +
+
+
+

Recent Activity

+ + {length(@events)} events + +
+
+ No recent activity +
+
+ + <.table + :if={@events_expanded} + id="events" + rows={@events} + row_id={&"event-#{&1.date}"} + > + <:col :let={event} label="Date"> + {format_datetime(event.date)} + + <:col :let={event} label="Type"> + {event.type} + + <:col :let={event} label="Description"> + {event.description} + + <:col :let={event} label="Amount"> + {format_currency(event.amount)} + + +
+
+
""" @@ -166,7 +331,11 @@ defmodule TraderexWeb.DashboardLive do defp format_currency(nil), do: "-" - defp format_currency(amount) when is_number(amount) do + defp format_currency(amount) when is_integer(amount) do + format_currency(amount / 1) + end + + defp format_currency(amount) when is_float(amount) do sign = if amount < 0, do: "-", else: "" "#{sign}$#{:erlang.float_to_binary(abs(amount), decimals: 2)}" end @@ -189,7 +358,40 @@ defmodule TraderexWeb.DashboardLive do Calendar.strftime(dt, "%Y-%m-%d") end + defp format_date_only(nil), do: "-" + + defp format_date_only(%Date{} = date) do + Calendar.strftime(date, "%Y-%m-%d") + end + + defp format_percent(nil), do: "-" + + defp format_percent(percent) when is_number(percent) do + sign = if percent >= 0, do: "+", else: "" + "#{sign}#{:erlang.float_to_binary(percent / 1, decimals: 2)}%" + end + + defp format_datetime(nil), do: "-" + + defp format_datetime(%DateTime{} = dt) do + Calendar.strftime(dt, "%Y-%m-%d %H:%M") + end + defp pl_color(nil), do: nil defp pl_color(amount) when amount >= 0, do: "text-success" defp pl_color(_amount), do: "text-error" + + defp amount_color(nil), do: nil + defp amount_color(amount) when amount > 0, do: "text-success" + defp amount_color(amount) when amount < 0, do: "text-error" + defp amount_color(_amount), do: nil + + defp format_period_label("WEEK"), do: "1W" + defp format_period_label("MONTH"), do: "1M" + defp format_period_label("YTD"), do: "YTD" + defp format_period_label("YEAR"), do: "1Y" + defp format_period_label("YEAR_3"), do: "3Y" + defp format_period_label("YEAR_5"), do: "5Y" + defp format_period_label("ALL"), do: "All" + defp format_period_label(period), do: period end diff --git a/traderex/lib/traderex_web/live/login_live.ex b/traderex/lib/traderex_web/live/login_live.ex new file mode 100644 index 0000000..0a3904c --- /dev/null +++ b/traderex/lib/traderex_web/live/login_live.ex @@ -0,0 +1,38 @@ +defmodule TraderexWeb.LoginLive do + use TraderexWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + form = to_form(%{"username" => "", "password" => ""}, as: :credentials) + {:ok, assign(socket, form: form)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+

Traderex Login

+ + <.flash :if={@flash[:error]} kind={:error} flash={@flash} /> + + <.form + for={@form} + id="login-form" + action={~p"/auth/login"} + method="post" + class="space-y-4" + > + <.input field={@form[:username]} type="text" label="Username" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + <.button type="submit" variant="primary" class="w-full"> + Sign In + + +
+
+
+ """ + end +end diff --git a/traderex/lib/traderex_web/plugs/require_auth.ex b/traderex/lib/traderex_web/plugs/require_auth.ex new file mode 100644 index 0000000..857300c --- /dev/null +++ b/traderex/lib/traderex_web/plugs/require_auth.ex @@ -0,0 +1,20 @@ +defmodule TraderexWeb.Plugs.RequireAuth do + @moduledoc """ + Plug that requires authentication to access protected routes. + """ + + import Plug.Conn + import Phoenix.Controller + + def init(opts), do: opts + + def call(conn, _opts) do + if TraderexWeb.Auth.authenticated?(conn) do + conn + else + conn + |> redirect(to: "/login") + |> halt() + end + end +end diff --git a/traderex/lib/traderex_web/router.ex b/traderex/lib/traderex_web/router.ex index 8201b79..572d0b7 100644 --- a/traderex/lib/traderex_web/router.ex +++ b/traderex/lib/traderex_web/router.ex @@ -14,9 +14,21 @@ defmodule TraderexWeb.Router do plug :accepts, ["json"] end + pipeline :require_auth do + plug TraderexWeb.Plugs.RequireAuth + end + scope "/", TraderexWeb do pipe_through :browser + live "/login", LoginLive + post "/auth/login", AuthController, :login + delete "/auth/logout", AuthController, :logout + end + + scope "/", TraderexWeb do + pipe_through [:browser, :require_auth] + live "/", DashboardLive end diff --git a/traderex/test/traderex_web/live/dashboard_live_test.exs b/traderex/test/traderex_web/live/dashboard_live_test.exs index 91f2a9e..c3c7543 100644 --- a/traderex/test/traderex_web/live/dashboard_live_test.exs +++ b/traderex/test/traderex_web/live/dashboard_live_test.exs @@ -3,13 +3,38 @@ defmodule TraderexWeb.DashboardLiveTest do import Phoenix.LiveViewTest - test "renders dashboard", %{conn: conn} do + setup do + # Set test credentials + Application.put_env(:traderex, :dashboard_user, "testuser") + Application.put_env(:traderex, :dashboard_password, "testpass") + + on_exit(fn -> + Application.delete_env(:traderex, :dashboard_user) + Application.delete_env(:traderex, :dashboard_password) + end) + + :ok + end + + defp log_in(conn) do + conn + |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_session(:authenticated, true) + end + + test "redirects to login when not authenticated", %{conn: conn} do + assert {:error, {:redirect, %{to: "/login"}}} = live(conn, ~p"/") + end + + test "renders dashboard when authenticated", %{conn: conn} do + conn = log_in(conn) {:ok, view, html} = live(conn, ~p"/") assert html =~ "Traderex Dashboard" assert has_element?(view, "header", "Account Overview") end - test "shows loading state initially", %{conn: conn} do + test "shows loading state initially when authenticated", %{conn: conn} do + conn = log_in(conn) {:ok, _view, html} = live(conn, ~p"/") assert html =~ "loading loading-spinner" end diff --git a/traderex/test/traderex_web/live/login_live_test.exs b/traderex/test/traderex_web/live/login_live_test.exs new file mode 100644 index 0000000..f1e3554 --- /dev/null +++ b/traderex/test/traderex_web/live/login_live_test.exs @@ -0,0 +1,11 @@ +defmodule TraderexWeb.LoginLiveTest do + use TraderexWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + test "renders login form", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/login") + assert html =~ "Traderex Login" + assert has_element?(view, "#login-form") + end +end