"use client"; import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Checkbox, Chip, CircularProgress, Collapse, Divider, FormControlLabel, Grid, IconButton, InputAdornment, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import PublicIcon from "@mui/icons-material/Public"; import SearchIcon from "@mui/icons-material/Search"; import CloseIcon from "@mui/icons-material/Close"; import { useState, useEffect, useMemo, useCallback, SyntheticEvent } from "react"; import { GeoBlockSettings } from "@/src/lib/settings"; import { GeoBlockMode } from "@/src/lib/models/proxy-hosts"; import { COUNTRIES, flagEmoji } from "./countries"; // ─── GeoIpStatus ───────────────────────────────────────────────────────────── type GeoIpStatusData = { country: boolean; asn: boolean } | null; function GeoIpStatus() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch("/api/geoip-status") .then((r) => r.json()) .then((d) => setStatus(d)) .catch(() => setStatus(null)) .finally(() => setLoading(false)); }, []); if (loading) { return ; } const allLoaded = status?.country && status?.asn; const noneLoaded = !status?.country && !status?.asn; const color = allLoaded ? "success" : noneLoaded ? "error" : "warning"; const Icon = allLoaded ? CheckCircleIcon : noneLoaded ? ErrorIcon : WarningAmberIcon; const label = allLoaded ? "GeoIP ready" : noneLoaded ? "GeoIP missing" : "GeoIP partial"; const tooltip = noneLoaded ? "GeoIP databases not found — country/continent/ASN blocking will not work. Enable the geoipupdate service." : !status?.country ? "GeoLite2-Country database missing — country/continent blocking disabled" : !status?.asn ? "GeoLite2-ASN database missing — ASN blocking disabled" : "GeoLite2-Country and GeoLite2-ASN databases loaded"; return ( } label={label} color={color} variant="outlined" sx={{ height: 22, fontSize: "0.7rem", fontWeight: 600, letterSpacing: 0.3, cursor: "default", "& .MuiChip-icon": { ml: "6px" } }} /> ); } // ─── CountryPicker ──────────────────────────────────────────────────────────── const CONTINENTS = [ { code: "AF", name: "Africa", emoji: "🌍" }, { code: "AN", name: "Antarctica", emoji: "🧊" }, { code: "AS", name: "Asia", emoji: "🌏" }, { code: "EU", name: "Europe", emoji: "🌍" }, { code: "NA", name: "N. America", emoji: "🌎" }, { code: "OC", name: "Oceania", emoji: "🌏" }, { code: "SA", name: "S. America", emoji: "🌎" }, ]; type CountryPickerProps = { name: string; initialValues?: string[]; accentColor?: "warning" | "success"; }; function CountryPicker({ name, initialValues = [], accentColor = "warning" }: CountryPickerProps) { const [selected, setSelected] = useState>( () => new Set(initialValues.map((c) => c.toUpperCase()).filter(Boolean)) ); const [search, setSearch] = useState(""); const filtered = useMemo(() => { const q = search.toLowerCase().trim(); if (!q) return COUNTRIES; return COUNTRIES.filter( (c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().startsWith(q) ); }, [search]); const toggle = useCallback((code: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }); }, []); const selectFiltered = useCallback(() => { setSelected((prev) => { const next = new Set(prev); filtered.forEach((c) => next.add(c.code)); return next; }); }, [filtered]); const clearFiltered = useCallback(() => { setSelected((prev) => { const next = new Set(prev); filtered.forEach((c) => next.delete(c.code)); return next; }); }, [filtered]); const selectedInFiltered = filtered.filter((c) => selected.has(c.code)).length; const allFilteredSelected = filtered.length > 0 && selectedInFiltered === filtered.length; return ( {/* Search */} setSearch(e.target.value)} slotProps={{ input: { startAdornment: ( ), endAdornment: search ? ( setSearch("")} edge="end" sx={{ p: 0.25 }}> ) : null, }, }} sx={{ mb: 0.75 }} /> {/* Toolbar */} {selected.size > 0 ? ( <>{selected.size} selected{search && `, ${selectedInFiltered} shown`} ) : ( None selected )} · {/* Grid */} t.palette.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.015)", "&::-webkit-scrollbar": { width: 5 }, "&::-webkit-scrollbar-thumb": { borderRadius: 3, bgcolor: "divider" }, "&::-webkit-scrollbar-track": { bgcolor: "transparent" }, }} > {filtered.length === 0 ? ( No countries match “{search}” ) : ( filtered.map((country) => { const isSelected = selected.has(country.code); return ( {flagEmoji(country.code)} {country.name} {country.code} } size="small" onClick={() => toggle(country.code)} color={isSelected ? accentColor : "default"} variant={isSelected ? "filled" : "outlined"} sx={{ cursor: "pointer", fontSize: "0.72rem", height: 26, transition: "all 0.1s ease", "& .MuiChip-label": { px: 0.75 }, ...(!isSelected && { borderColor: "divider", "&:hover": { borderColor: "text.disabled", bgcolor: "action.hover" }, }), ...(isSelected && { fontWeight: 600, boxShadow: accentColor === "warning" ? "0 0 0 1px rgba(237,108,2,0.3)" : "0 0 0 1px rgba(46,125,50,0.3)", }), }} /> ); }) )} {/* Selected summary chips (shown when search is active and selected items are hidden) */} {search && selected.size > 0 && ( All selected ({selected.size}): {[...selected].map((code) => { const country = COUNTRIES.find((c) => c.code === code); return ( {flagEmoji(code)} {country?.name ?? code} } size="small" onDelete={() => toggle(code)} color={accentColor} variant="filled" sx={{ fontSize: "0.7rem", height: 22, fontWeight: 600, "& .MuiChip-label": { px: 0.6 } }} /> ); })} )} ); } // ─── ContinentPicker ────────────────────────────────────────────────────────── type ContinentPickerProps = { name: string; initialValues?: string[]; accentColor?: "warning" | "success"; }; function ContinentPicker({ name, initialValues = [], accentColor = "warning" }: ContinentPickerProps) { const [selected, setSelected] = useState>( () => new Set(initialValues.map((c) => c.toUpperCase()).filter(Boolean)) ); const toggle = (code: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }); }; const isWarning = accentColor === "warning"; return ( {selected.size > 0 ? `${selected.size} selected` : None selected} · {CONTINENTS.map((c) => { const isSelected = selected.has(c.code); return ( toggle(c.code)} sx={{ display: "flex", alignItems: "center", gap: 0.75, px: 1.25, py: 0.75, borderRadius: 1.5, border: "1.5px solid", borderColor: isSelected ? isWarning ? "warning.main" : "success.main" : "divider", bgcolor: isSelected ? (t) => t.palette.mode === "dark" ? isWarning ? "rgba(237,108,2,0.15)" : "rgba(46,125,50,0.15)" : isWarning ? "rgba(237,108,2,0.08)" : "rgba(46,125,50,0.08)" : "transparent", cursor: "pointer", userSelect: "none", transition: "all 0.12s ease", "&:hover": { borderColor: isSelected ? isWarning ? "warning.dark" : "success.dark" : "text.disabled", bgcolor: isSelected ? (t) => t.palette.mode === "dark" ? isWarning ? "rgba(237,108,2,0.22)" : "rgba(46,125,50,0.22)" : isWarning ? "rgba(237,108,2,0.13)" : "rgba(46,125,50,0.13)" : "action.hover", }, ...(isSelected && { boxShadow: isWarning ? "0 0 0 1px rgba(237,108,2,0.25)" : "0 0 0 1px rgba(46,125,50,0.25)", }), }} > {c.emoji} {c.name} {c.code} ); })} ); } // ─── TagInput ──────────────────────────────────────────────────────────────── type TagInputProps = { name: string; label: string; initialValues?: string[]; placeholder?: string; helperText?: string; validate?: (value: string) => boolean; uppercase?: boolean; }; function TagInput({ name, label, initialValues = [], placeholder, helperText, validate, uppercase = false }: TagInputProps) { const [tags, setTags] = useState(initialValues); const [inputValue, setInputValue] = useState(""); function processValue(raw: string): string { return uppercase ? raw.trim().toUpperCase() : raw.trim(); } function commitInput(raw: string) { const value = processValue(raw); if (!value) return; if (validate && !validate(value)) return; if (tags.includes(value)) { setInputValue(""); return; } setTags((prev) => [...prev, value]); setInputValue(""); } return ( setInputValue(e.target.value)} onKeyDown={(e) => { if (e.key === "," || e.key === " " || e.key === "Enter") { e.preventDefault(); commitInput(inputValue); } if (e.key === "Backspace" && !inputValue && tags.length > 0) { setTags((prev) => prev.slice(0, -1)); } }} onBlur={() => { if (inputValue.trim()) commitInput(inputValue); }} slotProps={{ input: { startAdornment: tags.length > 0 ? ( {tags.map((tag) => ( setTags((prev) => prev.filter((t) => t !== tag))} sx={{ height: 20, fontSize: "0.68rem", "& .MuiChip-label": { px: 0.6 }, "& .MuiChip-deleteIcon": { fontSize: 12 } }} /> ))} ) : undefined, }, }} /> ); } // ─── ResponseHeadersEditor ──────────────────────────────────────────────────── type HeaderRow = { key: string; value: string }; function ResponseHeadersEditor({ initialHeaders }: { initialHeaders: Record }) { const [rows, setRows] = useState(() => Object.entries(initialHeaders).map(([key, value]) => ({ key, value })) ); return ( 0 ? 1 : 0}> Custom Response Headers setRows((prev) => [...prev, { key: "", value: "" }])}> {rows.length === 0 ? ( No custom headers — click + to add one. ) : ( {rows.map((row, i) => ( setRows((prev) => prev.map((r, j) => j === i ? { ...r, key: e.target.value } : r))} size="small" fullWidth /> setRows((prev) => prev.map((r, j) => j === i ? { ...r, value: e.target.value } : r))} size="small" fullWidth /> setRows((prev) => prev.filter((_, j) => j !== i))} sx={{ mt: 0.5 }}> ))} )} ); } // ─── RulesPanel ─────────────────────────────────────────────────────────────── type RulesPanelProps = { prefix: "block" | "allow"; initial: GeoBlockSettings | null; }; function RulesPanel({ prefix, initial }: RulesPanelProps) { const accentColor = prefix === "block" ? "warning" : "success"; const countries = prefix === "block" ? (initial?.block_countries ?? []) : (initial?.allow_countries ?? []); const continents = prefix === "block" ? (initial?.block_continents ?? []) : (initial?.allow_continents ?? []); const asns = prefix === "block" ? (initial?.block_asns ?? []) : (initial?.allow_asns ?? []); const cidrs = prefix === "block" ? (initial?.block_cidrs ?? []) : (initial?.allow_cidrs ?? []); const ips = prefix === "block" ? (initial?.block_ips ?? []) : (initial?.allow_ips ?? []); return ( {/* Countries */} Countries {/* Continents */} Continents {/* ASNs */} /^\d+$/.test(v)} /> {/* CIDRs + IPs */} ); } // ─── GeoBlockFields ─────────────────────────────────────────────────────────── type GeoBlockFieldsProps = { initialValues?: { geoblock: GeoBlockSettings | null; geoblock_mode: GeoBlockMode; }; showModeSelector?: boolean; }; export function GeoBlockFields({ initialValues, showModeSelector = true }: GeoBlockFieldsProps) { const initial = initialValues?.geoblock ?? null; const [enabled, setEnabled] = useState(initial?.enabled ?? false); const [mode, setMode] = useState(initialValues?.geoblock_mode ?? "merge"); const [rulesTab, setRulesTab] = useState(0); return ( theme.palette.mode === "dark" ? "rgba(237,108,2,0.06)" : "rgba(237,108,2,0.04)", p: 2 }} > {/* Header */} Geo Blocking Block or allow traffic by country, continent, ASN, CIDR, or IP setEnabled(checked)} sx={{ flexShrink: 0 }} /> {/* Mode selector */} {/* Detail fields */} {showModeSelector && ( <> {(["merge", "override"] as GeoBlockMode[]).map((v) => ( setMode(v)} sx={{ flex: 1, py: 0.75, px: 1.5, borderRadius: 1.5, border: "1.5px solid", borderColor: mode === v ? "warning.main" : "divider", bgcolor: mode === v ? (theme) => theme.palette.mode === "dark" ? "rgba(237,108,2,0.12)" : "rgba(237,108,2,0.08)" : "transparent", cursor: "pointer", textAlign: "center", transition: "all 0.15s ease", userSelect: "none", "&:hover": { borderColor: mode === v ? "warning.main" : "text.disabled", }, }} > {v === "merge" ? "Merge with global" : "Override global"} ))} )} {!showModeSelector && } {/* Block / Allow tabs */} setRulesTab(v)} variant="fullWidth" sx={{ mb: 2.5, "& .MuiTab-root": { textTransform: "none", fontWeight: 500 } }} > {/* Advanced: Trusted Proxies + Block Response */} } sx={{ minHeight: 44, "& .MuiAccordionSummary-content": { my: 0.5 } }}> Trusted Proxies & Block Response } label={Fail closed (block indeterminate IPs)} /> ); }