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:
228
apps/web/src/app/(authenticated)/federation/settings/page.tsx
Normal file
228
apps/web/src/app/(authenticated)/federation/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user