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:
parent
c35a502425
commit
930885e21a
8 changed files with 272 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
52
lib/towerops_web/live/agent_live/edit.ex
Normal file
52
lib/towerops_web/live/agent_live/edit.ex
Normal 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
|
||||
58
lib/towerops_web/live/agent_live/edit.html.heex
Normal file
58
lib/towerops_web/live/agent_live/edit.html.heex
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue