// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause import cx from "classnames" import React, { useCallback, useMemo, useRef, useState } from "react" import { ReactComponent as Check } from "src/assets/icons/check.svg" import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg" import useExitNodes, { ExitNode, noExitNode, runAsExitNode, trimDNSSuffix, } from "src/hooks/exit-nodes" import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data" import Popover from "src/ui/popover" import SearchInput from "src/ui/search-input" export default function ExitNodeSelector({ className, node, updateNode, updatePrefs, disabled, }: { className?: string node: NodeData updateNode: (update: NodeUpdate) => Promise | undefined updatePrefs: (p: PrefsUpdate) => Promise disabled?: boolean }) { const [open, setOpen] = useState(false) const [selected, setSelected] = useState(toSelectedExitNode(node)) const handleSelect = useCallback( (n: ExitNode) => { setOpen(false) if (n.ID === selected.ID) { return // no update } const old = selected setSelected(n) // optimistic UI update const reset = () => setSelected(old) switch (n.ID) { case noExitNode.ID: { if (old === runAsExitNode) { // stop advertising as exit node updateNode({ AdvertiseExitNode: false })?.catch(reset) } else { // stop using exit node updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset) } break } case runAsExitNode.ID: { const update = () => updateNode({ AdvertiseExitNode: true })?.catch(reset) if (old !== noExitNode) { // stop using exit node first updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }) .catch(reset) .then(update) } else { update() } break } default: { const update = () => updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset) if (old === runAsExitNode) { // stop advertising as exit node first updateNode({ AdvertiseExitNode: false })?.catch(reset).then(update) } else { update() } } } }, [setOpen, selected, setSelected] ) const [ none, // not using exit nodes advertising, // advertising as exit node using, // using another exit node ] = useMemo( () => [ selected.ID === noExitNode.ID, selected.ID === runAsExitNode.ID, selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID, ], [selected] ) return ( } asChild >
{(advertising || using) && ( )}
) } function toSelectedExitNode(data: NodeData): ExitNode { if (data.AdvertiseExitNode) { return runAsExitNode } if (data.ExitNodeStatus) { // TODO(sonia): also use online status const node = { ...data.ExitNodeStatus } if (node.Location) { // For mullvad nodes, use location as name. node.Name = `${node.Location.Country}: ${node.Location.City}` } else { // Otherwise use node name w/o DNS suffix. node.Name = trimDNSSuffix(node.Name, data.TailnetName) } return node } return noExitNode } function ExitNodeSelectorInner({ node, selected, onSelect, }: { node: NodeData selected: ExitNode onSelect: (node: ExitNode) => void }) { const [filter, setFilter] = useState("") const { data: exitNodes } = useExitNodes(node.TailnetName, filter) const listRef = useRef(null) const hasNodes = useMemo( () => exitNodes.find((n) => n.nodes.length > 0), [exitNodes] ) return (
{ // Jump list to top when search value changes. listRef.current?.scrollTo(0, 0) setFilter(e.target.value) }} /> {/* TODO(sonia): use loading spinner when loading useExitNodes */}
{hasNodes ? ( exitNodes.map( (group) => group.nodes.length > 0 && (
{group.name && (
{group.name}
)} {group.nodes.map((n) => ( onSelect(n)} isSelected={selected.ID == n.ID} /> ))}
) ) ) : (
{filter ? `No exit nodes matching โ€œ${filter}โ€` : "No exit nodes available"}
)}
) } function ExitNodeSelectorItem({ node, isSelected, onSelect, }: { node: ExitNode isSelected: boolean onSelect: () => void }) { return ( ) } function CountryFlag({ code }: { code: string }) { return ( countryFlags[code.toLowerCase()] || ( {code.toUpperCase()} ) ) } const countryFlags: { [countryCode: string]: string } = { ad: "๐Ÿ‡ฆ๐Ÿ‡ฉ", ae: "๐Ÿ‡ฆ๐Ÿ‡ช", af: "๐Ÿ‡ฆ๐Ÿ‡ซ", ag: "๐Ÿ‡ฆ๐Ÿ‡ฌ", ai: "๐Ÿ‡ฆ๐Ÿ‡ฎ", al: "๐Ÿ‡ฆ๐Ÿ‡ฑ", am: "๐Ÿ‡ฆ๐Ÿ‡ฒ", ao: "๐Ÿ‡ฆ๐Ÿ‡ด", aq: "๐Ÿ‡ฆ๐Ÿ‡ถ", ar: "๐Ÿ‡ฆ๐Ÿ‡ท", as: "๐Ÿ‡ฆ๐Ÿ‡ธ", at: "๐Ÿ‡ฆ๐Ÿ‡น", au: "๐Ÿ‡ฆ๐Ÿ‡บ", aw: "๐Ÿ‡ฆ๐Ÿ‡ผ", ax: "๐Ÿ‡ฆ๐Ÿ‡ฝ", az: "๐Ÿ‡ฆ๐Ÿ‡ฟ", ba: "๐Ÿ‡ง๐Ÿ‡ฆ", bb: "๐Ÿ‡ง๐Ÿ‡ง", bd: "๐Ÿ‡ง๐Ÿ‡ฉ", be: "๐Ÿ‡ง๐Ÿ‡ช", bf: "๐Ÿ‡ง๐Ÿ‡ซ", bg: "๐Ÿ‡ง๐Ÿ‡ฌ", bh: "๐Ÿ‡ง๐Ÿ‡ญ", bi: "๐Ÿ‡ง๐Ÿ‡ฎ", bj: "๐Ÿ‡ง๐Ÿ‡ฏ", bl: "๐Ÿ‡ง๐Ÿ‡ฑ", bm: "๐Ÿ‡ง๐Ÿ‡ฒ", bn: "๐Ÿ‡ง๐Ÿ‡ณ", bo: "๐Ÿ‡ง๐Ÿ‡ด", bq: "๐Ÿ‡ง๐Ÿ‡ถ", br: "๐Ÿ‡ง๐Ÿ‡ท", bs: "๐Ÿ‡ง๐Ÿ‡ธ", bt: "๐Ÿ‡ง๐Ÿ‡น", bv: "๐Ÿ‡ง๐Ÿ‡ป", bw: "๐Ÿ‡ง๐Ÿ‡ผ", by: "๐Ÿ‡ง๐Ÿ‡พ", bz: "๐Ÿ‡ง๐Ÿ‡ฟ", ca: "๐Ÿ‡จ๐Ÿ‡ฆ", cc: "๐Ÿ‡จ๐Ÿ‡จ", cd: "๐Ÿ‡จ๐Ÿ‡ฉ", cf: "๐Ÿ‡จ๐Ÿ‡ซ", cg: "๐Ÿ‡จ๐Ÿ‡ฌ", ch: "๐Ÿ‡จ๐Ÿ‡ญ", ci: "๐Ÿ‡จ๐Ÿ‡ฎ", ck: "๐Ÿ‡จ๐Ÿ‡ฐ", cl: "๐Ÿ‡จ๐Ÿ‡ฑ", cm: "๐Ÿ‡จ๐Ÿ‡ฒ", cn: "๐Ÿ‡จ๐Ÿ‡ณ", co: "๐Ÿ‡จ๐Ÿ‡ด", cr: "๐Ÿ‡จ๐Ÿ‡ท", cu: "๐Ÿ‡จ๐Ÿ‡บ", cv: "๐Ÿ‡จ๐Ÿ‡ป", cw: "๐Ÿ‡จ๐Ÿ‡ผ", cx: "๐Ÿ‡จ๐Ÿ‡ฝ", cy: "๐Ÿ‡จ๐Ÿ‡พ", cz: "๐Ÿ‡จ๐Ÿ‡ฟ", de: "๐Ÿ‡ฉ๐Ÿ‡ช", dj: "๐Ÿ‡ฉ๐Ÿ‡ฏ", dk: "๐Ÿ‡ฉ๐Ÿ‡ฐ", dm: "๐Ÿ‡ฉ๐Ÿ‡ฒ", do: "๐Ÿ‡ฉ๐Ÿ‡ด", dz: "๐Ÿ‡ฉ๐Ÿ‡ฟ", ec: "๐Ÿ‡ช๐Ÿ‡จ", ee: "๐Ÿ‡ช๐Ÿ‡ช", eg: "๐Ÿ‡ช๐Ÿ‡ฌ", eh: "๐Ÿ‡ช๐Ÿ‡ญ", er: "๐Ÿ‡ช๐Ÿ‡ท", es: "๐Ÿ‡ช๐Ÿ‡ธ", et: "๐Ÿ‡ช๐Ÿ‡น", eu: "๐Ÿ‡ช๐Ÿ‡บ", fi: "๐Ÿ‡ซ๐Ÿ‡ฎ", fj: "๐Ÿ‡ซ๐Ÿ‡ฏ", fk: "๐Ÿ‡ซ๐Ÿ‡ฐ", fm: "๐Ÿ‡ซ๐Ÿ‡ฒ", fo: "๐Ÿ‡ซ๐Ÿ‡ด", fr: "๐Ÿ‡ซ๐Ÿ‡ท", ga: "๐Ÿ‡ฌ๐Ÿ‡ฆ", gb: "๐Ÿ‡ฌ๐Ÿ‡ง", gd: "๐Ÿ‡ฌ๐Ÿ‡ฉ", ge: "๐Ÿ‡ฌ๐Ÿ‡ช", gf: "๐Ÿ‡ฌ๐Ÿ‡ซ", gg: "๐Ÿ‡ฌ๐Ÿ‡ฌ", gh: "๐Ÿ‡ฌ๐Ÿ‡ญ", gi: "๐Ÿ‡ฌ๐Ÿ‡ฎ", gl: "๐Ÿ‡ฌ๐Ÿ‡ฑ", gm: "๐Ÿ‡ฌ๐Ÿ‡ฒ", gn: "๐Ÿ‡ฌ๐Ÿ‡ณ", gp: "๐Ÿ‡ฌ๐Ÿ‡ต", gq: "๐Ÿ‡ฌ๐Ÿ‡ถ", gr: "๐Ÿ‡ฌ๐Ÿ‡ท", gs: "๐Ÿ‡ฌ๐Ÿ‡ธ", gt: "๐Ÿ‡ฌ๐Ÿ‡น", gu: "๐Ÿ‡ฌ๐Ÿ‡บ", gw: "๐Ÿ‡ฌ๐Ÿ‡ผ", gy: "๐Ÿ‡ฌ๐Ÿ‡พ", hk: "๐Ÿ‡ญ๐Ÿ‡ฐ", hm: "๐Ÿ‡ญ๐Ÿ‡ฒ", hn: "๐Ÿ‡ญ๐Ÿ‡ณ", hr: "๐Ÿ‡ญ๐Ÿ‡ท", ht: "๐Ÿ‡ญ๐Ÿ‡น", hu: "๐Ÿ‡ญ๐Ÿ‡บ", id: "๐Ÿ‡ฎ๐Ÿ‡ฉ", ie: "๐Ÿ‡ฎ๐Ÿ‡ช", il: "๐Ÿ‡ฎ๐Ÿ‡ฑ", im: "๐Ÿ‡ฎ๐Ÿ‡ฒ", in: "๐Ÿ‡ฎ๐Ÿ‡ณ", io: "๐Ÿ‡ฎ๐Ÿ‡ด", iq: "๐Ÿ‡ฎ๐Ÿ‡ถ", ir: "๐Ÿ‡ฎ๐Ÿ‡ท", is: "๐Ÿ‡ฎ๐Ÿ‡ธ", it: "๐Ÿ‡ฎ๐Ÿ‡น", je: "๐Ÿ‡ฏ๐Ÿ‡ช", jm: "๐Ÿ‡ฏ๐Ÿ‡ฒ", jo: "๐Ÿ‡ฏ๐Ÿ‡ด", jp: "๐Ÿ‡ฏ๐Ÿ‡ต", ke: "๐Ÿ‡ฐ๐Ÿ‡ช", kg: "๐Ÿ‡ฐ๐Ÿ‡ฌ", kh: "๐Ÿ‡ฐ๐Ÿ‡ญ", ki: "๐Ÿ‡ฐ๐Ÿ‡ฎ", km: "๐Ÿ‡ฐ๐Ÿ‡ฒ", kn: "๐Ÿ‡ฐ๐Ÿ‡ณ", kp: "๐Ÿ‡ฐ๐Ÿ‡ต", kr: "๐Ÿ‡ฐ๐Ÿ‡ท", kw: "๐Ÿ‡ฐ๐Ÿ‡ผ", ky: "๐Ÿ‡ฐ๐Ÿ‡พ", kz: "๐Ÿ‡ฐ๐Ÿ‡ฟ", la: "๐Ÿ‡ฑ๐Ÿ‡ฆ", lb: "๐Ÿ‡ฑ๐Ÿ‡ง", lc: "๐Ÿ‡ฑ๐Ÿ‡จ", li: "๐Ÿ‡ฑ๐Ÿ‡ฎ", lk: "๐Ÿ‡ฑ๐Ÿ‡ฐ", lr: "๐Ÿ‡ฑ๐Ÿ‡ท", ls: "๐Ÿ‡ฑ๐Ÿ‡ธ", lt: "๐Ÿ‡ฑ๐Ÿ‡น", lu: "๐Ÿ‡ฑ๐Ÿ‡บ", lv: "๐Ÿ‡ฑ๐Ÿ‡ป", ly: "๐Ÿ‡ฑ๐Ÿ‡พ", ma: "๐Ÿ‡ฒ๐Ÿ‡ฆ", mc: "๐Ÿ‡ฒ๐Ÿ‡จ", md: "๐Ÿ‡ฒ๐Ÿ‡ฉ", me: "๐Ÿ‡ฒ๐Ÿ‡ช", mf: "๐Ÿ‡ฒ๐Ÿ‡ซ", mg: "๐Ÿ‡ฒ๐Ÿ‡ฌ", mh: "๐Ÿ‡ฒ๐Ÿ‡ญ", mk: "๐Ÿ‡ฒ๐Ÿ‡ฐ", ml: "๐Ÿ‡ฒ๐Ÿ‡ฑ", mm: "๐Ÿ‡ฒ๐Ÿ‡ฒ", mn: "๐Ÿ‡ฒ๐Ÿ‡ณ", mo: "๐Ÿ‡ฒ๐Ÿ‡ด", mp: "๐Ÿ‡ฒ๐Ÿ‡ต", mq: "๐Ÿ‡ฒ๐Ÿ‡ถ", mr: "๐Ÿ‡ฒ๐Ÿ‡ท", ms: "๐Ÿ‡ฒ๐Ÿ‡ธ", mt: "๐Ÿ‡ฒ๐Ÿ‡น", mu: "๐Ÿ‡ฒ๐Ÿ‡บ", mv: "๐Ÿ‡ฒ๐Ÿ‡ป", mw: "๐Ÿ‡ฒ๐Ÿ‡ผ", mx: "๐Ÿ‡ฒ๐Ÿ‡ฝ", my: "๐Ÿ‡ฒ๐Ÿ‡พ", mz: "๐Ÿ‡ฒ๐Ÿ‡ฟ", na: "๐Ÿ‡ณ๐Ÿ‡ฆ", nc: "๐Ÿ‡ณ๐Ÿ‡จ", ne: "๐Ÿ‡ณ๐Ÿ‡ช", nf: "๐Ÿ‡ณ๐Ÿ‡ซ", ng: "๐Ÿ‡ณ๐Ÿ‡ฌ", ni: "๐Ÿ‡ณ๐Ÿ‡ฎ", nl: "๐Ÿ‡ณ๐Ÿ‡ฑ", no: "๐Ÿ‡ณ๐Ÿ‡ด", np: "๐Ÿ‡ณ๐Ÿ‡ต", nr: "๐Ÿ‡ณ๐Ÿ‡ท", nu: "๐Ÿ‡ณ๐Ÿ‡บ", nz: "๐Ÿ‡ณ๐Ÿ‡ฟ", om: "๐Ÿ‡ด๐Ÿ‡ฒ", pa: "๐Ÿ‡ต๐Ÿ‡ฆ", pe: "๐Ÿ‡ต๐Ÿ‡ช", pf: "๐Ÿ‡ต๐Ÿ‡ซ", pg: "๐Ÿ‡ต๐Ÿ‡ฌ", ph: "๐Ÿ‡ต๐Ÿ‡ญ", pk: "๐Ÿ‡ต๐Ÿ‡ฐ", pl: "๐Ÿ‡ต๐Ÿ‡ฑ", pm: "๐Ÿ‡ต๐Ÿ‡ฒ", pn: "๐Ÿ‡ต๐Ÿ‡ณ", pr: "๐Ÿ‡ต๐Ÿ‡ท", ps: "๐Ÿ‡ต๐Ÿ‡ธ", pt: "๐Ÿ‡ต๐Ÿ‡น", pw: "๐Ÿ‡ต๐Ÿ‡ผ", py: "๐Ÿ‡ต๐Ÿ‡พ", qa: "๐Ÿ‡ถ๐Ÿ‡ฆ", re: "๐Ÿ‡ท๐Ÿ‡ช", ro: "๐Ÿ‡ท๐Ÿ‡ด", rs: "๐Ÿ‡ท๐Ÿ‡ธ", ru: "๐Ÿ‡ท๐Ÿ‡บ", rw: "๐Ÿ‡ท๐Ÿ‡ผ", sa: "๐Ÿ‡ธ๐Ÿ‡ฆ", sb: "๐Ÿ‡ธ๐Ÿ‡ง", sc: "๐Ÿ‡ธ๐Ÿ‡จ", sd: "๐Ÿ‡ธ๐Ÿ‡ฉ", se: "๐Ÿ‡ธ๐Ÿ‡ช", sg: "๐Ÿ‡ธ๐Ÿ‡ฌ", sh: "๐Ÿ‡ธ๐Ÿ‡ญ", si: "๐Ÿ‡ธ๐Ÿ‡ฎ", sj: "๐Ÿ‡ธ๐Ÿ‡ฏ", sk: "๐Ÿ‡ธ๐Ÿ‡ฐ", sl: "๐Ÿ‡ธ๐Ÿ‡ฑ", sm: "๐Ÿ‡ธ๐Ÿ‡ฒ", sn: "๐Ÿ‡ธ๐Ÿ‡ณ", so: "๐Ÿ‡ธ๐Ÿ‡ด", sr: "๐Ÿ‡ธ๐Ÿ‡ท", ss: "๐Ÿ‡ธ๐Ÿ‡ธ", st: "๐Ÿ‡ธ๐Ÿ‡น", sv: "๐Ÿ‡ธ๐Ÿ‡ป", sx: "๐Ÿ‡ธ๐Ÿ‡ฝ", sy: "๐Ÿ‡ธ๐Ÿ‡พ", sz: "๐Ÿ‡ธ๐Ÿ‡ฟ", tc: "๐Ÿ‡น๐Ÿ‡จ", td: "๐Ÿ‡น๐Ÿ‡ฉ", tf: "๐Ÿ‡น๐Ÿ‡ซ", tg: "๐Ÿ‡น๐Ÿ‡ฌ", th: "๐Ÿ‡น๐Ÿ‡ญ", tj: "๐Ÿ‡น๐Ÿ‡ฏ", tk: "๐Ÿ‡น๐Ÿ‡ฐ", tl: "๐Ÿ‡น๐Ÿ‡ฑ", tm: "๐Ÿ‡น๐Ÿ‡ฒ", tn: "๐Ÿ‡น๐Ÿ‡ณ", to: "๐Ÿ‡น๐Ÿ‡ด", tr: "๐Ÿ‡น๐Ÿ‡ท", tt: "๐Ÿ‡น๐Ÿ‡น", tv: "๐Ÿ‡น๐Ÿ‡ป", tw: "๐Ÿ‡น๐Ÿ‡ผ", tz: "๐Ÿ‡น๐Ÿ‡ฟ", ua: "๐Ÿ‡บ๐Ÿ‡ฆ", ug: "๐Ÿ‡บ๐Ÿ‡ฌ", um: "๐Ÿ‡บ๐Ÿ‡ฒ", us: "๐Ÿ‡บ๐Ÿ‡ธ", uy: "๐Ÿ‡บ๐Ÿ‡พ", uz: "๐Ÿ‡บ๐Ÿ‡ฟ", va: "๐Ÿ‡ป๐Ÿ‡ฆ", vc: "๐Ÿ‡ป๐Ÿ‡จ", ve: "๐Ÿ‡ป๐Ÿ‡ช", vg: "๐Ÿ‡ป๐Ÿ‡ฌ", vi: "๐Ÿ‡ป๐Ÿ‡ฎ", vn: "๐Ÿ‡ป๐Ÿ‡ณ", vu: "๐Ÿ‡ป๐Ÿ‡บ", wf: "๐Ÿ‡ผ๐Ÿ‡ซ", ws: "๐Ÿ‡ผ๐Ÿ‡ธ", xk: "๐Ÿ‡ฝ๐Ÿ‡ฐ", ye: "๐Ÿ‡พ๐Ÿ‡ช", yt: "๐Ÿ‡พ๐Ÿ‡น", za: "๐Ÿ‡ฟ๐Ÿ‡ฆ", zm: "๐Ÿ‡ฟ๐Ÿ‡ฒ", zw: "๐Ÿ‡ฟ๐Ÿ‡ผ", }