import { AlertTriangle, ChevronDown, Copy, Globe, KeyRound, LayoutGrid, List as ListIcon, Pencil, Plus, Route, Settings2, SquareTerminal, Trash2, } from "lucide-react"; import React, { useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useStoredViewMode } from "../application/state/useStoredViewMode"; import { formatProxyConfigEndpoint, hasIncompleteProxyIdentity, hasMissingProxyIdentity, hasUnreadableProxyCredential, isProxyCommandConfig, isValidProxyPort, normalizeManualProxyConfig, removeProxyProfileReferences, updateProxyConfigField, } from "../domain/proxyProfiles"; import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder"; import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, } from "../infrastructure/config/storageKeys"; import { cn } from "../lib/utils"; import type { GroupConfig, Host, Identity, ProxyConfig, ProxyProfile } from "../types"; import { AsidePanel, AsidePanelContent, AsidePanelFooter, } from "./ui/aside-panel"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card } from "./ui/card"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "./ui/context-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { toast } from "./ui/toast"; import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass, vaultSectionTitleClass, } from "./vault/VaultPageHeader"; import { VaultEntityIcon, vaultProxyCommandIconClass, vaultProxyHttpIconClass, vaultProxySocksIconClass, } from "./vault/VaultEntityIcon"; import { useVaultItemReorder } from "./vault/vaultReorderDrag"; interface ProxyProfilesManagerProps { proxyProfiles: ProxyProfile[]; hosts: Host[]; groupConfigs: GroupConfig[]; identities: Identity[]; onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void; onUpdateHosts: (hosts: Host[]) => void; onUpdateGroupConfigs: (configs: GroupConfig[]) => void; } export type ProxyProfileSaveError = | "required" | "port" | "missingIdentity" | "incompleteIdentity" | "unreadableIdentity"; export const prepareProxyProfileForSave = ( draft: ProxyProfile, identities: Identity[], updatedAt = Date.now(), ): { saved?: ProxyProfile; error?: ProxyProfileSaveError } => { const label = draft.label.trim(); const host = draft.config.host.trim(); const command = draft.config.command?.trim() || ""; const isCommand = isProxyCommandConfig(draft.config); if (!label || (isCommand ? !command : (!host || !draft.config.port))) { return { error: "required" }; } if (!isCommand && !isValidProxyPort(draft.config.port)) { return { error: "port" }; } if (!isCommand && hasMissingProxyIdentity(draft.config, identities)) { return { error: "missingIdentity" }; } if (!isCommand && hasIncompleteProxyIdentity(draft.config, identities)) { return { error: "incompleteIdentity" }; } if (!isCommand && hasUnreadableProxyCredential(draft.config, identities)) { return { error: "unreadableIdentity" }; } const normalizedConfig = normalizeManualProxyConfig(draft.config); if (!normalizedConfig) return { error: "required" }; return { saved: { ...draft, label, config: normalizedConfig, updatedAt, }, }; }; const createDraftProfile = (): ProxyProfile => { const now = Date.now(); return { id: crypto.randomUUID(), label: "", config: { type: "http", host: "", port: 8080, }, createdAt: now, updatedAt: now, }; }; const getProfileUsageCount = ( profileId: string, hosts: Host[], groupConfigs: GroupConfig[], ): number => hosts.filter((host) => host.proxyProfileId === profileId).length + groupConfigs.filter((config) => config.proxyProfileId === profileId).length; type ProxyProfilesViewMode = "grid" | "list"; const proxyProtocolMeta = { http: { label: "HTTP", Icon: Globe, iconClassName: vaultProxyHttpIconClass, }, socks5: { label: "SOCKS5", Icon: Route, iconClassName: vaultProxySocksIconClass, }, command: { labelKey: "hostDetails.proxyPanel.command", Icon: SquareTerminal, iconClassName: vaultProxyCommandIconClass, }, } satisfies Record; iconClassName: string; }>; interface ProxyProfileCardProps { profile: ProxyProfile; usageCount: number; viewMode: ProxyProfilesViewMode; isSelected: boolean; reorderProps?: React.ButtonHTMLAttributes; onClick: () => void; onEdit: () => void; onDuplicate: () => void; onDelete: () => void; } const ProxyProfileCard: React.FC = ({ profile, usageCount, viewMode, isSelected, reorderProps, onClick, onEdit, onDuplicate, onDelete, }) => { const { t } = useI18n(); const usageLabel = t("proxyProfiles.usage", { count: usageCount }); const protocol = proxyProtocolMeta[profile.config.type]; const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label; const ProtocolIcon = protocol.Icon; const endpoint = formatProxyConfigEndpoint(profile.config); const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`; return ( {t("action.edit")} {t("action.duplicate")} {t("action.delete")} ); }; export const ProxyProfilesManager: React.FC = ({ proxyProfiles, hosts, groupConfigs, identities, onUpdateProxyProfiles, onUpdateHosts, onUpdateGroupConfigs, }) => { const { t } = useI18n(); const [search, setSearch] = useState(""); const [viewMode, setViewMode] = useStoredViewMode( STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, "grid", ); const proxyProfilesViewMode: ProxyProfilesViewMode = viewMode === "list" ? "list" : "grid"; const [draft, setDraft] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const listRef = useRef(null); const manualCredentialsValue = "__manual_credentials__"; const missingIdentityValue = "__missing_identity__"; const selectedDraftIdentity = useMemo( () => identities.find((identity) => identity.id === draft?.config.identityId), [draft?.config.identityId, identities], ); const hasMissingDraftIdentity = hasMissingProxyIdentity(draft?.config, identities); const hasIncompleteDraftIdentity = hasIncompleteProxyIdentity(draft?.config, identities); const hasUnreadableDraftIdentity = hasUnreadableProxyCredential(draft?.config, identities); const selectedDraftIdentityValue = selectedDraftIdentity?.id || (hasMissingDraftIdentity ? missingIdentityValue : manualCredentialsValue); const usageByProfileId = useMemo(() => { const map = new Map(); for (const profile of proxyProfiles) { map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs)); } return map; }, [groupConfigs, hosts, proxyProfiles]); const filteredProfiles = useMemo(() => { const q = search.trim().toLowerCase(); const result = !q ? proxyProfiles : proxyProfiles.filter((profile) => profile.label.toLowerCase().includes(q) || profile.config.host.toLowerCase().includes(q) || (profile.config.command || "").toLowerCase().includes(q) || profile.config.type.toLowerCase().includes(q), ); return sortByVaultOrder(result); }, [proxyProfiles, search]); const profileReorder = useVaultItemReorder({ containerRef: listRef, viewMode: proxyProfilesViewMode, dragType: "proxy-profile-id", targetAttribute: "data-proxy-profile-id", disabled: search.trim().length > 0, onReorder: (sourceId, targetId, position) => { onUpdateProxyProfiles(reorderVaultItems(proxyProfiles, sourceId, targetId, position)); }, }); const updateDraftConfig = (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => { setDraft((prev) => { if (!prev) return prev; return { ...prev, config: updateProxyConfigField(prev.config, field, value), }; }); }; const openCreate = () => { setDraft(createDraftProfile()); }; const openEdit = (profile: ProxyProfile) => { setDraft({ ...profile, config: { ...profile.config }, }); }; const duplicateProfile = (profile: ProxyProfile) => { const now = Date.now(); onUpdateProxyProfiles([ ...proxyProfiles, { ...profile, id: crypto.randomUUID(), label: t("proxyProfiles.copyName", { name: profile.label }), config: { ...profile.config }, createdAt: now, updatedAt: now, }, ]); }; const saveDraft = () => { if (!draft) return; const result = prepareProxyProfileForSave(draft, identities); if (!result.saved) { const messageKey = result.error === "port" ? "proxyProfiles.error.port" : result.error === "missingIdentity" ? "hostDetails.proxyPanel.missingIdentity" : result.error === "incompleteIdentity" ? "hostDetails.proxyPanel.incompleteIdentity" : result.error === "unreadableIdentity" ? "hostDetails.proxyPanel.unreadableIdentity" : "proxyProfiles.error.required"; toast.error(t(messageKey)); return; } const saved = result.saved; onUpdateProxyProfiles( proxyProfiles.some((profile) => profile.id === saved.id) ? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile) : [...proxyProfiles, saved], ); setDraft(null); }; const confirmDelete = () => { if (!deleteTarget) return; const cleaned = removeProxyProfileReferences(deleteTarget.id, { hosts, groupConfigs, }); onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id)); onUpdateHosts(cleaned.hosts); onUpdateGroupConfigs(cleaned.groupConfigs); if (draft?.id === deleteTarget.id) { setDraft(null); } setDeleteTarget(null); }; return (
setSearch(event.target.value)} placeholder={t("proxyProfiles.search.placeholder")} className="flex-shrink w-64" />

{t("proxyProfiles.section.proxies")}

{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
{filteredProfiles.length === 0 ? (

{t("proxyProfiles.empty.title")}

{t("proxyProfiles.empty.desc")}

) : (
{filteredProfiles.map((profile) => ( openEdit(profile)} onEdit={() => openEdit(profile)} onDuplicate={() => duplicateProfile(profile)} onDelete={() => setDeleteTarget(profile)} /> ))}
)}
{draft && ( setDraft(null)} title={draft.label || t("proxyProfiles.panel.newTitle")} >

{t("proxyProfiles.field.name")}

setDraft({ ...draft, label: event.target.value })} placeholder={t("proxyProfiles.field.name")} className="h-10" />

{t("field.type")}

{isProxyCommandConfig(draft.config) ? (

{t("hostDetails.proxyPanel.commandHelp")}

updateDraftConfig("command", event.target.value)} placeholder={t("hostDetails.proxyPanel.commandPlaceholder")} className="h-10 font-mono text-xs" />
) : (
updateDraftConfig("host", event.target.value)} placeholder={t("hostDetails.proxyPanel.hostPlaceholder")} className="h-10 flex-1" /> updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))} placeholder="3128" min={1} max={65535} step={1} className="h-10 w-24 text-center" />
)}
{!isProxyCommandConfig(draft.config) &&

{t("hostDetails.proxyPanel.credentials")}

{t("common.optional")}
{identities.length > 0 && (

{t("hostDetails.proxyPanel.keychainIdentity")}

)} {hasMissingDraftIdentity && (
{t("hostDetails.proxyPanel.missingIdentity")}
)} {hasIncompleteDraftIdentity && (
{t("hostDetails.proxyPanel.incompleteIdentity")}
)} {hasUnreadableDraftIdentity && (
{t("hostDetails.proxyPanel.unreadableIdentity")}
)} {selectedDraftIdentity ? (
{t("hostDetails.proxyPanel.keychainIdentity")} {selectedDraftIdentity.label} - {selectedDraftIdentity.username}
) : ( <> updateDraftConfig("username", event.target.value)} placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")} className="h-10" /> updateDraftConfig("password", event.target.value)} placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")} className="h-10" /> )}
}
)} !open && setDeleteTarget(null)}> {t("proxyProfiles.delete.title")} {deleteTarget ? t("proxyProfiles.delete.desc", { name: deleteTarget.label, count: usageByProfileId.get(deleteTarget.id) ?? 0, }) : ""}
); }; export default ProxyProfilesManager;