mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
534 lines
12 KiB
TypeScript
534 lines
12 KiB
TypeScript
// 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, NodeUpdaters } from "src/hooks/node-data"
|
|
import Popover from "src/ui/popover"
|
|
import SearchInput from "src/ui/search-input"
|
|
|
|
export default function ExitNodeSelector({
|
|
className,
|
|
node,
|
|
nodeUpdaters,
|
|
disabled,
|
|
}: {
|
|
className?: string
|
|
node: NodeData
|
|
nodeUpdaters: NodeUpdaters
|
|
disabled?: boolean
|
|
}) {
|
|
const [open, setOpen] = useState<boolean>(false)
|
|
const [selected, setSelected] = useState<ExitNode>(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
|
|
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
|
|
},
|
|
[nodeUpdaters, selected]
|
|
)
|
|
|
|
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 (
|
|
<Popover
|
|
open={disabled ? false : open}
|
|
onOpenChange={setOpen}
|
|
side="bottom"
|
|
sideOffset={5}
|
|
align="start"
|
|
alignOffset={8}
|
|
content={
|
|
<ExitNodeSelectorInner
|
|
node={node}
|
|
selected={selected}
|
|
onSelect={handleSelect}
|
|
/>
|
|
}
|
|
asChild
|
|
>
|
|
<div
|
|
className={cx(
|
|
"p-1.5 rounded-md border flex items-stretch gap-1.5",
|
|
{
|
|
"border-gray-200": none,
|
|
"bg-amber-600 border-amber-600": advertising,
|
|
"bg-blue-500 border-blue-500": using,
|
|
},
|
|
className
|
|
)}
|
|
>
|
|
<button
|
|
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
|
"bg-white hover:bg-stone-100": none,
|
|
"bg-amber-600 hover:bg-orange-400": advertising,
|
|
"bg-blue-500 hover:bg-blue-400": using,
|
|
"cursor-not-allowed": disabled,
|
|
})}
|
|
onClick={() => setOpen(!open)}
|
|
disabled={disabled}
|
|
>
|
|
<p
|
|
className={cx(
|
|
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
|
{ "bg-opacity-70 text-white": advertising || using }
|
|
)}
|
|
>
|
|
Exit node
|
|
</p>
|
|
<div className="flex items-center">
|
|
<p
|
|
className={cx("text-gray-800", {
|
|
"text-white": advertising || using,
|
|
})}
|
|
>
|
|
{selected.Location && (
|
|
<>
|
|
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
|
</>
|
|
)}
|
|
{selected === runAsExitNode
|
|
? "Running as exit node"
|
|
: selected.Name}
|
|
</p>
|
|
<ChevronDown
|
|
className={cx("ml-1", {
|
|
"stroke-neutral-800": none,
|
|
"stroke-white": advertising || using,
|
|
})}
|
|
/>
|
|
</div>
|
|
</button>
|
|
{(advertising || using) && (
|
|
<button
|
|
className={cx("px-3 py-2 rounded-sm text-white", {
|
|
"bg-orange-400": advertising,
|
|
"bg-blue-400": using,
|
|
"cursor-not-allowed": disabled,
|
|
})}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
handleSelect(noExitNode)
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
Disable
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function toSelectedExitNode(data: NodeData): ExitNode {
|
|
if (data.AdvertisingExitNode) {
|
|
return runAsExitNode
|
|
}
|
|
if (data.UsingExitNode) {
|
|
// TODO(sonia): also use online status
|
|
const node = { ...data.UsingExitNode }
|
|
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<string>("")
|
|
const { data: exitNodes } = useExitNodes(node, filter)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
const hasNodes = useMemo(
|
|
() => exitNodes.find((n) => n.nodes.length > 0),
|
|
[exitNodes]
|
|
)
|
|
|
|
return (
|
|
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
|
|
<SearchInput
|
|
name="exit-node-search"
|
|
inputClassName="w-full px-4 py-2"
|
|
autoCorrect="off"
|
|
autoComplete="off"
|
|
autoCapitalize="off"
|
|
placeholder="Search exit nodesโฆ"
|
|
value={filter}
|
|
onChange={(e) => {
|
|
// 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 */}
|
|
<div
|
|
ref={listRef}
|
|
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
|
|
>
|
|
{hasNodes ? (
|
|
exitNodes.map(
|
|
(group) =>
|
|
group.nodes.length > 0 && (
|
|
<div
|
|
key={group.id}
|
|
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
|
|
>
|
|
{group.name && (
|
|
<div className="px-4 py-2 text-gray-500 text-xs font-medium uppercase tracking-wide">
|
|
{group.name}
|
|
</div>
|
|
)}
|
|
{group.nodes.map((n) => (
|
|
<ExitNodeSelectorItem
|
|
key={`${n.ID}-${n.Name}`}
|
|
node={n}
|
|
onSelect={() => onSelect(n)}
|
|
isSelected={selected.ID === n.ID}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
)
|
|
) : (
|
|
<div className="text-center truncate text-gray-500 p-5">
|
|
{filter
|
|
? `No exit nodes matching โ${filter}โ`
|
|
: "No exit nodes available"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ExitNodeSelectorItem({
|
|
node,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
node: ExitNode
|
|
isSelected: boolean
|
|
onSelect: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
key={node.ID}
|
|
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
|
onClick={onSelect}
|
|
>
|
|
<div>
|
|
{node.Location && (
|
|
<>
|
|
<CountryFlag code={node.Location.CountryCode} />{" "}
|
|
</>
|
|
)}
|
|
<span className="leading-snug">{node.Name}</span>
|
|
</div>
|
|
{isSelected && <Check />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function CountryFlag({ code }: { code: string }) {
|
|
return (
|
|
<>{countryFlags[code.toLowerCase()]}</> || (
|
|
<span className="font-medium text-gray-500 text-xs">
|
|
{code.toUpperCase()}
|
|
</span>
|
|
)
|
|
)
|
|
}
|
|
|
|
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: "๐ฟ๐ผ",
|
|
}
|