feat: component manifest drives installs, checkoff, and desktop lockstep
INSTALL-001: manifest schema with components+artifacts, CI generation in release gate, manifest-driven install template with --guided and --checkoff modes, post-install provisioning checks, desktop joins version lockstep (bump-version.sh + CI build + gate enforcement). Setup.tsx reduced to primitives (FormSection, TextField, Alert).
This commit is contained in:
parent
0669b4d6b1
commit
88b612c77e
6 changed files with 805 additions and 663 deletions
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle, Key } from 'lucide-react';
|
||||
import { Database, Settings, Shield, User, Eye, EyeOff, CheckCircle, Key } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { setupApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SetupFormData {
|
||||
adminUser: string;
|
||||
|
|
@ -26,6 +27,63 @@ interface SigningKeys {
|
|||
algorithm: string;
|
||||
}
|
||||
|
||||
// ---- local primitives (reused within Setup) ----
|
||||
|
||||
/** Section header with icon — repeated 4× in the form. */
|
||||
const FormSection: React.FC<{ icon: React.ElementType; title: string; children: React.ReactNode }> = ({
|
||||
icon: Icon, title, children,
|
||||
}) => (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
type AlertKind = 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
const ALERT_STYLES: Record<AlertKind, string> = {
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
};
|
||||
|
||||
const Alert: React.FC<{ kind: AlertKind; children: React.ReactNode; className?: string }> = ({
|
||||
kind, children, className,
|
||||
}) => (
|
||||
<div className={cn('border rounded-md p-3 text-sm', ALERT_STYLES[kind], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Standard text input with label — 15+ instances in the form. */
|
||||
const TextField: React.FC<{
|
||||
id: string; label: string; name: string; value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
type?: string; placeholder?: string; required?: boolean; readOnly?: boolean;
|
||||
rightButton?: React.ReactNode;
|
||||
}> = ({ id, label, name, value, onChange, type = 'text', placeholder, required, readOnly, rightButton }) => (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type} id={id} name={name} value={value} onChange={onChange}
|
||||
placeholder={placeholder} required={required} readOnly={readOnly}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm sm:text-sm',
|
||||
'placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500',
|
||||
readOnly && 'bg-gray-100',
|
||||
rightButton && 'pr-10',
|
||||
)}
|
||||
/>
|
||||
{rightButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
|
|
@ -33,11 +91,18 @@ const Setup: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
|
||||
const [generatingKeys, setGeneratingKeys] = useState(false);
|
||||
|
||||
const togglePassword = useCallback((field: string) => {
|
||||
setVisiblePasswords(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(field) ? next.delete(field) : next.add(field);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState<SetupFormData>({
|
||||
adminUser: 'admin',
|
||||
adminPassword: '',
|
||||
|
|
@ -337,299 +402,97 @@ const Setup: React.FC = () => {
|
|||
{/* Setup Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="alert alert-danger rounded-md">
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert kind="error">{error}</Alert>}
|
||||
|
||||
{/* Administrator Account */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
|
||||
</div>
|
||||
<FormSection icon={User} title="Administrator Account">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="adminUser"
|
||||
name="adminUser"
|
||||
value={formData.adminUser}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="adminPassword"
|
||||
name="adminPassword"
|
||||
value={formData.adminPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter secure password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<TextField id="adminUser" label="Admin Username" name="adminUser"
|
||||
value={formData.adminUser} onChange={handleInputChange} placeholder="admin" required />
|
||||
<TextField id="adminPassword" label="Admin Password" name="adminPassword"
|
||||
value={formData.adminPassword} onChange={handleInputChange}
|
||||
type={visiblePasswords.has('admin') ? 'text' : 'password'}
|
||||
placeholder="Enter secure password" required
|
||||
rightButton={
|
||||
<button type="button" className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => togglePassword('admin')}>
|
||||
{visiblePasswords.has('admin')
|
||||
? <EyeOff className="h-4 w-4 text-gray-400" />
|
||||
: <Eye className="h-4 w-4 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Key className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Security Keys</h3>
|
||||
</div>
|
||||
<div className="alert alert-info rounded-md mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
Generate Ed25519 signing keys for secure agent updates.
|
||||
<strong> Save the private key securely</strong> - it will be included in your configuration.
|
||||
</p>
|
||||
} />
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={Key} title="Security Keys">
|
||||
<Alert kind="info" className="mb-4">
|
||||
Generate Ed25519 signing keys for secure agent updates.
|
||||
<strong> Save the private key securely</strong> — it will be included in your configuration.
|
||||
</Alert>
|
||||
{!signingKeys ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateKeys}
|
||||
disabled={generatingKeys}
|
||||
className="w-full py-2 px-4 border border-indigo-600 text-indigo-600 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<button type="button" onClick={handleGenerateKeys} disabled={generatingKeys}
|
||||
className="w-full py-2 px-4 border border-indigo-600 text-indigo-600 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center">
|
||||
{generatingKeys ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
|
||||
Generating Keys...
|
||||
</>
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2" />Generating Keys...</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Generate Signing Keys
|
||||
</>
|
||||
<><Key className="h-4 w-4 mr-2" />Generate Signing Keys</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Public Key Fingerprint
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={signingKeys.fingerprint}
|
||||
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Algorithm
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={signingKeys.algorithm.toUpperCase()}
|
||||
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="alert alert-success rounded-md p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Keys generated! Private key will be securely included in your configuration file.
|
||||
</p>
|
||||
</div>
|
||||
<TextField id="keyFingerprint" label="Public Key Fingerprint" name="keyFingerprint"
|
||||
value={signingKeys.fingerprint} onChange={() => {}} readOnly />
|
||||
<TextField id="keyAlgorithm" label="Algorithm" name="keyAlgorithm"
|
||||
value={signingKeys.algorithm.toUpperCase()} onChange={() => {}} readOnly />
|
||||
<Alert kind="success">
|
||||
Keys generated! Private key will be securely included in your configuration file.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Database className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
|
||||
</div>
|
||||
<FormSection icon={Database} title="Database Configuration">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbHost"
|
||||
name="dbHost"
|
||||
value={formData.dbHost}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="postgres"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="dbPort"
|
||||
name="dbPort"
|
||||
value={formData.dbPort}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbName"
|
||||
name="dbName"
|
||||
value={formData.dbName}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database User
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={formData.dbUser}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
id="dbPassword"
|
||||
name="dbPassword"
|
||||
value={formData.dbPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter database password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
>
|
||||
{showDbPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<TextField id="dbHost" label="Database Host" name="dbHost"
|
||||
value={formData.dbHost} onChange={handleInputChange} placeholder="postgres" required />
|
||||
<TextField id="dbPort" label="Database Port" name="dbPort"
|
||||
value={formData.dbPort} onChange={handleInputChange} type="number" required />
|
||||
<TextField id="dbName" label="Database Name" name="dbName"
|
||||
value={formData.dbName} onChange={handleInputChange} placeholder="redflag" required />
|
||||
<TextField id="dbUser" label="Database User" name="dbUser"
|
||||
value={formData.dbUser} onChange={handleInputChange} placeholder="redflag" required />
|
||||
<TextField id="dbPassword" label="Database Password" name="dbPassword"
|
||||
value={formData.dbPassword} onChange={handleInputChange}
|
||||
type={visiblePasswords.has('db') ? 'text' : 'password'}
|
||||
placeholder="Enter database password" required
|
||||
rightButton={
|
||||
<button type="button" className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => togglePassword('db')}>
|
||||
{visiblePasswords.has('db')
|
||||
? <EyeOff className="h-4 w-4 text-gray-400" />
|
||||
: <Eye className="h-4 w-4 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
|
||||
</div>
|
||||
<FormSection icon={Settings} title="Server Configuration">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverHost"
|
||||
name="serverHost"
|
||||
value={formData.serverHost}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="serverPort"
|
||||
name="serverPort"
|
||||
value={formData.serverPort}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maximum Agent Seats
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxSeats"
|
||||
name="maxSeats"
|
||||
value={formData.maxSeats}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="50"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
</div>
|
||||
<TextField id="serverHost" label="Server Host" name="serverHost"
|
||||
value={formData.serverHost} onChange={handleInputChange} placeholder="0.0.0.0" required />
|
||||
<TextField id="serverPort" label="Server Port" name="serverPort"
|
||||
value={formData.serverPort} onChange={handleInputChange} type="number" placeholder="8080" required />
|
||||
<TextField id="maxSeats" label="Maximum Agent Seats" name="maxSeats"
|
||||
value={formData.maxSeats} onChange={handleInputChange} type="number" required
|
||||
placeholder="50" />
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="publicURL" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Agent-facing Server URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="publicURL"
|
||||
name="publicURL"
|
||||
value={formData.publicURL}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="http://redflag.example.com:8080"
|
||||
required
|
||||
/>
|
||||
<TextField id="publicURL" label="Agent-facing Server URL" name="publicURL"
|
||||
value={formData.publicURL} onChange={handleInputChange} type="url"
|
||||
placeholder="http://redflag.example.com:8080" required />
|
||||
<p className="mt-1 text-xs text-gray-500">Used in generated install commands and agent callbacks</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
|
|
|
|||
Loading…
Reference in a new issue