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,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<PublicInstanceIdentity | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
// Load instance identity on mount
useEffect(() => {
void loadInstance();
}, []);
const loadInstance = async (): Promise<void> => {
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<void> => {
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<void> => {
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 (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<div className="text-gray-600">Loading configuration...</div>
</div>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-8">
<h2 className="text-lg font-semibold text-red-900 mb-2">
Unable to Load Configuration
</h2>
<p className="text-red-700">{error}</p>
<button
onClick={() => void loadInstance()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
// No instance (shouldn't happen, but handle gracefully)
if (!instance) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<div className="text-gray-600">No instance configuration found</div>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Federation Settings</h1>
<p className="text-gray-600">
Configure your instance's federation capabilities and identity. These settings determine
how your instance interacts with other Mosaic Stack instances.
</p>
</div>
{/* Success Message */}
{successMessage && (
<div className="mb-6 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
{successMessage}
</div>
)}
{/* Main Configuration Form */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<SpokeConfigurationForm
instance={instance}
onSave={handleSave}
isLoading={isSaving}
{...(saveError && { error: saveError })}
/>
</div>
{/* Advanced Section: Regenerate Keys */}
<div className="mt-8 bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">Advanced</h3>
<p className="text-sm text-gray-600 mb-4">
Regenerating your instance's keypair will invalidate all existing federation
connections. Connected instances will need to re-establish connections with your new
public key.
</p>
{showRegenerateConfirm ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-900 mb-2">Confirm Keypair Regeneration</h4>
<p className="text-sm text-yellow-800 mb-4">
This action will disconnect all federated instances. They will need to reconnect
using your new public key. This action cannot be undone.
</p>
<div className="flex items-center space-x-3">
<button
onClick={() => void handleRegenerateKeys()}
disabled={isRegenerating}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isRegenerating ? "Regenerating..." : "Confirm Regenerate"}
</button>
<button
onClick={() => {
setShowRegenerateConfirm(false);
}}
disabled={isRegenerating}
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>
</div>
) : (
<button
onClick={() => {
setShowRegenerateConfirm(true);
}}
className="px-4 py-2 border border-yellow-600 text-yellow-700 rounded-lg hover:bg-yellow-50 transition-colors"
>
Regenerate Keypair
</button>
)}
</div>
</div>
</div>
);
}