961 lines
29 KiB
TypeScript
961 lines
29 KiB
TypeScript
import {
|
|
AlertTriangle,
|
|
Check,
|
|
ChevronDown,
|
|
Globe,
|
|
LayoutGrid,
|
|
List as ListIcon,
|
|
Server,
|
|
Shuffle,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
|
import {
|
|
GroupConfig,
|
|
Host,
|
|
ManagedSource,
|
|
PortForwardingRule,
|
|
PortForwardingType,
|
|
ProxyProfile,
|
|
SSHKey,
|
|
} from "../domain/models";
|
|
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
|
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
|
import { cn } from "../lib/utils";
|
|
import SelectHostPanel from "./SelectHostPanel";
|
|
import {
|
|
AsidePanel,
|
|
AsidePanelContent,
|
|
AsidePanelFooter,
|
|
} from "./ui/aside-panel";
|
|
import { Button } from "./ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "./ui/dialog";
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
|
import { SortDropdown } from "./ui/sort-dropdown";
|
|
import { toast } from "./ui/toast";
|
|
import {
|
|
VaultHeaderSearch,
|
|
VaultPageHeader,
|
|
vaultHeaderIconButtonClass,
|
|
vaultHeaderSecondaryButtonClass,
|
|
vaultSectionTitleClass,
|
|
} from "./vault/VaultPageHeader";
|
|
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
|
|
|
// Import components and utilities from port-forwarding module
|
|
import {
|
|
EditPanel,
|
|
generateRuleLabel as buildRuleLabel,
|
|
getTypeMenuLabel,
|
|
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[];
|
|
identities?: import('../domain/models').Identity[];
|
|
customGroups: string[];
|
|
managedSources?: ManagedSource[];
|
|
groupConfigs?: GroupConfig[];
|
|
proxyProfiles?: ProxyProfile[];
|
|
onNewHost?: () => void;
|
|
onSaveHost?: (host: Host) => void;
|
|
onCreateGroup?: (groupPath: string) => void;
|
|
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
|
}
|
|
|
|
const PortForwarding: React.FC<PortForwardingProps> = ({
|
|
hosts,
|
|
keys,
|
|
identities = [],
|
|
customGroups: _customGroups,
|
|
managedSources = [],
|
|
groupConfigs = [],
|
|
proxyProfiles = [],
|
|
onNewHost: _onNewHost,
|
|
onSaveHost,
|
|
onCreateGroup: _onCreateGroup,
|
|
terminalSettings,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const {
|
|
rules: _rules,
|
|
selectedRuleId,
|
|
viewMode,
|
|
sortMode,
|
|
search,
|
|
setSelectedRuleId,
|
|
setViewMode,
|
|
setSortMode,
|
|
setSearch,
|
|
addRule,
|
|
updateRule,
|
|
deleteRule,
|
|
duplicateRule,
|
|
reorderRule,
|
|
setRuleStatus,
|
|
startTunnel,
|
|
stopTunnel,
|
|
filteredRules,
|
|
selectedRule: _selectedRule,
|
|
preferFormMode,
|
|
setPreferFormMode,
|
|
} = usePortForwardingState();
|
|
|
|
// Track connecting/stopping states
|
|
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const proxyProfileIdSet = useMemo(
|
|
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
|
[proxyProfiles],
|
|
);
|
|
const ruleListRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const ruleReorder = useVaultItemReorder({
|
|
containerRef: ruleListRef,
|
|
viewMode,
|
|
dragType: "rule-id",
|
|
targetAttribute: "data-rule-id",
|
|
disabled: search.trim().length > 0,
|
|
onReorder: reorderRule,
|
|
});
|
|
|
|
const resolveEffectiveHost = useCallback(
|
|
(host: Host): Host => {
|
|
const withGroupDefaults = host.group
|
|
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
|
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
|
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
|
},
|
|
[groupConfigs, proxyProfileIdSet, proxyProfiles],
|
|
);
|
|
|
|
// Start a port forwarding tunnel
|
|
const handleStartTunnel = useCallback(
|
|
async (rule: PortForwardingRule) => {
|
|
const _rawHost = hosts.find((h) => h.id === rule.hostId);
|
|
if (!_rawHost) {
|
|
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
|
|
toast.error(
|
|
t("pf.error.hostNotFound"),
|
|
t("pf.toast.titleWithLabel", { label: rule.label }),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const _host = resolveEffectiveHost(_rawHost);
|
|
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
|
|
|
|
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
|
let errorShown = false;
|
|
|
|
try {
|
|
const result = await startTunnel(
|
|
rule,
|
|
_host,
|
|
effectiveHosts,
|
|
keys,
|
|
identities,
|
|
(status, error) => {
|
|
// Show toast on error (only once)
|
|
if (status === "error" && error && !errorShown) {
|
|
errorShown = true;
|
|
toast.error(
|
|
error,
|
|
t("pf.toast.titleWithLabel", { label: rule.label }),
|
|
);
|
|
}
|
|
},
|
|
rule.autoStart, // Enable reconnect for auto-start rules
|
|
terminalSettings,
|
|
);
|
|
// Show error from result only if not already shown
|
|
if (!result.success && result.error && !errorShown) {
|
|
errorShown = true;
|
|
toast.error(
|
|
result.error,
|
|
t("pf.toast.titleWithLabel", { label: rule.label }),
|
|
);
|
|
}
|
|
} finally {
|
|
setPendingOperations((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(rule.id);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
|
|
);
|
|
|
|
// 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);
|
|
|
|
// Delete confirmation dialog state for active tunnels
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [ruleToDelete, setRuleToDelete] = useState<PortForwardingRule | null>(null);
|
|
const [isDeleting, setIsDeleting] = 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() ||
|
|
(() => {
|
|
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 = useCallback(() => {
|
|
setShowEditPanel(false);
|
|
setEditingRule(null);
|
|
setEditDraft({});
|
|
setSelectedRuleId(null);
|
|
}, [setSelectedRuleId]);
|
|
|
|
// Handle delete with confirmation for active tunnels
|
|
const handleDeleteRule = useCallback(
|
|
(rule: PortForwardingRule) => {
|
|
// If tunnel is active or connecting, show confirmation dialog
|
|
if (rule.status === "active" || rule.status === "connecting") {
|
|
setRuleToDelete(rule);
|
|
setShowDeleteConfirm(true);
|
|
} else {
|
|
// If inactive, delete directly
|
|
if (editingRule?.id === rule.id) {
|
|
closeEditPanel();
|
|
}
|
|
deleteRule(rule.id);
|
|
}
|
|
},
|
|
[editingRule, deleteRule, closeEditPanel],
|
|
);
|
|
|
|
// Confirm delete of active tunnel: stop first, then delete
|
|
const confirmDeleteActiveRule = useCallback(async () => {
|
|
if (!ruleToDelete) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
// Stop the tunnel first
|
|
await stopTunnel(ruleToDelete.id);
|
|
// Then delete the rule
|
|
if (editingRule?.id === ruleToDelete.id) {
|
|
closeEditPanel();
|
|
}
|
|
deleteRule(ruleToDelete.id);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setShowDeleteConfirm(false);
|
|
setRuleToDelete(null);
|
|
}
|
|
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
|
|
|
|
// 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 => {
|
|
return buildRuleLabel(
|
|
wizardType,
|
|
draftRule.localPort,
|
|
draftRule.remoteHost,
|
|
draftRule.remotePort,
|
|
);
|
|
};
|
|
|
|
// 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]" : "",
|
|
)}
|
|
>
|
|
<VaultPageHeader className="z-20">
|
|
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
|
<DropdownTrigger asChild>
|
|
<Button
|
|
variant="secondary"
|
|
className={vaultHeaderSecondaryButtonClass}
|
|
>
|
|
<Zap size={14} />
|
|
{t("pf.action.newForwarding")}
|
|
<ChevronDown
|
|
size={14}
|
|
className={cn(
|
|
"transition-transform",
|
|
showNewMenu ? "rotate-180" : "",
|
|
)}
|
|
/>
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownContent className="w-max flex flex-col" align="start" sideOffset={8}>
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
|
|
onClick={() => startNewRule("local")}
|
|
>
|
|
<Globe size={16} className="text-blue-500" />
|
|
{getTypeMenuLabel(t, "local")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
|
|
onClick={() => startNewRule("remote")}
|
|
>
|
|
<Server size={16} className="text-orange-500" />
|
|
{getTypeMenuLabel(t, "remote")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
|
|
onClick={() => startNewRule("dynamic")}
|
|
>
|
|
<Shuffle size={16} className="text-purple-500" />
|
|
{getTypeMenuLabel(t, "dynamic")}
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<VaultHeaderSearch
|
|
placeholder={t("common.searchPlaceholder")}
|
|
className="w-64"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
|
|
{/* View mode toggle */}
|
|
<Dropdown>
|
|
<DropdownTrigger asChild>
|
|
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
|
|
{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} /> {t("pf.view.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} /> {t("pf.view.list")}
|
|
{viewMode === "list" && (
|
|
<Check size={12} className="ml-auto" />
|
|
)}
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
|
|
{/* Sort mode toggle */}
|
|
<SortDropdown
|
|
value={sortMode}
|
|
onChange={(mode) => {
|
|
if (mode !== "group") setSortMode(mode);
|
|
}}
|
|
modes={["manual", "az", "za", "newest", "oldest"]}
|
|
className={vaultHeaderIconButtonClass}
|
|
/>
|
|
</div>
|
|
</VaultPageHeader>
|
|
|
|
{/* Rules List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!hasRules ? (
|
|
<div className="flex h-full flex-col items-center justify-center p-3 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">
|
|
{t("pf.empty.title")}
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
{t("pf.empty.desc")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("pf.rulesCount", { count: filteredRules.length })}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
ref={ruleListRef}
|
|
className={cn(
|
|
viewMode === "grid"
|
|
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
|
: "flex flex-col gap-2.5",
|
|
)}
|
|
onDragOverCapture={ruleReorder.handleDragOverCapture}
|
|
onDragOver={ruleReorder.handleDragOver}
|
|
onDropCapture={ruleReorder.handleDropCapture}
|
|
onDragEndCapture={ruleReorder.handleDragEndCapture}
|
|
>
|
|
{filteredRules.map((rule) => (
|
|
<RuleCard
|
|
key={rule.id}
|
|
rule={rule}
|
|
host={hosts.find((h) => h.id === rule.hostId)}
|
|
viewMode={viewMode}
|
|
isSelected={selectedRuleId === rule.id}
|
|
isPending={pendingOperations.has(rule.id)}
|
|
reorderProps={ruleReorder.getItemReorderProps(rule.id, `rule:${rule.id}`)}
|
|
onSelect={() => {
|
|
setSelectedRuleId(rule.id);
|
|
startEditRule(rule);
|
|
}}
|
|
onEdit={() => startEditRule(rule)}
|
|
onDuplicate={() => duplicateRule(rule.id)}
|
|
onDelete={() => handleDeleteRule(rule)}
|
|
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={() => handleDeleteRule(editingRule)}
|
|
onOpenHostSelector={() => setShowHostSelector(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* Wizard Panel */}
|
|
{showWizard && (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={() => {
|
|
setShowWizard(false);
|
|
resetWizard();
|
|
}}
|
|
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
|
|
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
|
|
? t("pf.wizard.saveChanges")
|
|
: t("pf.wizard.done")
|
|
: t("pf.wizard.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 ? t("pf.wizard.cancel") : t("pf.wizard.skipWizard")}
|
|
</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}
|
|
identities={identities}
|
|
proxyProfiles={proxyProfiles}
|
|
managedSources={managedSources}
|
|
onSaveHost={onSaveHost}
|
|
onCreateGroup={_onCreateGroup}
|
|
/>
|
|
)}
|
|
|
|
{/* 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()}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Active Tunnel Confirmation Dialog */}
|
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
|
|
if (!isDeleting) {
|
|
setShowDeleteConfirm(open);
|
|
if (!open) setRuleToDelete(null);
|
|
}
|
|
}}>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle size={20} />
|
|
{t("pf.deleteActive.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowDeleteConfirm(false);
|
|
setRuleToDelete(null);
|
|
}}
|
|
disabled={isDeleting}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={confirmDeleteActiveRule}
|
|
disabled={isDeleting}
|
|
>
|
|
{t("pf.deleteActive.confirm")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PortForwarding;
|