Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability. Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML. Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
820 lines
24 KiB
TypeScript
820 lines
24 KiB
TypeScript
import {
|
|
Check,
|
|
ChevronDown,
|
|
Globe,
|
|
LayoutGrid,
|
|
List as ListIcon,
|
|
Search,
|
|
Server,
|
|
Shuffle,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import React, { useCallback, useState } from "react";
|
|
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
|
import {
|
|
Host,
|
|
PortForwardingRule,
|
|
PortForwardingType,
|
|
SSHKey,
|
|
} from "../domain/models";
|
|
import { cn } from "../lib/utils";
|
|
import SelectHostPanel from "./SelectHostPanel";
|
|
import {
|
|
AsidePanel,
|
|
AsidePanelContent,
|
|
AsidePanelFooter,
|
|
} from "./ui/aside-panel";
|
|
import { Button } from "./ui/button";
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
|
import { Input } from "./ui/input";
|
|
import { SortDropdown } from "./ui/sort-dropdown";
|
|
import { toast } from "./ui/toast";
|
|
|
|
// Import components and utilities from port-forwarding module
|
|
import {
|
|
EditPanel,
|
|
NewFormPanel,
|
|
RuleCard,
|
|
WizardContent,
|
|
} from "./port-forwarding";
|
|
|
|
type WizardStep =
|
|
| "type"
|
|
| "local-config"
|
|
| "remote-host-selection"
|
|
| "remote-config"
|
|
| "destination"
|
|
| "host-selection"
|
|
| "label";
|
|
|
|
interface PortForwardingProps {
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
customGroups: string[];
|
|
onNewHost?: () => void;
|
|
onSaveHost?: (host: Host) => void;
|
|
onCreateGroup?: (groupPath: string) => void;
|
|
}
|
|
|
|
const PortForwarding: React.FC<PortForwardingProps> = ({
|
|
hosts,
|
|
keys,
|
|
customGroups: _customGroups,
|
|
onNewHost: _onNewHost,
|
|
onSaveHost,
|
|
onCreateGroup: _onCreateGroup,
|
|
}) => {
|
|
const {
|
|
rules: _rules,
|
|
selectedRuleId,
|
|
viewMode,
|
|
sortMode,
|
|
search,
|
|
setSelectedRuleId,
|
|
setViewMode,
|
|
setSortMode,
|
|
setSearch,
|
|
addRule,
|
|
updateRule,
|
|
deleteRule,
|
|
duplicateRule,
|
|
setRuleStatus,
|
|
startTunnel,
|
|
stopTunnel,
|
|
filteredRules,
|
|
selectedRule: _selectedRule,
|
|
preferFormMode,
|
|
setPreferFormMode,
|
|
} = usePortForwardingState();
|
|
|
|
// Track connecting/stopping states
|
|
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
|
|
// Start a port forwarding tunnel
|
|
const handleStartTunnel = useCallback(
|
|
async (rule: PortForwardingRule) => {
|
|
const _host = hosts.find((h) => h.id === rule.hostId);
|
|
if (!_host) {
|
|
setRuleStatus(rule.id, "error", "Host not found");
|
|
toast.error("Host not found", `Port Forwarding: ${rule.label}`);
|
|
return;
|
|
}
|
|
|
|
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
|
let errorShown = false;
|
|
|
|
try {
|
|
const result = await startTunnel(
|
|
rule,
|
|
_host,
|
|
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
|
(status, error) => {
|
|
// Show toast on error (only once)
|
|
if (status === "error" && error && !errorShown) {
|
|
errorShown = true;
|
|
toast.error(error, `Port Forwarding: ${rule.label}`);
|
|
}
|
|
},
|
|
);
|
|
// Show error from result only if not already shown
|
|
if (!result.success && result.error && !errorShown) {
|
|
errorShown = true;
|
|
toast.error(result.error, `Port Forwarding: ${rule.label}`);
|
|
}
|
|
} finally {
|
|
setPendingOperations((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(rule.id);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[hosts, keys, setRuleStatus, startTunnel],
|
|
);
|
|
|
|
// Stop a port forwarding tunnel
|
|
const handleStopTunnel = useCallback(
|
|
async (rule: PortForwardingRule) => {
|
|
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
|
|
|
try {
|
|
await stopTunnel(rule.id);
|
|
} finally {
|
|
setPendingOperations((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(rule.id);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[stopTunnel],
|
|
);
|
|
|
|
// Wizard state
|
|
const [showWizard, setShowWizard] = useState(false);
|
|
const [wizardStep, setWizardStep] = useState<WizardStep>("type");
|
|
const [wizardType, setWizardType] = useState<PortForwardingType>("local");
|
|
const [draftRule, setDraftRule] = useState<Partial<PortForwardingRule>>({
|
|
label: "",
|
|
type: "local",
|
|
localPort: undefined,
|
|
bindAddress: "127.0.0.1",
|
|
remoteHost: "",
|
|
remotePort: undefined,
|
|
hostId: undefined,
|
|
});
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [showHostSelector, setShowHostSelector] = useState(false);
|
|
|
|
// Edit panel state (separate from wizard)
|
|
const [showEditPanel, setShowEditPanel] = useState(false);
|
|
const [editingRule, setEditingRule] = useState<PortForwardingRule | null>(
|
|
null,
|
|
);
|
|
const [editDraft, setEditDraft] = useState<Partial<PortForwardingRule>>({});
|
|
|
|
// New forwarding form mode (skip wizard, all-in-one form)
|
|
const [showNewForm, setShowNewForm] = useState(false);
|
|
const [newFormDraft, setNewFormDraft] = useState<Partial<PortForwardingRule>>(
|
|
{
|
|
label: "",
|
|
type: "local",
|
|
localPort: undefined,
|
|
bindAddress: "127.0.0.1",
|
|
remoteHost: "",
|
|
remotePort: undefined,
|
|
hostId: undefined,
|
|
},
|
|
);
|
|
|
|
// New forwarding menu
|
|
const [showNewMenu, setShowNewMenu] = useState(false);
|
|
|
|
// Reset wizard
|
|
const resetWizard = () => {
|
|
setWizardStep("type");
|
|
setWizardType("local");
|
|
setDraftRule({
|
|
label: "",
|
|
type: "local",
|
|
localPort: undefined,
|
|
bindAddress: "127.0.0.1",
|
|
remoteHost: "",
|
|
remotePort: undefined,
|
|
hostId: undefined,
|
|
});
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// Reset new form
|
|
const resetNewForm = () => {
|
|
setNewFormDraft({
|
|
label: "",
|
|
type: "local",
|
|
localPort: undefined,
|
|
bindAddress: "127.0.0.1",
|
|
remoteHost: "",
|
|
remotePort: undefined,
|
|
hostId: undefined,
|
|
});
|
|
};
|
|
|
|
// Start new rule - wizard or form based on user preference
|
|
const startNewRule = (type: PortForwardingType) => {
|
|
setShowNewMenu(false);
|
|
|
|
if (preferFormMode) {
|
|
// Form mode: show all-in-one form
|
|
resetNewForm();
|
|
setNewFormDraft((prev) => ({ ...prev, type }));
|
|
setShowNewForm(true);
|
|
setShowWizard(false);
|
|
} else {
|
|
// Wizard mode
|
|
resetWizard();
|
|
setWizardType(type);
|
|
setDraftRule((prev) => ({ ...prev, type }));
|
|
setShowWizard(true);
|
|
setShowNewForm(false);
|
|
setWizardStep("type");
|
|
}
|
|
};
|
|
|
|
// Skip wizard and switch to form mode
|
|
const skipWizardToForm = () => {
|
|
// Save preference
|
|
setPreferFormMode(true);
|
|
|
|
// Transfer current draft to form
|
|
setNewFormDraft({
|
|
...draftRule,
|
|
type: wizardType,
|
|
});
|
|
setShowWizard(false);
|
|
setShowNewForm(true);
|
|
};
|
|
|
|
// Open wizard from form
|
|
const openWizardFromForm = () => {
|
|
// User opens wizard - prefer wizard mode next time
|
|
setPreferFormMode(false);
|
|
|
|
// Transfer current form draft to wizard
|
|
setWizardType(newFormDraft.type || "local");
|
|
setDraftRule({ ...newFormDraft });
|
|
setShowNewForm(false);
|
|
setShowWizard(true);
|
|
setWizardStep("type");
|
|
};
|
|
|
|
// Save new rule from form
|
|
const saveNewFormRule = () => {
|
|
const label =
|
|
newFormDraft.label?.trim() ||
|
|
(() => {
|
|
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
|
|
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
|
|
switch (newFormDraft.type) {
|
|
case "local":
|
|
return `Local:${newFormDraft.localPort} → ${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
|
|
case "remote":
|
|
return `Remote:${newFormDraft.localPort} → ${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
|
|
case "dynamic":
|
|
return `SOCKS:${newFormDraft.localPort}`;
|
|
default:
|
|
return "New Rule";
|
|
}
|
|
})();
|
|
|
|
addRule({
|
|
label,
|
|
type: newFormDraft.type || "local",
|
|
localPort: newFormDraft.localPort!,
|
|
bindAddress: newFormDraft.bindAddress || "127.0.0.1",
|
|
remoteHost: newFormDraft.remoteHost,
|
|
remotePort: newFormDraft.remotePort,
|
|
hostId: newFormDraft.hostId,
|
|
});
|
|
|
|
setShowNewForm(false);
|
|
resetNewForm();
|
|
};
|
|
|
|
// Close new form
|
|
const closeNewForm = () => {
|
|
setShowNewForm(false);
|
|
resetNewForm();
|
|
};
|
|
|
|
// Check if new form is valid
|
|
const isNewFormValid = (): boolean => {
|
|
if (
|
|
!newFormDraft.localPort ||
|
|
newFormDraft.localPort <= 0 ||
|
|
newFormDraft.localPort >= 65536
|
|
)
|
|
return false;
|
|
if (!newFormDraft.hostId) return false;
|
|
if (newFormDraft.type !== "dynamic") {
|
|
if (!newFormDraft.remoteHost || !newFormDraft.remotePort) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Edit existing rule - open edit panel
|
|
const startEditRule = (rule: PortForwardingRule) => {
|
|
setEditingRule(rule);
|
|
setEditDraft({ ...rule });
|
|
setShowEditPanel(true);
|
|
setShowWizard(false);
|
|
setShowNewForm(false);
|
|
};
|
|
|
|
// Save edited rule
|
|
const saveEditedRule = () => {
|
|
if (editingRule && editDraft.id) {
|
|
updateRule(editDraft.id, editDraft);
|
|
setShowEditPanel(false);
|
|
setEditingRule(null);
|
|
setEditDraft({});
|
|
}
|
|
};
|
|
|
|
// Close edit panel
|
|
const closeEditPanel = () => {
|
|
setShowEditPanel(false);
|
|
setEditingRule(null);
|
|
setEditDraft({});
|
|
setSelectedRuleId(null);
|
|
};
|
|
|
|
// Handle wizard navigation
|
|
// Flow for local: type -> local-config -> destination -> host-selection
|
|
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
|
|
// Flow for dynamic: type -> local-config -> host-selection
|
|
const getNextStep = (): WizardStep | null => {
|
|
switch (wizardStep) {
|
|
case "type":
|
|
if (wizardType === "dynamic") return "local-config";
|
|
if (wizardType === "local") return "local-config";
|
|
if (wizardType === "remote") return "remote-host-selection";
|
|
return null;
|
|
case "local-config":
|
|
if (wizardType === "dynamic") return "host-selection";
|
|
if (wizardType === "local") return "destination";
|
|
return null;
|
|
case "remote-host-selection":
|
|
return "remote-config";
|
|
case "remote-config":
|
|
return "destination";
|
|
case "destination":
|
|
if (wizardType === "remote") return "label";
|
|
return "host-selection"; // Host selection is last for local
|
|
case "host-selection":
|
|
return null;
|
|
case "label":
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getPrevStep = (): WizardStep | null => {
|
|
switch (wizardStep) {
|
|
case "type":
|
|
return null;
|
|
case "local-config":
|
|
return "type";
|
|
case "remote-host-selection":
|
|
return "type";
|
|
case "remote-config":
|
|
return "remote-host-selection";
|
|
case "destination":
|
|
if (wizardType === "local") return "local-config";
|
|
if (wizardType === "remote") return "remote-config";
|
|
return null;
|
|
case "host-selection":
|
|
if (wizardType === "dynamic") return "local-config";
|
|
return "destination";
|
|
case "label":
|
|
return "destination";
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const canProceed = (): boolean => {
|
|
switch (wizardStep) {
|
|
case "type":
|
|
// Type step just shows description, always can proceed
|
|
return true;
|
|
case "local-config":
|
|
return !!(
|
|
draftRule.localPort &&
|
|
draftRule.localPort > 0 &&
|
|
draftRule.localPort < 65536
|
|
);
|
|
case "remote-host-selection":
|
|
return !!draftRule.hostId;
|
|
case "remote-config":
|
|
return !!(
|
|
draftRule.localPort &&
|
|
draftRule.localPort > 0 &&
|
|
draftRule.localPort < 65536
|
|
);
|
|
case "destination":
|
|
return !!(
|
|
draftRule.remoteHost &&
|
|
draftRule.remotePort &&
|
|
draftRule.remotePort > 0
|
|
);
|
|
case "host-selection":
|
|
return !!draftRule.hostId;
|
|
case "label":
|
|
return true; // Label is optional
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isLastStep = (): boolean => {
|
|
return getNextStep() === null;
|
|
};
|
|
|
|
// Save rule
|
|
const saveRule = () => {
|
|
// Generate label if not provided
|
|
const label = draftRule.label?.trim() || generateRuleLabel();
|
|
|
|
if (isEditing && draftRule.id) {
|
|
updateRule(draftRule.id, { ...draftRule, label });
|
|
} else {
|
|
addRule({
|
|
label,
|
|
type: wizardType,
|
|
localPort: draftRule.localPort!,
|
|
bindAddress: draftRule.bindAddress || "127.0.0.1",
|
|
remoteHost: draftRule.remoteHost,
|
|
remotePort: draftRule.remotePort,
|
|
hostId: draftRule.hostId,
|
|
});
|
|
}
|
|
|
|
setShowWizard(false);
|
|
resetWizard();
|
|
};
|
|
|
|
const generateRuleLabel = (): string => {
|
|
const _host = hosts.find((h) => h.id === draftRule.hostId);
|
|
const _hostLabel = _host?.label || "Unknown";
|
|
|
|
switch (wizardType) {
|
|
case "local":
|
|
return `Local:${draftRule.localPort} → ${draftRule.remoteHost}:${draftRule.remotePort}`;
|
|
case "remote":
|
|
return `Remote:${draftRule.localPort} → ${draftRule.remoteHost}:${draftRule.remotePort}`;
|
|
case "dynamic":
|
|
return `SOCKS:${draftRule.localPort}`;
|
|
default:
|
|
return "New Rule";
|
|
}
|
|
};
|
|
|
|
// Handle skip wizard (just save with defaults)
|
|
const _skipWizard = () => {
|
|
setShowWizard(false);
|
|
resetWizard();
|
|
};
|
|
|
|
// Render wizard panel content
|
|
const hasRules = filteredRules.length > 0;
|
|
|
|
return (
|
|
<div className="flex h-full relative">
|
|
{/* Main Content */}
|
|
<div
|
|
className={cn(
|
|
"flex-1 flex flex-col min-h-0",
|
|
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
|
)}
|
|
>
|
|
{/* Toolbar */}
|
|
<div className="h-14 px-4 flex items-center gap-3 bg-secondary/60 border-b border-border/60 relative z-20">
|
|
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
|
<DropdownTrigger asChild>
|
|
<Button variant="secondary" className="h-9 px-3 gap-2">
|
|
<Zap size={14} />
|
|
NEW FORWARDING
|
|
<ChevronDown
|
|
size={14}
|
|
className={cn(
|
|
"transition-transform",
|
|
showNewMenu ? "rotate-180" : "",
|
|
)}
|
|
/>
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownContent className="w-52" align="start" sideOffset={8}>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start gap-3 h-10"
|
|
onClick={() => startNewRule("local")}
|
|
>
|
|
<Globe size={16} className="text-blue-500" />
|
|
Local Forwarding
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start gap-3 h-10"
|
|
onClick={() => startNewRule("remote")}
|
|
>
|
|
<Server size={16} className="text-orange-500" />
|
|
Remote Forwarding
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start gap-3 h-10"
|
|
onClick={() => startNewRule("dynamic")}
|
|
>
|
|
<Shuffle size={16} className="text-purple-500" />
|
|
Dynamic Forwarding
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search
|
|
size={14}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
placeholder="Search..."
|
|
className="h-9 pl-8 w-44"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* View mode toggle */}
|
|
<Dropdown>
|
|
<DropdownTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
|
{viewMode === "grid" ? (
|
|
<LayoutGrid size={16} />
|
|
) : (
|
|
<ListIcon size={16} />
|
|
)}
|
|
<ChevronDown size={10} className="ml-0.5" />
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownContent className="w-32" align="end">
|
|
<Button
|
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("grid")}
|
|
>
|
|
<LayoutGrid size={14} /> Grid
|
|
{viewMode === "grid" && (
|
|
<Check size={12} className="ml-auto" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === "list" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("list")}
|
|
>
|
|
<ListIcon size={14} /> List
|
|
{viewMode === "list" && (
|
|
<Check size={12} className="ml-auto" />
|
|
)}
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
|
|
{/* Sort mode toggle */}
|
|
<SortDropdown
|
|
value={sortMode}
|
|
onChange={setSortMode}
|
|
className="h-9 w-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rules List */}
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{!hasRules ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
|
<Zap size={32} className="opacity-60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
Set up port forwarding
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
Save port forwarding to access databases, web apps, and other
|
|
services.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold">Port Forwarding</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
{filteredRules.length} rules
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
viewMode === "grid"
|
|
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
|
: "flex flex-col gap-2.5",
|
|
)}
|
|
>
|
|
{filteredRules.map((rule) => (
|
|
<RuleCard
|
|
key={rule.id}
|
|
rule={rule}
|
|
viewMode={viewMode}
|
|
isSelected={selectedRuleId === rule.id}
|
|
isPending={pendingOperations.has(rule.id)}
|
|
onSelect={() => {
|
|
setSelectedRuleId(rule.id);
|
|
startEditRule(rule);
|
|
}}
|
|
onEdit={() => startEditRule(rule)}
|
|
onDuplicate={() => duplicateRule(rule.id)}
|
|
onDelete={() => {
|
|
if (editingRule?.id === rule.id) {
|
|
closeEditPanel();
|
|
}
|
|
deleteRule(rule.id);
|
|
}}
|
|
onStart={() => handleStartTunnel(rule)}
|
|
onStop={() => handleStopTunnel(rule)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Edit Panel - shown when a rule is selected */}
|
|
{showEditPanel && editingRule && (
|
|
<EditPanel
|
|
rule={editingRule}
|
|
draft={editDraft}
|
|
hosts={hosts}
|
|
onDraftChange={(updates) =>
|
|
setEditDraft((prev) => ({ ...prev, ...updates }))
|
|
}
|
|
onSave={saveEditedRule}
|
|
onClose={closeEditPanel}
|
|
onDuplicate={() => {
|
|
duplicateRule(editingRule.id);
|
|
closeEditPanel();
|
|
}}
|
|
onDelete={() => {
|
|
deleteRule(editingRule.id);
|
|
closeEditPanel();
|
|
}}
|
|
onOpenHostSelector={() => setShowHostSelector(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* Wizard Panel */}
|
|
{showWizard && (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={() => {
|
|
setShowWizard(false);
|
|
resetWizard();
|
|
}}
|
|
title={isEditing ? "Edit Port Forwarding" : "New Port Forwarding"}
|
|
width="w-[360px]"
|
|
showBackButton={!!getPrevStep()}
|
|
onBack={
|
|
getPrevStep()
|
|
? () => {
|
|
const prev = getPrevStep();
|
|
if (prev) setWizardStep(prev);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<AsidePanelContent>
|
|
<WizardContent
|
|
step={wizardStep}
|
|
type={wizardType}
|
|
draft={draftRule}
|
|
hosts={hosts}
|
|
onTypeChange={(type) => {
|
|
setWizardType(type);
|
|
setDraftRule((prev) => ({ ...prev, type }));
|
|
}}
|
|
onDraftChange={(updates) =>
|
|
setDraftRule((prev) => ({ ...prev, ...updates }))
|
|
}
|
|
onOpenHostSelector={() => setShowHostSelector(true)}
|
|
/>
|
|
</AsidePanelContent>
|
|
<AsidePanelFooter className="space-y-2">
|
|
<Button
|
|
className="w-full h-10"
|
|
disabled={!canProceed()}
|
|
onClick={() => {
|
|
if (isLastStep()) {
|
|
saveRule();
|
|
} else {
|
|
const next = getNextStep();
|
|
if (next) setWizardStep(next);
|
|
}
|
|
}}
|
|
>
|
|
{isLastStep()
|
|
? isEditing
|
|
? "Save Changes"
|
|
: "Done"
|
|
: "Continue"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full h-10 text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
|
onClick={() => {
|
|
if (isEditing) {
|
|
setShowWizard(false);
|
|
resetWizard();
|
|
} else {
|
|
skipWizardToForm();
|
|
}
|
|
}}
|
|
>
|
|
{isEditing ? "Cancel" : "Skip wizard"}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
)}
|
|
|
|
{/* Host Selector Overlay */}
|
|
{showHostSelector && (
|
|
<SelectHostPanel
|
|
hosts={hosts}
|
|
customGroups={_customGroups}
|
|
selectedHostIds={
|
|
showEditPanel
|
|
? editDraft.hostId
|
|
? [editDraft.hostId]
|
|
: []
|
|
: showNewForm
|
|
? newFormDraft.hostId
|
|
? [newFormDraft.hostId]
|
|
: []
|
|
: draftRule.hostId
|
|
? [draftRule.hostId]
|
|
: []
|
|
}
|
|
multiSelect={false}
|
|
onSelect={(host) => {
|
|
if (showEditPanel) {
|
|
setEditDraft((prev) => ({ ...prev, hostId: host.id }));
|
|
} else if (showNewForm) {
|
|
setNewFormDraft((prev) => ({ ...prev, hostId: host.id }));
|
|
} else {
|
|
setDraftRule((prev) => ({ ...prev, hostId: host.id }));
|
|
}
|
|
setShowHostSelector(false);
|
|
}}
|
|
onBack={() => setShowHostSelector(false)}
|
|
onContinue={() => setShowHostSelector(false)}
|
|
availableKeys={keys}
|
|
onSaveHost={onSaveHost}
|
|
onCreateGroup={_onCreateGroup}
|
|
title="Select Host"
|
|
/>
|
|
)}
|
|
|
|
{/* New Form Panel (skip wizard mode) */}
|
|
{showNewForm && (
|
|
<NewFormPanel
|
|
draft={newFormDraft}
|
|
hosts={hosts}
|
|
onDraftChange={(updates) =>
|
|
setNewFormDraft((prev) => ({ ...prev, ...updates }))
|
|
}
|
|
onSave={saveNewFormRule}
|
|
onClose={closeNewForm}
|
|
onOpenHostSelector={() => setShowHostSelector(true)}
|
|
onOpenWizard={openWizardFromForm}
|
|
isValid={isNewFormValid()}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PortForwarding;
|