diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 5665ac19f..b8ec34a6c 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -68,7 +68,7 @@ function HomeView({ data: NodeData newSession: () => Promise refreshData: () => Promise - updateNode: (update: NodeUpdate) => void + updateNode: (update: NodeUpdate) => Promise | undefined }) { return ( <> @@ -80,7 +80,7 @@ function HomeView({ /> ) : data.DebugMode === "full" && auth?.ok ? ( // Render new client interface in management mode. - + ) : data.DebugMode === "login" || data.DebugMode === "full" ? ( // Render new client interface in readonly mode. diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx new file mode 100644 index 000000000..b4f39b680 --- /dev/null +++ b/client/web/src/components/exit-node-selector.tsx @@ -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 | undefined +}) { + const [open, setOpen] = useState(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 ( + <> +
+ + {(advertising || using) && ( + + )} +
+ {open && ( +
+
+ + +
+ +
+ )} + + ) +} + +function DropdownSection({ + items, + selected, + onSelect, +}: { + items: string[] + selected?: string + onSelect: (item: string) => void +}) { + return ( +
+ {items.map((v) => ( + + ))} +
+ ) +} diff --git a/client/web/src/components/views/management-client-view.tsx b/client/web/src/components/views/management-client-view.tsx index 9fcb822de..6bb76d223 100644 --- a/client/web/src/components/views/management-client-view.tsx +++ b/client/web/src/components/views/management-client-view.tsx @@ -1,12 +1,18 @@ import cx from "classnames" import React from "react" -import { NodeData } from "src/hooks/node-data" +import ExitNodeSelector from "src/components/exit-node-selector" +import { NodeData, NodeUpdate } from "src/hooks/node-data" import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg" -import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { Link } from "wouter" -export default function ManagementClientView(props: NodeData) { +export default function ManagementClientView({ + node, + updateNode, +}: { + node: NodeData + updateNode: (update: NodeUpdate) => Promise | undefined +}) { return (

This device

@@ -15,16 +21,20 @@ export default function ManagementClientView(props: NodeData) {
-

{props.DeviceName}

+

{node.DeviceName}

{/* TODO(sonia): display actual status */}

Connected

- {props.IP} + {node.IP}

- + -
-

- Exit node -

-
-

None

- -
-
- - ) -} - function SettingsCard({ title, link, diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 316c69b64..613f72d39 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -75,7 +75,7 @@ export default function useNodeData() { : data.AdvertiseExitNode, } - apiFetch("/data", "POST", update, { up: "true" }) + return apiFetch("/data", "POST", update, { up: "true" }) .then((r) => r.json()) .then((r) => { setIsPosting(false) @@ -89,7 +89,10 @@ export default function useNodeData() { } refreshData() }) - .catch((err) => alert("Failed operation: " + err.message)) + .catch((err) => { + alert("Failed operation: " + err.message) + throw err + }) }, [data] ) diff --git a/client/web/src/icons/check.svg b/client/web/src/icons/check.svg new file mode 100644 index 000000000..efa11685d --- /dev/null +++ b/client/web/src/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/web/src/icons/chevron-down.svg b/client/web/src/icons/chevron-down.svg index 7d32b1de8..afc98f255 100644 --- a/client/web/src/icons/chevron-down.svg +++ b/client/web/src/icons/chevron-down.svg @@ -1,3 +1,3 @@ - + diff --git a/client/web/src/icons/search.svg b/client/web/src/icons/search.svg new file mode 100644 index 000000000..782cd90ee --- /dev/null +++ b/client/web/src/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/index.css b/client/web/src/index.css index 4ebbf9ff3..0cd0aa15c 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -31,13 +31,6 @@ .card td:last-child { @apply text-neutral-800 text-sm leading-tight; } - - .hover-button { - @apply px-2 py-1.5 bg-white rounded-[1px] cursor-pointer; - } - .hover-button:hover { - @apply bg-stone-100; - } } /**