client/web: add visual indication for exit node pending approval (#10532)

Add visual indication when running as an exit node prior to receiving
admin approval.

Updates https://github.com/tailscale/tailscale/issues/10261

Signed-off-by: Mario Minardi <mario@tailscale.com>
Co-authored-by: Sonia Appasamy <sonia@tailscale.com>
pull/10557/head
Mario Minardi 12 months ago committed by GitHub
parent e9f203d747
commit 763b9daa84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -92,7 +92,8 @@ export function useAPI() {
<MutateDataType, FetchDataType = any>( <MutateDataType, FetchDataType = any>(
key: string, key: string,
fetch: Promise<FetchDataType>, fetch: Promise<FetchDataType>,
optimisticData: (current: MutateDataType) => MutateDataType optimisticData: (current: MutateDataType) => MutateDataType,
revalidate?: boolean // optionally specify whether to run final revalidation (step 3)
): Promise<FetchDataType | undefined> => { ): Promise<FetchDataType | undefined> => {
const options: MutatorOptions = { const options: MutatorOptions = {
/** /**
@ -105,6 +106,7 @@ export function useAPI() {
*/ */
populateCache: false, populateCache: false,
optimisticData, optimisticData,
revalidate: revalidate,
} }
return mutate(key, fetch, options) return mutate(key, fetch, options)
}, },
@ -226,8 +228,12 @@ export function useAPI() {
...old, ...old,
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined, UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
AdvertisingExitNode: Boolean(body.AdvertiseExitNode), AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
AdvertisingExitNodeApproved: Boolean(body.AdvertiseExitNode)
? true // gets updated in revalidation
: old.AdvertisingExitNodeApproved,
} }
} },
false // skip final revalidation
) )
.then(() => metrics.forEach((m) => incrementMetric(m))) .then(() => metrics.forEach((m) => incrementMetric(m)))
.catch(handlePostError("Failed to update exit node")) .catch(handlePostError("Failed to update exit node"))

@ -14,6 +14,7 @@ import useExitNodes, {
import { ExitNode, NodeData } from "src/types" import { ExitNode, NodeData } from "src/types"
import Popover from "src/ui/popover" import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input" import SearchInput from "src/ui/search-input"
import { useSWRConfig } from "swr"
export default function ExitNodeSelector({ export default function ExitNodeSelector({
className, className,
@ -27,7 +28,14 @@ export default function ExitNodeSelector({
const api = useAPI() const api = useAPI()
const [open, setOpen] = useState<boolean>(false) const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node)) const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const [pending, setPending] = useState<boolean>(false)
const { mutate } = useSWRConfig() // allows for global mutation
useEffect(() => setSelected(toSelectedExitNode(node)), [node]) useEffect(() => setSelected(toSelectedExitNode(node)), [node])
useEffect(() => {
setPending(
node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
)
}, [node])
const handleSelect = useCallback( const handleSelect = useCallback(
(n: ExitNode) => { (n: ExitNode) => {
@ -35,9 +43,18 @@ export default function ExitNodeSelector({
if (n.ID === selected.ID) { if (n.ID === selected.ID) {
return // no update return // no update
} }
// Eager clear of pending state to avoid UI oddities
if (n.ID !== runAsExitNode.ID) {
setPending(false)
}
api({ action: "update-exit-node", data: n }) api({ action: "update-exit-node", data: n })
// refresh data after short timeout to pick up any pending approval updates
setTimeout(() => {
mutate("/data")
}, 1000)
}, },
[api, selected] [api, mutate, selected.ID]
) )
const [ const [
@ -52,7 +69,7 @@ export default function ExitNodeSelector({
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID, selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
!selected.Online, !selected.Online,
], ],
[selected] [selected.ID, selected.Online]
) )
return ( return (
@ -61,6 +78,7 @@ export default function ExitNodeSelector({
"rounded-md", "rounded-md",
{ {
"bg-red-600": offline, "bg-red-600": offline,
"bg-yellow-400": pending,
}, },
className className
)} )}
@ -160,6 +178,12 @@ export default function ExitNodeSelector({
blocked until you disable the exit node or select a different one. blocked until you disable the exit node or select a different one.
</p> </p>
)} )}
{pending && (
<p className="text-white p-3">
Pending approval to run as exit node. This device won't be usable as
an exit node until then.
</p>
)}
</div> </div>
) )
} }

@ -15,6 +15,7 @@ export type NodeData = {
KeyExpired: boolean KeyExpired: boolean
UsingExitNode?: ExitNode UsingExitNode?: ExitNode
AdvertisingExitNode: boolean AdvertisingExitNode: boolean
AdvertisingExitNodeApproved: boolean
AdvertisedRoutes?: SubnetRoute[] AdvertisedRoutes?: SubnetRoute[]
TUNMode: boolean TUNMode: boolean
IsSynology: boolean IsSynology: boolean

@ -591,6 +591,7 @@ type nodeData struct {
UsingExitNode *exitNode UsingExitNode *exitNode
AdvertisingExitNode bool AdvertisingExitNode bool
AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin
AdvertisedRoutes []subnetRoute // excludes exit node routes AdvertisedRoutes []subnetRoute // excludes exit node routes
RunningSSHServer bool RunningSSHServer bool
@ -693,6 +694,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
return p == route return p == route
}) })
} }
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
for _, r := range prefs.AdvertiseRoutes { for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 { if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertisingExitNode = true data.AdvertisingExitNode = true

Loading…
Cancel
Save