mirror of https://github.com/tailscale/tailscale/
client/web: add initial framework for exit node selector
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>pull/10153/head
parent
de2af54ffc
commit
5e095ddc20
@ -0,0 +1,171 @@
|
|||||||
|
import cx from "classnames"
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||||
|
import { ReactComponent as Check } from "src/icons/check.svg"
|
||||||
|
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
|
||||||
|
import { ReactComponent as Search } from "src/icons/search.svg"
|
||||||
|
|
||||||
|
const noExitNode = "None"
|
||||||
|
const runAsExitNode = "Run as exit node…"
|
||||||
|
|
||||||
|
export default function ExitNodeSelector({
|
||||||
|
className,
|
||||||
|
node,
|
||||||
|
updateNode,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
node: NodeData
|
||||||
|
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
|
const [selected, setSelected] = useState(
|
||||||
|
node.AdvertiseExitNode ? runAsExitNode : noExitNode
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(item: string) => {
|
||||||
|
setOpen(false)
|
||||||
|
if (item === selected) {
|
||||||
|
return // no update
|
||||||
|
}
|
||||||
|
const old = selected
|
||||||
|
setSelected(item)
|
||||||
|
var update: NodeUpdate = {}
|
||||||
|
switch (item) {
|
||||||
|
case noExitNode:
|
||||||
|
// turn off exit node
|
||||||
|
update = { AdvertiseExitNode: false }
|
||||||
|
break
|
||||||
|
case runAsExitNode:
|
||||||
|
// turn on exit node
|
||||||
|
update = { AdvertiseExitNode: true }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
updateNode(update)?.catch(() => setSelected(old))
|
||||||
|
},
|
||||||
|
[setOpen, selected, setSelected]
|
||||||
|
)
|
||||||
|
// TODO: close on click outside
|
||||||
|
// TODO(sonia): allow choosing to use another exit node
|
||||||
|
|
||||||
|
const [
|
||||||
|
none, // not using exit nodes
|
||||||
|
advertising, // advertising as exit node
|
||||||
|
using, // using another exit node
|
||||||
|
] = useMemo(
|
||||||
|
() => [
|
||||||
|
selected === noExitNode,
|
||||||
|
selected === runAsExitNode,
|
||||||
|
selected !== noExitNode && selected !== runAsExitNode,
|
||||||
|
],
|
||||||
|
[selected]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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-indigo-500 border-indigo-500": using,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={cx("flex-1 px-2 py-1.5 rounded-[1px] cursor-pointer", {
|
||||||
|
"bg-white hover:bg-stone-100": none,
|
||||||
|
"bg-amber-600 hover:bg-orange-400": advertising,
|
||||||
|
"bg-indigo-500 hover:bg-indigo-400": using,
|
||||||
|
})}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cx(
|
||||||
|
"text-neutral-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-neutral-800", {
|
||||||
|
"text-white": advertising || using,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{selected === runAsExitNode ? "Running as exit node" : "None"}
|
||||||
|
</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 cursor-pointer", {
|
||||||
|
"bg-orange-400": advertising,
|
||||||
|
"bg-indigo-400": using,
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleSelect(noExitNode)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
|
||||||
|
<div className="w-full px-4 py-2 flex items-center gap-2.5">
|
||||||
|
<Search />
|
||||||
|
<input
|
||||||
|
className="flex-1 leading-snug"
|
||||||
|
placeholder="Search exit nodes…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownSection
|
||||||
|
items={[noExitNode, runAsExitNode]}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownSection({
|
||||||
|
items,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: string[]
|
||||||
|
selected?: string
|
||||||
|
onSelect: (item: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full mt-1 pt-1 border-t border-gray-200">
|
||||||
|
{items.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
||||||
|
onClick={() => onSelect(v)}
|
||||||
|
>
|
||||||
|
<div className="leading-snug">{v}</div>
|
||||||
|
{selected == v && <Check />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 236 B |
@ -1,3 +1,3 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 203 B |
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 500 B |
Loading…
Reference in New Issue