diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 74bdf9176..9414e2d5d 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -92,7 +92,8 @@ export function useAPI() { ( key: string, fetch: Promise, - optimisticData: (current: MutateDataType) => MutateDataType + optimisticData: (current: MutateDataType) => MutateDataType, + revalidate?: boolean // optionally specify whether to run final revalidation (step 3) ): Promise => { const options: MutatorOptions = { /** @@ -105,6 +106,7 @@ export function useAPI() { */ populateCache: false, optimisticData, + revalidate: revalidate, } return mutate(key, fetch, options) }, @@ -226,8 +228,12 @@ export function useAPI() { ...old, UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined, 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))) .catch(handlePostError("Failed to update exit node")) diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index aa8eef018..3edfc5d08 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -14,6 +14,7 @@ import useExitNodes, { import { ExitNode, NodeData } from "src/types" import Popover from "src/ui/popover" import SearchInput from "src/ui/search-input" +import { useSWRConfig } from "swr" export default function ExitNodeSelector({ className, @@ -27,7 +28,14 @@ export default function ExitNodeSelector({ const api = useAPI() const [open, setOpen] = useState(false) const [selected, setSelected] = useState(toSelectedExitNode(node)) + const [pending, setPending] = useState(false) + const { mutate } = useSWRConfig() // allows for global mutation useEffect(() => setSelected(toSelectedExitNode(node)), [node]) + useEffect(() => { + setPending( + node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false + ) + }, [node]) const handleSelect = useCallback( (n: ExitNode) => { @@ -35,9 +43,18 @@ export default function ExitNodeSelector({ if (n.ID === selected.ID) { 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 }) + + // refresh data after short timeout to pick up any pending approval updates + setTimeout(() => { + mutate("/data") + }, 1000) }, - [api, selected] + [api, mutate, selected.ID] ) const [ @@ -52,7 +69,7 @@ export default function ExitNodeSelector({ selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID, !selected.Online, ], - [selected] + [selected.ID, selected.Online] ) return ( @@ -61,6 +78,7 @@ export default function ExitNodeSelector({ "rounded-md", { "bg-red-600": offline, + "bg-yellow-400": pending, }, className )} @@ -160,6 +178,12 @@ export default function ExitNodeSelector({ blocked until you disable the exit node or select a different one.

)} + {pending && ( +

+ Pending approval to run as exit node. This device won't be usable as + an exit node until then. +

+ )} ) } diff --git a/client/web/src/types.ts b/client/web/src/types.ts index bd72935ee..62fa4c59f 100644 --- a/client/web/src/types.ts +++ b/client/web/src/types.ts @@ -15,6 +15,7 @@ export type NodeData = { KeyExpired: boolean UsingExitNode?: ExitNode AdvertisingExitNode: boolean + AdvertisingExitNodeApproved: boolean AdvertisedRoutes?: SubnetRoute[] TUNMode: boolean IsSynology: boolean diff --git a/client/web/web.go b/client/web/web.go index ebb1f1f39..956d3defa 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -589,10 +589,11 @@ type nodeData struct { UnraidToken string URLPrefix string // if set, the URL prefix the client is served behind - UsingExitNode *exitNode - AdvertisingExitNode bool - AdvertisedRoutes []subnetRoute // excludes exit node routes - RunningSSHServer bool + UsingExitNode *exitNode + AdvertisingExitNode bool + AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin + AdvertisedRoutes []subnetRoute // excludes exit node routes + RunningSSHServer bool ClientVersion *tailcfg.ClientVersion @@ -693,6 +694,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { return p == route }) } + data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6) + for _, r := range prefs.AdvertiseRoutes { if r == exitNodeRouteV4 || r == exitNodeRouteV6 { data.AdvertisingExitNode = true