Add agent rename functionality

Implemented ability to rename agents through a new edit page following TDD:

Backend changes:
- Added update_agent_token/2 in Agents context for updating agent names
- Added update_changeset/2 in AgentToken schema that only allows name updates
- Security: Token field cannot be changed, only name field is allowed

LiveView implementation:
- Created AgentLive.Edit module with mount, validate, and save handlers
- Created edit.html.heex template with form for renaming agents
- Added route /orgs/:org_slug/agents/:id/edit
- Added Edit button to agent show page header
- Validates organization ownership using Repo.get_by!

Tests:
- Added comprehensive context tests for update_agent_token/2
- Added LiveView tests for edit page functionality
- Tests cover: loading, validation, save/redirect, auth, and org ownership

All 801 tests passing, no Credo warnings.
This commit is contained in:
Graham McIntire 2026-01-17 15:48:47 -06:00
parent c35a502425
commit 930885e21a
No known key found for this signature in database
8 changed files with 272 additions and 0 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,58 @@
<Layouts.app flash={@flash}>
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<.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"
>
<span class="hero-arrow-left h-4 w-4 inline"></span> Back to agent
</.link>
</div>
<div class="bg-white dark:bg-zinc-900 shadow rounded-lg">
<div class="px-6 py-5 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="text-lg font-medium text-zinc-900 dark:text-zinc-100">
Edit Agent
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Update the agent name. The authentication token cannot be changed.
</p>
</div>
<.form
for={@form}
id="edit-agent-form"
phx-change="validate"
phx-submit="save"
class="px-6 py-5"
>
<div class="space-y-6">
<div>
<.input
field={@form[:name]}
type="text"
label="Agent Name"
placeholder="e.g., Datacenter Agent"
required
/>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
A descriptive name to identify this agent in your organization.
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-4">
<.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
</.link>
<.button type="submit" phx-disable-with="Saving...">
Save Changes
</.button>
</div>
</.form>
</div>
</div>
</Layouts.app>

View file

@ -8,6 +8,12 @@
{@page_title}
<:subtitle>Agent Details and Health</:subtitle>
<: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>
<.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"

View file

@ -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

View file

@ -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} =

View file

@ -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&#39;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