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 <[email protected]>
229 lines
7.8 KiB
TypeScript
229 lines
7.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|