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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
276
apps/web/src/components/federation/SpokeConfigurationForm.tsx
Normal file
276
apps/web/src/components/federation/SpokeConfigurationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user