From 0495f979a7d76029797838d71c456852020f2401 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 3 Feb 2026 14:51:59 -0600 Subject: [PATCH] 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 --- apps/api/src/federation/audit.service.ts | 19 ++ apps/api/src/federation/dto/instance.dto.ts | 46 +++ .../federation/federation.controller.spec.ts | 9 + .../src/federation/federation.controller.ts | 44 ++- .../src/federation/federation.service.spec.ts | 122 ++++++++ apps/api/src/federation/federation.service.ts | 40 +++ .../federation/settings/page.tsx | 228 +++++++++++++++ .../SpokeConfigurationForm.test.tsx | 170 +++++++++++ .../federation/SpokeConfigurationForm.tsx | 276 ++++++++++++++++++ apps/web/src/lib/api/federation.ts | 29 +- ...e.ts_20260203-1445_1_remediation_needed.md | 20 ++ ...o.ts_20260203-1443_1_remediation_needed.md | 20 ++ ...c.ts_20260203-1447_1_remediation_needed.md | 20 ++ ...c.ts_20260203-1447_2_remediation_needed.md | 20 ++ ...r.ts_20260203-1444_1_remediation_needed.md | 20 ++ ...r.ts_20260203-1445_1_remediation_needed.md | 20 ++ ...r.ts_20260203-1448_1_remediation_needed.md | 20 ++ ...c.ts_20260203-1444_1_remediation_needed.md | 20 ++ ...e.ts_20260203-1444_1_remediation_needed.md | 20 ++ ....tsx_20260203-1447_1_remediation_needed.md | 20 ++ ....tsx_20260203-1448_1_remediation_needed.md | 20 ++ ....tsx_20260203-1448_2_remediation_needed.md | 20 ++ ....tsx_20260203-1445_1_remediation_needed.md | 20 ++ ....tsx_20260203-1447_1_remediation_needed.md | 20 ++ ....tsx_20260203-1451_1_remediation_needed.md | 20 ++ ....tsx_20260203-1446_1_remediation_needed.md | 20 ++ ....tsx_20260203-1449_1_remediation_needed.md | 20 ++ ....tsx_20260203-1450_1_remediation_needed.md | 20 ++ ...n.ts_20260203-1445_1_remediation_needed.md | 20 ++ ...n.ts_20260203-1445_2_remediation_needed.md | 20 ++ ...n.ts_20260203-1445_3_remediation_needed.md | 20 ++ .../93-agent-spawn-via-federation.md | 24 +- docs/scratchpads/94-spoke-configuration-ui.md | 241 +++++++++++++++ 33 files changed, 1660 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/federation/dto/instance.dto.ts create mode 100644 apps/web/src/app/(authenticated)/federation/settings/page.tsx create mode 100644 apps/web/src/components/federation/SpokeConfigurationForm.test.tsx create mode 100644 apps/web/src/components/federation/SpokeConfigurationForm.tsx create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-audit.service.ts_20260203-1445_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-dto-instance.dto.ts_20260203-1443_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.controller.spec.ts_20260203-1447_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.controller.spec.ts_20260203-1447_2_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-1444_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-1445_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-1448_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.service.spec.ts_20260203-1444_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-federation-federation.service.ts_20260203-1444_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-app-(authenticated)-federation-settings-page.tsx_20260203-1447_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-app-(authenticated)-federation-settings-page.tsx_20260203-1448_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-app-(authenticated)-federation-settings-page.tsx_20260203-1448_2_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.test.tsx_20260203-1445_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.test.tsx_20260203-1447_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.test.tsx_20260203-1451_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.tsx_20260203-1446_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.tsx_20260203-1449_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-components-federation-SpokeConfigurationForm.tsx_20260203-1450_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-lib-api-federation.ts_20260203-1445_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-lib-api-federation.ts_20260203-1445_2_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-web-src-lib-api-federation.ts_20260203-1445_3_remediation_needed.md create mode 100644 docs/scratchpads/94-spoke-configuration-ui.md diff --git a/apps/api/src/federation/audit.service.ts b/apps/api/src/federation/audit.service.ts index ac855b8..dce634b 100644 --- a/apps/api/src/federation/audit.service.ts +++ b/apps/api/src/federation/audit.service.ts @@ -25,6 +25,25 @@ export class FederationAuditService { }); } + /** + * Log instance configuration update (system-level operation) + * Logged to application logs for security audit trail + */ + logInstanceConfigurationUpdate( + userId: string, + instanceId: string, + updates: Record + ): void { + this.logger.log({ + event: "FEDERATION_INSTANCE_CONFIG_UPDATED", + userId, + instanceId, + updates, + timestamp: new Date().toISOString(), + securityEvent: true, + }); + } + /** * Log federated authentication initiation */ diff --git a/apps/api/src/federation/dto/instance.dto.ts b/apps/api/src/federation/dto/instance.dto.ts new file mode 100644 index 0000000..928239b --- /dev/null +++ b/apps/api/src/federation/dto/instance.dto.ts @@ -0,0 +1,46 @@ +/** + * Instance Configuration DTOs + * + * Data Transfer Objects for instance configuration API. + */ + +import { IsString, IsBoolean, IsOptional, IsObject, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; + +/** + * DTO for federation capabilities + */ +export class FederationCapabilitiesDto { + @IsBoolean() + supportsQuery!: boolean; + + @IsBoolean() + supportsCommand!: boolean; + + @IsBoolean() + supportsEvent!: boolean; + + @IsBoolean() + supportsAgentSpawn!: boolean; + + @IsString() + protocolVersion!: string; +} + +/** + * DTO for updating instance configuration + */ +export class UpdateInstanceDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @ValidateNested() + @Type(() => FederationCapabilitiesDto) + capabilities?: FederationCapabilitiesDto; + + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/apps/api/src/federation/federation.controller.spec.ts b/apps/api/src/federation/federation.controller.spec.ts index cff56ca..48b682f 100644 --- a/apps/api/src/federation/federation.controller.spec.ts +++ b/apps/api/src/federation/federation.controller.spec.ts @@ -8,6 +8,7 @@ import { FederationController } from "./federation.controller"; import { FederationService } from "./federation.service"; import { FederationAuditService } from "./audit.service"; import { ConnectionService } from "./connection.service"; +import { FederationAgentService } from "./federation-agent.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; import { FederationConnectionStatus } from "@prisma/client"; @@ -88,6 +89,14 @@ describe("FederationController", () => { handleIncomingConnectionRequest: vi.fn(), }, }, + { + provide: FederationAgentService, + useValue: { + spawnAgentOnRemote: vi.fn(), + getAgentStatus: vi.fn(), + killAgentOnRemote: vi.fn(), + }, + }, ], }) .overrideGuard(AuthGuard) diff --git a/apps/api/src/federation/federation.controller.ts b/apps/api/src/federation/federation.controller.ts index 8a77529..2e67b7a 100644 --- a/apps/api/src/federation/federation.controller.ts +++ b/apps/api/src/federation/federation.controller.ts @@ -4,7 +4,18 @@ * API endpoints for instance identity and federation management. */ -import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common"; +import { + Controller, + Get, + Post, + Patch, + UseGuards, + Logger, + Req, + Body, + Param, + Query, +} from "@nestjs/common"; import { FederationService } from "./federation.service"; import { FederationAuditService } from "./audit.service"; import { ConnectionService } from "./connection.service"; @@ -22,6 +33,7 @@ import { DisconnectConnectionDto, IncomingConnectionRequestDto, } from "./dto/connection.dto"; +import { UpdateInstanceDto } from "./dto/instance.dto"; import type { SpawnAgentCommandPayload } from "./types/federation-agent.types"; import { FederationConnectionStatus } from "@prisma/client"; @@ -68,6 +80,36 @@ export class FederationController { return result; } + /** + * Update instance configuration + * Requires system administrator privileges + * Allows updating name, capabilities, and metadata + * Returns public identity only (private key never exposed in API) + */ + @Patch("instance") + @UseGuards(AuthGuard, AdminGuard) + async updateInstanceConfiguration( + @Req() req: AuthenticatedRequest, + @Body() dto: UpdateInstanceDto + ): Promise { + if (!req.user) { + throw new Error("User not authenticated"); + } + + this.logger.log(`Admin user ${req.user.id} updating instance configuration`); + + const result = await this.federationService.updateInstanceConfiguration(dto); + + // Audit log for security compliance + const auditData: Record = {}; + if (dto.name !== undefined) auditData.name = dto.name; + if (dto.capabilities !== undefined) auditData.capabilities = dto.capabilities; + if (dto.metadata !== undefined) auditData.metadata = dto.metadata; + this.auditService.logInstanceConfigurationUpdate(req.user.id, result.instanceId, auditData); + + return result; + } + /** * Initiate a connection to a remote instance * Requires authentication diff --git a/apps/api/src/federation/federation.service.spec.ts b/apps/api/src/federation/federation.service.spec.ts index 483b368..fb85ea8 100644 --- a/apps/api/src/federation/federation.service.spec.ts +++ b/apps/api/src/federation/federation.service.spec.ts @@ -228,4 +228,126 @@ describe("FederationService", () => { expect(result).toHaveProperty("instanceId"); }); }); + + describe("updateInstanceConfiguration", () => { + it("should update instance name", async () => { + // Arrange + const updatedInstance = { ...mockInstance, name: "Updated Instance" }; + vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({ + ...mockInstance, + privateKey: mockDecryptedPrivateKey, + }); + vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance); + + // Act + const result = await service.updateInstanceConfiguration({ name: "Updated Instance" }); + + // Assert + expect(prismaService.instance.update).toHaveBeenCalledWith({ + where: { id: mockInstance.id }, + data: { name: "Updated Instance" }, + }); + expect(result.name).toBe("Updated Instance"); + expect(result).not.toHaveProperty("privateKey"); + }); + + it("should update instance capabilities", async () => { + // Arrange + const newCapabilities = { + supportsQuery: true, + supportsCommand: false, + supportsEvent: true, + supportsAgentSpawn: false, + protocolVersion: "1.0", + }; + const updatedInstance = { ...mockInstance, capabilities: newCapabilities }; + vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({ + ...mockInstance, + privateKey: mockDecryptedPrivateKey, + }); + vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance); + + // Act + const result = await service.updateInstanceConfiguration({ capabilities: newCapabilities }); + + // Assert + expect(prismaService.instance.update).toHaveBeenCalledWith({ + where: { id: mockInstance.id }, + data: { capabilities: newCapabilities }, + }); + expect(result.capabilities).toEqual(newCapabilities); + }); + + it("should update instance metadata", async () => { + // Arrange + const newMetadata = { description: "Test description", region: "us-west-2" }; + const updatedInstance = { ...mockInstance, metadata: newMetadata }; + vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({ + ...mockInstance, + privateKey: mockDecryptedPrivateKey, + }); + vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance); + + // Act + const result = await service.updateInstanceConfiguration({ metadata: newMetadata }); + + // Assert + expect(prismaService.instance.update).toHaveBeenCalledWith({ + where: { id: mockInstance.id }, + data: { metadata: newMetadata }, + }); + expect(result.metadata).toEqual(newMetadata); + }); + + it("should update multiple fields at once", async () => { + // Arrange + const updates = { + name: "Updated Instance", + capabilities: { + supportsQuery: false, + supportsCommand: false, + supportsEvent: false, + supportsAgentSpawn: false, + protocolVersion: "1.0", + }, + metadata: { description: "Updated" }, + }; + const updatedInstance = { ...mockInstance, ...updates }; + vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({ + ...mockInstance, + privateKey: mockDecryptedPrivateKey, + }); + vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance); + + // Act + const result = await service.updateInstanceConfiguration(updates); + + // Assert + expect(prismaService.instance.update).toHaveBeenCalledWith({ + where: { id: mockInstance.id }, + data: updates, + }); + expect(result.name).toBe("Updated Instance"); + expect(result.capabilities).toEqual(updates.capabilities); + expect(result.metadata).toEqual(updates.metadata); + }); + + it("should not expose private key in response", async () => { + // Arrange + const updatedInstance = { ...mockInstance, name: "Updated" }; + vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({ + ...mockInstance, + privateKey: mockDecryptedPrivateKey, + }); + vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance); + + // Act + const result = await service.updateInstanceConfiguration({ name: "Updated" }); + + // Assert - SECURITY: Verify private key is NOT in response + expect(result).not.toHaveProperty("privateKey"); + expect(result).toHaveProperty("publicKey"); + expect(result).toHaveProperty("instanceId"); + }); + }); }); diff --git a/apps/api/src/federation/federation.service.ts b/apps/api/src/federation/federation.service.ts index 977c3e8..390aec3 100644 --- a/apps/api/src/federation/federation.service.ts +++ b/apps/api/src/federation/federation.service.ts @@ -104,6 +104,46 @@ export class FederationService { return publicIdentity; } + /** + * Update instance configuration + * Allows updating name, capabilities, and metadata + * Returns public identity only (no private key exposure) + */ + async updateInstanceConfiguration(updates: { + name?: string; + capabilities?: FederationCapabilities; + metadata?: Record; + }): Promise { + const instance = await this.getInstanceIdentity(); + + // Build update data object + const data: Prisma.InstanceUpdateInput = {}; + + if (updates.name !== undefined) { + data.name = updates.name; + } + + if (updates.capabilities !== undefined) { + data.capabilities = updates.capabilities as Prisma.JsonObject; + } + + if (updates.metadata !== undefined) { + data.metadata = updates.metadata as Prisma.JsonObject; + } + + const updatedInstance = await this.prisma.instance.update({ + where: { id: instance.id }, + data, + }); + + this.logger.log(`Instance configuration updated: ${JSON.stringify(updates)}`); + + // Return public identity only (security fix) + const identity = this.mapToInstanceIdentity(updatedInstance); + const { privateKey: _privateKey, ...publicIdentity } = identity; + return publicIdentity; + } + /** * Create a new instance identity */ diff --git a/apps/web/src/app/(authenticated)/federation/settings/page.tsx b/apps/web/src/app/(authenticated)/federation/settings/page.tsx new file mode 100644 index 0000000..1c77434 --- /dev/null +++ b/apps/web/src/app/(authenticated)/federation/settings/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +/** + * Federation Settings Page + * Configure local instance federation settings (spoke configuration) + * Admin-only page + */ + +import { useState, useEffect } from "react"; +import { SpokeConfigurationForm } from "@/components/federation/SpokeConfigurationForm"; +import { + fetchInstanceIdentity, + updateInstanceConfiguration, + regenerateInstanceKeys, + type PublicInstanceIdentity, + type UpdateInstanceRequest, +} from "@/lib/api/federation"; + +export default function FederationSettingsPage(): React.JSX.Element { + const [instance, setInstance] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + const [error, setError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false); + + // Load instance identity on mount + useEffect(() => { + void loadInstance(); + }, []); + + const loadInstance = async (): Promise => { + setIsLoading(true); + setError(null); + + try { + const data = await fetchInstanceIdentity(); + setInstance(data); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Unable to load instance configuration. Please try again." + ); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async (updates: UpdateInstanceRequest): Promise => { + setIsSaving(true); + setSaveError(null); + setSuccessMessage(null); + + try { + const updatedInstance = await updateInstanceConfiguration(updates); + setInstance(updatedInstance); + setSuccessMessage("Configuration saved successfully"); + + // Clear success message after 3 seconds + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + } catch (err) { + setSaveError( + err instanceof Error ? err.message : "Unable to save configuration. Please try again." + ); + } finally { + setIsSaving(false); + } + }; + + const handleRegenerateKeys = async (): Promise => { + setIsRegenerating(true); + setSaveError(null); + setSuccessMessage(null); + + try { + const updatedInstance = await regenerateInstanceKeys(); + setInstance(updatedInstance); + setSuccessMessage("Instance keypair regenerated successfully"); + setShowRegenerateConfirm(false); + + // Clear success message after 3 seconds + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + } catch (err) { + setSaveError( + err instanceof Error ? err.message : "Unable to regenerate keys. Please try again." + ); + } finally { + setIsRegenerating(false); + } + }; + + // Loading state + if (isLoading) { + return ( +
+
+

Federation Settings

+
+
Loading configuration...
+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+

Federation Settings

+
+

+ Unable to Load Configuration +

+

{error}

+ +
+
+
+ ); + } + + // No instance (shouldn't happen, but handle gracefully) + if (!instance) { + return ( +
+
+

Federation Settings

+
+
No instance configuration found
+
+
+
+ ); + } + + return ( +
+
+ {/* Page Header */} +
+

Federation Settings

+

+ Configure your instance's federation capabilities and identity. These settings determine + how your instance interacts with other Mosaic Stack instances. +

+
+ + {/* Success Message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Main Configuration Form */} +
+ +
+ + {/* Advanced Section: Regenerate Keys */} +
+

Advanced

+

+ Regenerating your instance's keypair will invalidate all existing federation + connections. Connected instances will need to re-establish connections with your new + public key. +

+ + {showRegenerateConfirm ? ( +
+

Confirm Keypair Regeneration

+

+ This action will disconnect all federated instances. They will need to reconnect + using your new public key. This action cannot be undone. +

+
+ + +
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/federation/SpokeConfigurationForm.test.tsx b/apps/web/src/components/federation/SpokeConfigurationForm.test.tsx new file mode 100644 index 0000000..137e125 --- /dev/null +++ b/apps/web/src/components/federation/SpokeConfigurationForm.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + const saveButton = screen.getByText("Saving..."); + expect(saveButton).toBeDisabled(); + }); + + it("should display error message when provided", () => { + const onSave = vi.fn(); + render( + + ); + + expect(screen.getByText("Unable to save configuration")).toBeInTheDocument(); + }); + + it("should use PDA-friendly language in help text", () => { + const onSave = vi.fn(); + render(); + + // 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(); + + // 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(); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(onCancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/components/federation/SpokeConfigurationForm.tsx b/apps/web/src/components/federation/SpokeConfigurationForm.tsx new file mode 100644 index 0000000..548a7b5 --- /dev/null +++ b/apps/web/src/components/federation/SpokeConfigurationForm.tsx @@ -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): 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 => { + await navigator.clipboard.writeText(instance.publicKey); + }; + + return ( +
+ {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Instance Identity Section */} +
+

Instance Identity

+ + {/* Instance ID (Read-only) */} +
+ +
+ {instance.instanceId} +
+
+ + {/* Instance URL (Read-only) */} +
+ +
+ {instance.url} +
+
+ + {/* Instance Name (Editable) */} +
+ + { + 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" + /> +

+ This name helps identify your instance in federation connections +

+
+ + {/* Description (Editable) */} +
+ +