diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex index 40c135d4..91383cfd 100644 --- a/lib/towerops/agents.ex +++ b/lib/towerops/agents.ex @@ -146,6 +146,27 @@ defmodule Towerops.Agents do end end + @doc """ + Updates an agent token with the given attributes. + + Currently only supports updating the name field. Other fields like + the token itself cannot be changed (use regenerate_token instead). + + ## Examples + + iex> update_agent_token(agent_token, %{name: "New Name"}) + {:ok, %AgentToken{name: "New Name"}} + + iex> update_agent_token(agent_token, %{name: ""}) + {:error, %Ecto.Changeset{}} + + """ + def update_agent_token(%AgentToken{} = agent_token, attrs) do + agent_token + |> AgentToken.update_changeset(attrs) + |> Repo.update() + end + @doc """ Revokes an agent token by setting enabled to false. diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex index 092dd5a2..59159f1b 100644 --- a/lib/towerops/agents/agent_token.ex +++ b/lib/towerops/agents/agent_token.ex @@ -80,6 +80,29 @@ defmodule Towerops.Agents.AgentToken do {encoded_token, changeset} end + @doc """ + Changeset for updating an agent token. + + Only allows updating the name field. Other fields like the token itself + cannot be changed for security reasons. + + ## Examples + + iex> changeset = AgentToken.update_changeset(agent_token, %{name: "New Name"}) + iex> changeset.valid? + true + + iex> changeset = AgentToken.update_changeset(agent_token, %{name: ""}) + iex> changeset.valid? + false + + """ + def update_changeset(agent_token, attrs) do + agent_token + |> cast(attrs, [:name]) + |> validate_required([:name]) + end + @doc """ Verifies a token by checking if it exists in the database. diff --git a/lib/towerops_web/live/agent_live/edit.ex b/lib/towerops_web/live/agent_live/edit.ex new file mode 100644 index 00000000..ad0fa8b9 --- /dev/null +++ b/lib/towerops_web/live/agent_live/edit.ex @@ -0,0 +1,52 @@ +defmodule ToweropsWeb.AgentLive.Edit do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Agents + alias Towerops.Agents.AgentToken + + @impl true + def mount(%{"id" => id}, _session, socket) do + organization = socket.assigns.current_organization + + # Get agent token and verify it belongs to this organization + agent_token = Towerops.Repo.get_by!(AgentToken, id: id, organization_id: organization.id) + + changeset = AgentToken.update_changeset(agent_token, %{}) + + {:ok, + socket + |> assign(:page_title, "Edit #{agent_token.name}") + |> assign(:agent_token, agent_token) + |> assign(:form, to_form(changeset))} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("validate", %{"agent_token" => agent_params}, socket) do + changeset = + socket.assigns.agent_token + |> AgentToken.update_changeset(agent_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + @impl true + def handle_event("save", %{"agent_token" => agent_params}, socket) do + case Agents.update_agent_token(socket.assigns.agent_token, agent_params) do + {:ok, agent_token} -> + {:noreply, + socket + |> put_flash(:info, "Agent updated successfully") + |> push_navigate(to: ~p"/orgs/#{socket.assigns.current_organization.slug}/agents/#{agent_token.id}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end +end diff --git a/lib/towerops_web/live/agent_live/edit.html.heex b/lib/towerops_web/live/agent_live/edit.html.heex new file mode 100644 index 00000000..4f1cb434 --- /dev/null +++ b/lib/towerops_web/live/agent_live/edit.html.heex @@ -0,0 +1,58 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}"} + class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Back to agent + +
+ +
+
+

+ Edit Agent +

+

+ Update the agent name. The authentication token cannot be changed. +

+
+ + <.form + for={@form} + id="edit-agent-form" + phx-change="validate" + phx-submit="save" + class="px-6 py-5" + > +
+
+ <.input + field={@form[:name]} + type="text" + label="Agent Name" + placeholder="e.g., Datacenter Agent" + required + /> +

+ A descriptive name to identify this agent in your organization. +

+
+
+ +
+ <.link + navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}"} + class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100" + > + Cancel + + <.button type="submit" phx-disable-with="Saving..."> + Save Changes + +
+ +
+
+
diff --git a/lib/towerops_web/live/agent_live/show.html.heex b/lib/towerops_web/live/agent_live/show.html.heex index 2d760ede..997c12ab 100644 --- a/lib/towerops_web/live/agent_live/show.html.heex +++ b/lib/towerops_web/live/agent_live/show.html.heex @@ -8,6 +8,12 @@ {@page_title} <:subtitle>Agent Details and Health <:actions> + <.link + navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}/edit"} + class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600" + > + <.icon name="hero-pencil" class="h-5 w-5" /> Edit Agent + <.link navigate={~p"/orgs/#{@current_organization.slug}/agents"} class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100" diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 9f191c92..84992214 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -196,6 +196,7 @@ defmodule ToweropsWeb.Router do # Agent routes live "/agents", AgentLive.Index, :index + live "/agents/:id/edit", AgentLive.Edit, :edit live "/agents/:id", AgentLive.Show, :show end end diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs index 22373c56..4d0597d0 100644 --- a/test/towerops/agents_test.exs +++ b/test/towerops/agents_test.exs @@ -83,6 +83,35 @@ defmodule Towerops.AgentsTest do end end + describe "update_agent_token/2" do + test "updates agent token name", %{organization: org} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Old Name") + + assert {:ok, updated_token} = Agents.update_agent_token(agent_token, %{name: "New Name"}) + assert updated_token.name == "New Name" + assert updated_token.id == agent_token.id + end + + test "returns error with invalid data", %{organization: org} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Name") + + assert {:error, changeset} = Agents.update_agent_token(agent_token, %{name: ""}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "does not update other fields", %{organization: org} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Name") + original_token = agent_token.token + + {:ok, updated_token} = + Agents.update_agent_token(agent_token, %{name: "Updated Name", token: "hacked"}) + + assert updated_token.name == "Updated Name" + assert updated_token.token == original_token + end + end + describe "delete_agent_token/1" do setup %{organization: org} do {:ok, site} = diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs index 4bddcbed..118325dd 100644 --- a/test/towerops_web/live/agent_live_test.exs +++ b/test/towerops_web/live/agent_live_test.exs @@ -159,4 +159,86 @@ defmodule ToweropsWeb.AgentLiveTest do assert html =~ "2d ago" end end + + describe "Edit" do + test "loads edit page with existing agent name", %{conn: conn, organization: organization} do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Old Agent Name") + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + + assert html =~ "Edit Agent" + assert html =~ "Old Agent Name" + assert html =~ "Update the agent name" + end + + test "validates empty name shows error on submit", %{conn: conn, organization: organization} do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent") + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + + html = + view + |> form("#edit-agent-form", agent_token: %{name: ""}) + |> render_submit() + + assert html =~ "can't be blank" + + # Verify agent was not updated + agent = Agents.get_agent_token!(agent_token.id) + assert agent.name == "Test Agent" + end + + test "successful save updates agent and redirects to show page", %{ + conn: conn, + organization: organization + } do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Old Name") + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + + view + |> form("#edit-agent-form", agent_token: %{name: "New Name"}) + |> render_submit() + + # Verify redirect to show page with flash message + assert_redirect(view, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}") + + # Verify agent was updated + updated_agent = Agents.get_agent_token!(agent_token.id) + assert updated_agent.name == "New Name" + end + + test "cancel button navigates back to show page", %{conn: conn, organization: organization} do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent") + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + + assert html =~ "Cancel" + + # Find cancel link and verify it points to show page + assert html =~ + ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}" + end + + test "requires authentication", %{organization: organization} do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent") + + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + + test "verifies agent belongs to organization", %{conn: conn, organization: organization, user: user} do + # Create another organization + {:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id) + {:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent") + + # Try to edit agent from other organization + assert_raise Ecto.NoResultsError, fn -> + live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit") + end + end + end end