Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
737 lines
26 KiB
TypeScript
737 lines
26 KiB
TypeScript
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<ProxyConfig["type"], {
|
|
label?: string;
|
|
labelKey?: string;
|
|
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
|
iconClassName: string;
|
|
}>;
|
|
|
|
interface ProxyProfileCardProps {
|
|
profile: ProxyProfile;
|
|
usageCount: number;
|
|
viewMode: ProxyProfilesViewMode;
|
|
isSelected: boolean;
|
|
reorderProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
onClick: () => void;
|
|
onEdit: () => void;
|
|
onDuplicate: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
|
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 (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<button
|
|
{...reorderProps}
|
|
type="button"
|
|
aria-label={accessibleLabel}
|
|
className={cn(
|
|
reorderProps && "vault-drop-indicator-row",
|
|
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
viewMode === "grid"
|
|
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
|
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
|
isSelected && "ring-2 ring-primary",
|
|
reorderProps?.className,
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-center gap-3 h-full">
|
|
<VaultEntityIcon
|
|
className={protocol.iconClassName}
|
|
title={protocolLabel}
|
|
icon={<ProtocolIcon size={18} />}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
|
</div>
|
|
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
|
{endpoint} -{" "}
|
|
{protocolLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onClick={onEdit}>
|
|
<Pencil size={14} className="mr-2" />
|
|
{t("action.edit")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={onDuplicate}>
|
|
<Copy size={14} className="mr-2" />
|
|
{t("action.duplicate")}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
|
<Trash2 size={14} className="mr-2" />
|
|
{t("action.delete")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
};
|
|
|
|
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
|
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<ProxyProfile | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
|
const listRef = useRef<HTMLDivElement | null>(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<string, number>();
|
|
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 (
|
|
<div className="h-full flex relative">
|
|
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
|
<VaultPageHeader>
|
|
<Button
|
|
onClick={openCreate}
|
|
variant="secondary"
|
|
className={vaultHeaderSecondaryButtonClass}
|
|
>
|
|
<Plus size={14} />
|
|
{t("proxyProfiles.action.add")}
|
|
</Button>
|
|
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
|
<VaultHeaderSearch
|
|
aria-label={t("proxyProfiles.search.placeholder")}
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder={t("proxyProfiles.search.placeholder")}
|
|
className="flex-shrink w-64"
|
|
/>
|
|
<Dropdown>
|
|
<DropdownTrigger asChild>
|
|
<Button
|
|
aria-label={t("proxyProfiles.viewMode")}
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
|
|
>
|
|
{proxyProfilesViewMode === "grid" ? (
|
|
<LayoutGrid size={16} />
|
|
) : (
|
|
<ListIcon size={16} />
|
|
)}
|
|
<ChevronDown size={10} className="ml-0.5" />
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownContent className="w-32" align="end">
|
|
<Button
|
|
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("grid")}
|
|
>
|
|
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
|
</Button>
|
|
<Button
|
|
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
|
|
className="w-full justify-start gap-2 h-9"
|
|
onClick={() => setViewMode("list")}
|
|
>
|
|
<ListIcon size={14} /> {t("vault.view.list")}
|
|
</Button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
</div>
|
|
</VaultPageHeader>
|
|
|
|
<div
|
|
ref={listRef}
|
|
className="flex-1 overflow-y-auto"
|
|
onDragOverCapture={profileReorder.handleDragOverCapture}
|
|
onDragOver={profileReorder.handleDragOver}
|
|
onDropCapture={profileReorder.handleDropCapture}
|
|
onDragEndCapture={profileReorder.handleDragEndCapture}
|
|
>
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className={vaultSectionTitleClass}>
|
|
{t("proxyProfiles.section.proxies")}
|
|
</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
|
|
</span>
|
|
</div>
|
|
|
|
{filteredProfiles.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
|
<Globe size={32} className="opacity-60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
{t("proxyProfiles.empty.title")}
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm mb-4">
|
|
{t("proxyProfiles.empty.desc")}
|
|
</p>
|
|
<Button onClick={openCreate}>
|
|
<Plus size={14} className="mr-2" />
|
|
{t("proxyProfiles.action.add")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={
|
|
proxyProfilesViewMode === "grid"
|
|
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
: "flex flex-col gap-0"
|
|
}
|
|
>
|
|
{filteredProfiles.map((profile) => (
|
|
<ProxyProfileCard
|
|
key={profile.id}
|
|
profile={profile}
|
|
usageCount={usageByProfileId.get(profile.id) ?? 0}
|
|
viewMode={proxyProfilesViewMode}
|
|
isSelected={draft?.id === profile.id}
|
|
reorderProps={profileReorder.getItemReorderProps(profile.id, `proxy:${profile.id}`)}
|
|
onClick={() => openEdit(profile)}
|
|
onEdit={() => openEdit(profile)}
|
|
onDuplicate={() => duplicateProfile(profile)}
|
|
onDelete={() => setDeleteTarget(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{draft && (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={() => setDraft(null)}
|
|
title={draft.label || t("proxyProfiles.panel.newTitle")}
|
|
>
|
|
<AsidePanelContent>
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="flex items-center gap-2">
|
|
<Settings2 size={14} className="text-muted-foreground" />
|
|
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
|
|
</div>
|
|
<Input
|
|
aria-label={t("proxyProfiles.field.name")}
|
|
value={draft.label}
|
|
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
|
|
placeholder={t("proxyProfiles.field.name")}
|
|
className="h-10"
|
|
/>
|
|
</Card>
|
|
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Globe size={14} className="text-muted-foreground" />
|
|
<p className="text-xs font-semibold">{t("field.type")}</p>
|
|
</div>
|
|
<Select
|
|
value={draft.config.type}
|
|
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
|
|
>
|
|
<SelectTrigger aria-label={t("field.type")} className="h-10">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="http">HTTP</SelectItem>
|
|
<SelectItem value="socks5">SOCKS5</SelectItem>
|
|
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{isProxyCommandConfig(draft.config) ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("hostDetails.proxyPanel.commandHelp")}
|
|
</p>
|
|
<Input
|
|
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
|
|
value={draft.config.command || ""}
|
|
onChange={(event) => updateDraftConfig("command", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
|
|
className="h-10 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<Input
|
|
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
|
value={draft.config.host}
|
|
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
|
className="h-10 flex-1"
|
|
/>
|
|
<Input
|
|
aria-label={t("hostDetails.port")}
|
|
type="number"
|
|
value={draft.config.port || ""}
|
|
onChange={(event) => 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"
|
|
/>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<KeyRound size={14} className="text-muted-foreground" />
|
|
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
|
|
</div>
|
|
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
|
|
</div>
|
|
{identities.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("hostDetails.proxyPanel.keychainIdentity")}
|
|
</p>
|
|
<Select
|
|
value={selectedDraftIdentityValue}
|
|
onValueChange={(value) => {
|
|
if (value === missingIdentityValue) return;
|
|
updateDraftConfig(
|
|
"identityId",
|
|
value === manualCredentialsValue ? undefined : value,
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={t("hostDetails.proxyPanel.keychainIdentity")}
|
|
className="h-10"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={manualCredentialsValue}>
|
|
{t("hostDetails.proxyPanel.manualCredentials")}
|
|
</SelectItem>
|
|
{hasMissingDraftIdentity && (
|
|
<SelectItem value={missingIdentityValue}>
|
|
{t("hostDetails.proxyPanel.missingIdentity")}
|
|
</SelectItem>
|
|
)}
|
|
{identities.map((identity) => (
|
|
<SelectItem key={identity.id} value={identity.id}>
|
|
{identity.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
{hasMissingDraftIdentity && (
|
|
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
|
{t("hostDetails.proxyPanel.missingIdentity")}
|
|
</div>
|
|
)}
|
|
{hasIncompleteDraftIdentity && (
|
|
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
|
{t("hostDetails.proxyPanel.incompleteIdentity")}
|
|
</div>
|
|
)}
|
|
{hasUnreadableDraftIdentity && (
|
|
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
|
{t("hostDetails.proxyPanel.unreadableIdentity")}
|
|
</div>
|
|
)}
|
|
{selectedDraftIdentity ? (
|
|
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Badge variant="secondary" className="text-xs shrink-0">
|
|
{t("hostDetails.proxyPanel.keychainIdentity")}
|
|
</Badge>
|
|
<span className="truncate">
|
|
{selectedDraftIdentity.label} - {selectedDraftIdentity.username}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Input
|
|
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
|
value={draft.config.username || ""}
|
|
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
|
className="h-10"
|
|
/>
|
|
<Input
|
|
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
|
type="password"
|
|
value={draft.config.password || ""}
|
|
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
|
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
|
className="h-10"
|
|
/>
|
|
</>
|
|
)}
|
|
</Card>}
|
|
</AsidePanelContent>
|
|
<AsidePanelFooter>
|
|
<Button className="w-full" onClick={saveDraft}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
)}
|
|
|
|
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle size={18} className="text-destructive" />
|
|
{t("proxyProfiles.delete.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{deleteTarget
|
|
? t("proxyProfiles.delete.desc", {
|
|
name: deleteTarget.label,
|
|
count: usageByProfileId.get(deleteTarget.id) ?? 0,
|
|
})
|
|
: ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button variant="destructive" onClick={confirmDelete}>
|
|
{t("action.delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProxyProfilesManager;
|