feat(#94): implement spoke configuration UI

Implements the final piece of M7-Federation - the spoke configuration UI
that allows administrators to configure their local instance's federation
capabilities and settings.

Backend Changes:
- Add UpdateInstanceDto with validation for name, capabilities, and metadata
- Implement FederationService.updateInstanceConfiguration() method
- Add PATCH /api/v1/federation/instance endpoint to FederationController
- Add audit logging for configuration updates
- Add tests for updateInstanceConfiguration (5 new tests, all passing)

Frontend Changes:
- Create SpokeConfigurationForm component with PDA-friendly design
- Create /federation/settings page with configuration management
- Add regenerate keypair functionality with confirmation dialog
- Extend federation API client with updateInstanceConfiguration and regenerateInstanceKeys
- Add comprehensive tests (10 tests, all passing)

Design Decisions:
- Admin-only access via AdminGuard
- Never expose private key in API responses (security)
- PDA-friendly language throughout (no demanding terms)
- Clear visual hierarchy with read-only and editable fields
- Truncated public key with copy button for usability
- Confirmation dialog for destructive key regeneration

All tests passing:
- Backend: 13/13 federation service tests passing
- Frontend: 10/10 SpokeConfigurationForm tests passing
- TypeScript compilation: passing
- Linting: passing
- PDA-friendliness: verified

This completes M7-Federation. All federation features are now implemented.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:51:59 -06:00
parent 12abdfe81d
commit 0495f979a7
33 changed files with 1660 additions and 8 deletions

View File

@@ -0,0 +1,170 @@
/**
* Tests for SpokeConfigurationForm Component
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SpokeConfigurationForm } from "./SpokeConfigurationForm";
import type { PublicInstanceIdentity } from "@/lib/api/federation";
describe("SpokeConfigurationForm", () => {
const mockInstance: PublicInstanceIdentity = {
id: "instance-123",
instanceId: "test-instance-001",
name: "Test Instance",
url: "https://test.example.com",
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCKPUBLICKEY\n-----END PUBLIC KEY-----",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
metadata: {
description: "Test instance description",
},
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
};
it("should render instance identity information", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
expect(screen.getByDisplayValue("Test Instance")).toBeInTheDocument();
expect(screen.getByText("test-instance-001")).toBeInTheDocument();
expect(screen.getByText("https://test.example.com")).toBeInTheDocument();
});
it("should render capability toggles with correct initial state", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const queryToggle = screen.getByLabelText(/Query Support/i);
const commandToggle = screen.getByLabelText(/Command Support/i);
const eventToggle = screen.getByLabelText(/Event Support/i);
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
expect(queryToggle).toBeChecked();
expect(commandToggle).toBeChecked();
expect(eventToggle).toBeChecked();
expect(agentToggle).not.toBeChecked();
});
it("should allow editing instance name", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const nameInput = screen.getByDisplayValue("Test Instance");
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
await waitFor(() => {
expect(screen.getByDisplayValue("Updated Instance")).toBeInTheDocument();
});
});
it("should toggle capabilities", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
expect(agentToggle).not.toBeChecked();
fireEvent.click(agentToggle);
await waitFor(() => {
expect(agentToggle).toBeChecked();
});
});
it("should call onSave with updated configuration", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Change name
const nameInput = screen.getByDisplayValue("Test Instance");
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
// Toggle agent spawn
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
fireEvent.click(agentToggle);
// Click save
const saveButton = screen.getByText("Save Configuration");
fireEvent.click(saveButton);
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith({
name: "Updated Instance",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
metadata: {
description: "Test instance description",
},
});
});
});
it("should display loading state when saving", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} isLoading={true} />);
const saveButton = screen.getByText("Saving...");
expect(saveButton).toBeDisabled();
});
it("should display error message when provided", () => {
const onSave = vi.fn();
render(
<SpokeConfigurationForm
instance={mockInstance}
onSave={onSave}
error="Unable to save configuration"
/>
);
expect(screen.getByText("Unable to save configuration")).toBeInTheDocument();
});
it("should use PDA-friendly language in help text", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Should NOT use demanding language
expect(screen.queryByText(/must/i)).not.toBeInTheDocument();
expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
expect(screen.queryByText(/critical/i)).not.toBeInTheDocument();
// Should use friendly language (multiple instances expected)
const friendlyText = screen.getAllByText(/Allows connected instances/i);
expect(friendlyText.length).toBeGreaterThan(0);
});
it("should truncate public key and show copy button", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Public key should be truncated
expect(screen.getByText(/-----BEGIN PUBLIC KEY-----/)).toBeInTheDocument();
expect(screen.getByText(/Copy/i)).toBeInTheDocument();
});
it("should handle cancel action", async () => {
const onSave = vi.fn();
const onCancel = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} onCancel={onCancel} />);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
await waitFor(() => {
expect(onCancel).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,276 @@
/**
* SpokeConfigurationForm Component
* Allows administrators to configure local instance federation settings
*/
"use client";
import { useState } from "react";
import type { PublicInstanceIdentity, UpdateInstanceRequest } from "@/lib/api/federation";
interface SpokeConfigurationFormProps {
instance: PublicInstanceIdentity;
onSave: (updates: UpdateInstanceRequest) => void;
onCancel?: () => void;
isLoading?: boolean;
error?: string;
}
export function SpokeConfigurationForm({
instance,
onSave,
onCancel,
isLoading = false,
error,
}: SpokeConfigurationFormProps): React.JSX.Element {
const [name, setName] = useState(instance.name);
const [description, setDescription] = useState((instance.metadata.description as string) || "");
const [capabilities, setCapabilities] = useState(instance.capabilities);
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const updates: UpdateInstanceRequest = {
name,
capabilities,
metadata: {
...instance.metadata,
description,
},
};
onSave(updates);
};
const handleCapabilityToggle = (capability: keyof typeof capabilities): void => {
if (capability === "protocolVersion") return; // Can't toggle protocol version
setCapabilities((prev) => ({
...prev,
[capability]: !prev[capability],
}));
};
const copyPublicKey = async (): Promise<void> => {
await navigator.clipboard.writeText(instance.publicKey);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Instance Identity Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Instance Identity</h3>
{/* Instance ID (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instance ID</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{instance.instanceId}
</div>
</div>
{/* Instance URL (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instance URL</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{instance.url}
</div>
</div>
{/* Instance Name (Editable) */}
<div>
<label htmlFor="instance-name" className="block text-sm font-medium text-gray-700 mb-1">
Instance Name
</label>
<input
id="instance-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
disabled={isLoading}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
/>
<p className="text-sm text-gray-500 mt-1">
This name helps identify your instance in federation connections
</p>
</div>
{/* Description (Editable) */}
<div>
<label
htmlFor="instance-description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description
</label>
<textarea
id="instance-description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
disabled={isLoading}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
placeholder="Optional description for this instance"
/>
</div>
{/* Public Key (Read-only with copy button) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Public Key</label>
<div className="relative">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600 font-mono text-xs overflow-hidden">
{instance.publicKey.substring(0, 100)}...
</div>
<button
type="button"
onClick={copyPublicKey}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Copy
</button>
</div>
</div>
</div>
{/* Federation Capabilities Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Federation Capabilities</h3>
<p className="text-sm text-gray-600">
Configure which federation features this instance supports. These settings determine what
connected instances can do with your instance.
</p>
{/* Query Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-query"
checked={capabilities.supportsQuery}
onChange={() => {
handleCapabilityToggle("supportsQuery");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-query" className="font-medium text-gray-900 cursor-pointer">
Query Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to query data from this instance (tasks, events, projects)
</p>
</div>
</div>
{/* Command Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-command"
checked={capabilities.supportsCommand}
onChange={() => {
handleCapabilityToggle("supportsCommand");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-command" className="font-medium text-gray-900 cursor-pointer">
Command Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to send commands to this instance
</p>
</div>
</div>
{/* Event Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-event"
checked={capabilities.supportsEvent}
onChange={() => {
handleCapabilityToggle("supportsEvent");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-event" className="font-medium text-gray-900 cursor-pointer">
Event Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to subscribe to events from this instance
</p>
</div>
</div>
{/* Agent Spawn Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-agent-spawn"
checked={capabilities.supportsAgentSpawn}
onChange={() => {
handleCapabilityToggle("supportsAgentSpawn");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label
htmlFor="supports-agent-spawn"
className="font-medium text-gray-900 cursor-pointer"
>
Agent Spawn Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to spawn and manage agents on this instance
</p>
</div>
</div>
{/* Protocol Version (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Protocol Version</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{capabilities.protocolVersion}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center space-x-3 pt-4 border-t border-gray-200">
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Saving..." : "Save Configuration"}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
>
Cancel
</button>
)}
</div>
</form>
);
}