diff --git a/client/web/package.json b/client/web/package.json
index a545756df..e04c97f6c 100644
--- a/client/web/package.json
+++ b/client/web/package.json
@@ -13,7 +13,8 @@
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "wouter": "^2.11.0"
+ "wouter": "^2.11.0",
+ "zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.0.20",
diff --git a/client/web/src/assets/icons/copy.svg b/client/web/src/assets/icons/copy.svg
new file mode 100644
index 000000000..01b732081
--- /dev/null
+++ b/client/web/src/assets/icons/copy.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/client/web/src/assets/icons/x.svg b/client/web/src/assets/icons/x.svg
new file mode 100644
index 000000000..91984b30c
--- /dev/null
+++ b/client/web/src/assets/icons/x.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/client/web/src/components/address-copy-card.tsx b/client/web/src/components/address-copy-card.tsx
new file mode 100644
index 000000000..3ed582997
--- /dev/null
+++ b/client/web/src/components/address-copy-card.tsx
@@ -0,0 +1,131 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import * as Primitive from "@radix-ui/react-popover"
+import cx from "classnames"
+import React, { useCallback } from "react"
+import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
+import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
+import NiceIP from "src/components/nice-ip"
+import useToaster from "src/hooks/toaster"
+import Button from "src/ui/button"
+import { copyText } from "src/utils/clipboard"
+
+/**
+ * AddressCard renders a clickable IP address text that opens a
+ * dialog with a copyable list of all addresses (IPv4, IPv6, DNS)
+ * for the machine.
+ */
+export default function AddressCard({
+ v4Address,
+ v6Address,
+ shortDomain,
+ fullDomain,
+ triggerClassName,
+}: {
+ v4Address: string
+ v6Address: string
+ shortDomain?: string
+ fullDomain?: string
+ triggerClassName?: string
+}) {
+ const children = (
+
+ {shortDomain && }
+ {fullDomain && }
+ {v4Address && (
+
+ )}
+ {v6Address && (
+
+ )}
+
+ )
+
+ return (
+
+
+
+ }
+ aria-label="See all addresses for this device."
+ >
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function AddressRow({
+ label,
+ value,
+ ip,
+}: {
+ label: string
+ value: string
+ ip?: boolean
+}) {
+ const toaster = useToaster()
+ const onCopyClick = useCallback(() => {
+ copyText(value)
+ .then(() => toaster.show({ message: `Copied ${label} to clipboard` }))
+ .catch(() =>
+ toaster.show({
+ message: `Failed to copy ${label} to clipboard`,
+ variant: "danger",
+ })
+ )
+ }, [label, toaster, value])
+
+ return (
+
+
+
+ {ip ? (
+
+ ) : (
+
{value}
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/client/web/src/components/login-toggle.tsx b/client/web/src/components/login-toggle.tsx
index a34d915b2..53b52bc98 100644
--- a/client/web/src/components/login-toggle.tsx
+++ b/client/web/src/components/login-toggle.tsx
@@ -105,13 +105,13 @@ function LoginPopoverContent({
return // already checking
}
setIsRunningCheck(true)
- fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" })
+ fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setIsRunningCheck(false)
setCanConnectOverTS(true)
})
.catch(() => setIsRunningCheck(false))
- }, [auth.viewerIdentity, isRunningCheck, node.IP])
+ }, [auth.viewerIdentity, isRunningCheck, node.IPv4])
/**
* Checking connection for first time on page load.
@@ -130,7 +130,7 @@ function LoginPopoverContent({
} else {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
- const manageURL = `http://${node.IP}:5252/?check=now`
+ const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// if we're inside an iframe, open management client in new window
window.open(manageURL, "_blank")
@@ -138,7 +138,7 @@ function LoginPopoverContent({
window.location.href = manageURL
}
}
- }, [node.IP, auth.viewerIdentity, newSession])
+ }, [node.IPv4, auth.viewerIdentity, newSession])
return (
diff --git a/client/web/src/components/nice-ip.tsx b/client/web/src/components/nice-ip.tsx
new file mode 100644
index 000000000..f00d763f9
--- /dev/null
+++ b/client/web/src/components/nice-ip.tsx
@@ -0,0 +1,65 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import cx from "classnames"
+import React from "react"
+import { isTailscaleIPv6 } from "src/utils/util"
+
+type Props = {
+ ip: string
+ className?: string
+}
+
+/**
+ * NiceIP displays IP addresses with nice truncation.
+ */
+export default function NiceIP(props: Props) {
+ const { ip, className } = props
+
+ if (!isTailscaleIPv6(ip)) {
+ return {ip}
+ }
+
+ const [trimmable, untrimmable] = splitIPv6(ip)
+
+ return (
+
+ {trimmable.length > 0 && (
+ {trimmable}
+ )}
+ {untrimmable}
+
+ )
+}
+
+/**
+ * Split an IPv6 address into two pieces, to help with truncating the middle.
+ * Only exported for testing purposes. Do not use.
+ */
+export function splitIPv6(ip: string): [string, string] {
+ // We want to split the IPv6 address into segments, but not remove the delimiter.
+ // So we inject an invalid IPv6 character ("|") as a delimiter into the string,
+ // then split on that.
+ const parts = ip.replace(/(:{1,2})/g, "|$1").split("|")
+
+ // Then we find the number of end parts that fits within the character limit,
+ // and join them back together.
+ const characterLimit = 12
+ let characterCount = 0
+ let idxFromEnd = 1
+ for (let i = parts.length - 1; i >= 0; i--) {
+ const part = parts[i]
+ if (characterCount + part.length > characterLimit) {
+ break
+ }
+ characterCount += part.length
+ idxFromEnd++
+ }
+
+ const start = parts.slice(0, -idxFromEnd).join("")
+ const end = parts.slice(-idxFromEnd).join("")
+
+ return [start, end]
+}
diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx
index efa912166..70b29c40c 100644
--- a/client/web/src/components/views/device-details-view.tsx
+++ b/client/web/src/components/views/device-details-view.tsx
@@ -6,9 +6,11 @@ import React from "react"
import { apiFetch } from "src/api"
import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
+import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
+import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter"
export default function DeviceDetailsView({
@@ -69,7 +71,14 @@ export default function DeviceDetailsView({
Machine name
- {node.DeviceName}
+
+
+ {node.DeviceName}
+
+
OS
@@ -77,7 +86,14 @@ export default function DeviceDetailsView({
ID
- {node.ID}
+
+
+ {node.ID}
+
+
Tailscale version
@@ -101,20 +117,46 @@ export default function DeviceDetailsView({
Tailscale IPv4
- {node.IP}
+
+
+ {node.IPv4}
+
+
Tailscale IPv6
- {node.IPv6}
+
+
+
+
+
Short domain
- {node.DeviceName}
+
+
+ {node.DeviceName}
+
+
Full domain
- {node.DeviceName}.{node.TailnetName}
+
+ {node.DeviceName}.{node.TailnetName}
+
@@ -125,7 +167,7 @@ export default function DeviceDetailsView({
node={node}
>
Want even more details? Visit{" "}
-
+
this device’s page
{" "}
in the admin console.
diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx
index 6f71b07f7..9a069727b 100644
--- a/client/web/src/components/views/home-view.tsx
+++ b/client/web/src/components/views/home-view.tsx
@@ -5,9 +5,10 @@ import cx from "classnames"
import React, { useMemo } from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
+import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
-import { pluralize } from "src/util"
+import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter"
export default function HomeView({
@@ -49,7 +50,13 @@ export default function HomeView({
- {node.IP}
+
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
diff --git a/client/web/src/components/views/subnet-router-view.tsx b/client/web/src/components/views/subnet-router-view.tsx
index e8febd9e4..fa20bc679 100644
--- a/client/web/src/components/views/subnet-router-view.tsx
+++ b/client/web/src/components/views/subnet-router-view.tsx
@@ -142,7 +142,7 @@ export default function SubnetRouterView({
node={node}
>
To approve routes, in the admin console go to{" "}
-
+
the machine’s route settings
.
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts
index a5825cac2..5cca13023 100644
--- a/client/web/src/hooks/node-data.ts
+++ b/client/web/src/hooks/node-data.ts
@@ -2,18 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useMemo, useState } from "react"
-import { apiFetch, setUnraidCsrfToken } from "src/api"
+import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
-import { assertNever } from "src/util"
-import { incrementMetric, MetricName } from "src/api"
+import { assertNever } from "src/utils/util"
export type NodeData = {
Profile: UserProfile
Status: NodeState
DeviceName: string
OS: string
- IP: string
+ IPv4: string
IPv6: string
ID: string
KeyExpiry: string
@@ -177,7 +176,11 @@ export default function useNodeData() {
const updateMetrics = () => {
// only update metrics if values have changed
if (data?.AdvertisingExitNode !== d.AdvertiseExitNode) {
- incrementMetric(d.AdvertiseExitNode ? "web_client_advertise_exitnode_enable" : "web_client_advertise_exitnode_disable")
+ incrementMetric(
+ d.AdvertiseExitNode
+ ? "web_client_advertise_exitnode_enable"
+ : "web_client_advertise_exitnode_disable"
+ )
}
}
diff --git a/client/web/src/hooks/toaster.ts b/client/web/src/hooks/toaster.ts
new file mode 100644
index 000000000..41fb4f42d
--- /dev/null
+++ b/client/web/src/hooks/toaster.ts
@@ -0,0 +1,17 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import { useRawToasterForHook } from "src/ui/toaster"
+
+/**
+ * useToaster provides a mechanism to display toasts. It returns an object with
+ * methods to show, dismiss, or clear all toasts:
+ *
+ * const toastKey = toaster.show({ message: "Hello world" })
+ * toaster.dismiss(toastKey)
+ * toaster.clear()
+ *
+ */
+const useToaster = useRawToasterForHook
+
+export default useToaster
diff --git a/client/web/src/index.css b/client/web/src/index.css
index b6da3af80..9740d043c 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -188,7 +188,7 @@
@apply text-gray-500 text-sm leading-tight truncate;
}
.card td:last-child {
- @apply col-span-2 text-gray-800 text-sm leading-tight truncate;
+ @apply col-span-2 text-gray-800 text-sm leading-tight;
}
.description {
diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx
index ef5a65a44..cbab009b7 100644
--- a/client/web/src/index.tsx
+++ b/client/web/src/index.tsx
@@ -11,6 +11,7 @@
import React from "react"
import { createRoot } from "react-dom/client"
import App from "src/components/app"
+import ToastProvider from "src/ui/toaster"
declare var window: any
// This is used to determine if the react client is built.
@@ -25,6 +26,8 @@ const root = createRoot(rootEl)
root.render(
-
+
+
+
)
diff --git a/client/web/src/ui/quick-copy.tsx b/client/web/src/ui/quick-copy.tsx
new file mode 100644
index 000000000..bc8f916c8
--- /dev/null
+++ b/client/web/src/ui/quick-copy.tsx
@@ -0,0 +1,160 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import cx from "classnames"
+import React, { useEffect, useRef, useState } from "react"
+import useToaster from "src/hooks/toaster"
+import { copyText } from "src/utils/clipboard"
+
+type Props = {
+ className?: string
+ hideAffordance?: boolean
+ /**
+ * primaryActionSubject is the subject of the toast confirmation message
+ * "Copied to clipboard"
+ */
+ primaryActionSubject: string
+ primaryActionValue: string
+ secondaryActionName?: string
+ secondaryActionValue?: string
+ /**
+ * secondaryActionSubject is the subject of the toast confirmation message
+ * prompted by the secondary action "Copied to clipboard"
+ */
+ secondaryActionSubject?: string
+ children?: React.ReactNode
+
+ /**
+ * onSecondaryAction is used to trigger events when the secondary copy
+ * function is used. It is not used when the secondary action is hidden.
+ */
+ onSecondaryAction?: () => void
+}
+
+/**
+ * QuickCopy is a UI component that allows for copying textual content in one click.
+ */
+export default function QuickCopy(props: Props) {
+ const {
+ className,
+ hideAffordance,
+ primaryActionSubject,
+ primaryActionValue,
+ secondaryActionValue,
+ secondaryActionName,
+ secondaryActionSubject,
+ onSecondaryAction,
+ children,
+ } = props
+ const toaster = useToaster()
+ const containerRef = useRef(null)
+ const buttonRef = useRef(null)
+ const [showButton, setShowButton] = useState(false)
+
+ useEffect(() => {
+ if (!showButton) {
+ return
+ }
+ if (!containerRef.current || !buttonRef.current) {
+ return
+ }
+ // We don't need to watch any `resize` event because it's pretty unlikely
+ // the browser will resize while their cursor is over one of these items.
+ const rect = containerRef.current.getBoundingClientRect()
+ const maximumPossibleWidth = window.innerWidth - rect.left + 4
+
+ // We add the border-width (1px * 2 sides) and the padding (0.5rem * 2 sides)
+ // and add 1px for rounding up the calculation in order to get the final
+ // maxWidth value. This should be kept in sync with the CSS classes below.
+ buttonRef.current.style.maxWidth = `${maximumPossibleWidth}px`
+ buttonRef.current.style.visibility = "visible"
+ }, [showButton])
+
+ const handlePrimaryAction = () => {
+ copyText(primaryActionValue)
+ toaster.show({
+ message: `Copied ${primaryActionSubject} to the clipboard`,
+ })
+ }
+
+ const handleSecondaryAction = () => {
+ if (!secondaryActionValue) {
+ return
+ }
+ copyText(secondaryActionValue)
+ toaster.show({
+ message: `Copied ${
+ secondaryActionSubject || secondaryActionName
+ } to the clipboard`,
+ })
+ onSecondaryAction?.()
+ }
+
+ return (
+ setShowButton(false)}
+ >
+
setShowButton(true)}
+ className={cx("truncate", className)}
+ >
+ {children}
+
+ {!hideAffordance && (
+
setShowButton(true)}
+ onClick={handlePrimaryAction}
+ className={cx("cursor-pointer text-blue-500", { "ml-2": children })}
+ >
+ Copy
+
+ )}
+
+ {showButton && (
+
+
+
+
+ {children}
+
+
+ Copy
+
+
+
+ {secondaryActionValue && (
+
+ {secondaryActionName}
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/client/web/src/ui/toaster.tsx b/client/web/src/ui/toaster.tsx
new file mode 100644
index 000000000..e6e877c83
--- /dev/null
+++ b/client/web/src/ui/toaster.tsx
@@ -0,0 +1,280 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import cx from "classnames"
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react"
+import { createPortal } from "react-dom"
+import { ReactComponent as X } from "src/assets/icons/x.svg"
+import { noop } from "src/utils/util"
+import { create } from "zustand"
+import { shallow } from "zustand/shallow"
+
+// Set up root element on the document body for toasts to render into.
+const root = document.createElement("div")
+root.id = "toast-root"
+root.classList.add("relative", "z-20")
+document.body.append(root)
+
+const toastSpacing = remToPixels(1)
+
+export type Toaster = {
+ clear: () => void
+ dismiss: (key: string) => void
+ show: (props: Toast) => string
+}
+
+type Toast = {
+ key?: string // key is a unique string value that ensures only one toast with a given key is shown at a time.
+ className?: string
+ variant?: "danger" // styling for the toast, undefined is neutral, danger is for failed requests
+ message: React.ReactNode
+ timeout?: number
+ added?: number // timestamp of when the toast was added
+}
+
+type ToastWithKey = Toast & { key: string }
+
+type State = {
+ toasts: ToastWithKey[]
+ maxToasts: number
+ clear: () => void
+ dismiss: (key: string) => void
+ show: (props: Toast) => string
+}
+
+const useToasterState = create((set, get) => ({
+ toasts: [],
+ maxToasts: 5,
+ clear: () => {
+ set({ toasts: [] })
+ },
+ dismiss: (key: string) => {
+ set((prev) => ({
+ toasts: prev.toasts.filter((t) => t.key !== key),
+ }))
+ },
+ show: (props: Toast) => {
+ const { toasts: prevToasts, maxToasts } = get()
+
+ const propsWithKey = {
+ key: Date.now().toString(),
+ ...props,
+ }
+ const prevIdx = prevToasts.findIndex((t) => t.key === propsWithKey.key)
+
+ // If the toast already exists, update it. Otherwise, append it.
+ const nextToasts =
+ prevIdx !== -1
+ ? [
+ ...prevToasts.slice(0, prevIdx),
+ propsWithKey,
+ ...prevToasts.slice(prevIdx + 1),
+ ]
+ : [...prevToasts, propsWithKey]
+
+ set({
+ // Get the last `maxToasts` toasts of the set.
+ toasts: nextToasts.slice(-maxToasts),
+ })
+ return propsWithKey.key
+ },
+}))
+
+const clearSelector = (state: State) => state.clear
+
+const toasterSelector = (state: State) => ({
+ show: state.show,
+ dismiss: state.dismiss,
+ clear: state.clear,
+})
+
+/**
+ * useRawToasterForHook is meant to supply the hook function for hooks/toaster.
+ * Use hooks/toaster instead.
+ */
+export const useRawToasterForHook = () =>
+ useToasterState(toasterSelector, shallow)
+
+type ToastProviderProps = {
+ children: React.ReactNode
+ canEscapeKeyClear?: boolean
+}
+
+/**
+ * ToastProvider is the top-level toaster component. It stores the toast state.
+ */
+export default function ToastProvider(props: ToastProviderProps) {
+ const { children, canEscapeKeyClear = true } = props
+ const clear = useToasterState(clearSelector)
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (!canEscapeKeyClear) {
+ return
+ }
+ if (e.key === "Esc" || e.key === "Escape") {
+ clear()
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [canEscapeKeyClear, clear])
+
+ return (
+ <>
+ {children}
+
+ >
+ )
+}
+
+const toastContainerSelector = (state: State) => ({
+ toasts: state.toasts,
+ dismiss: state.dismiss,
+})
+
+/**
+ * ToastContainer manages the positioning and animation for all currently
+ * displayed toasts. It should only be used by ToastProvider.
+ */
+function ToastContainer() {
+ const { toasts, dismiss } = useToasterState(toastContainerSelector, shallow)
+
+ const [prevToasts, setPrevToasts] = useState(toasts)
+ useEffect(() => setPrevToasts(toasts), [toasts])
+
+ const [refMap] = useState(() => new Map())
+ const getOffsetForToast = useCallback(
+ (key: string) => {
+ let offset = 0
+
+ let arr = toasts
+ let index = arr.findIndex((t) => t.key === key)
+ if (index === -1) {
+ arr = prevToasts
+ index = arr.findIndex((t) => t.key === key)
+ }
+
+ if (index === -1) {
+ return offset
+ }
+
+ for (let i = arr.length; i > index; i--) {
+ if (!arr[i]) {
+ continue
+ }
+ const ref = refMap.get(arr[i].key)
+ if (!ref) {
+ continue
+ }
+ offset -= ref.offsetHeight
+ offset -= toastSpacing
+ }
+ return offset
+ },
+ [refMap, prevToasts, toasts]
+ )
+
+ const toastsWithStyles = useMemo(
+ () =>
+ toasts.map((toast) => ({
+ toast: toast,
+ style: {
+ transform: `translateY(${getOffsetForToast(toast.key)}px) scale(1.0)`,
+ },
+ })),
+ [getOffsetForToast, toasts]
+ )
+
+ if (!root) {
+ throw new Error("Could not find toast root") // should never happen
+ }
+
+ return createPortal(
+
+ {toastsWithStyles.map(({ toast, style }) => (
+ ref && refMap.set(toast.key, ref)}
+ toast={toast}
+ onDismiss={dismiss}
+ style={style}
+ />
+ ))}
+
,
+ root
+ )
+}
+
+/**
+ * ToastBlock is the display of an individual toast, and also manages timeout
+ * settings for a particular toast.
+ */
+const ToastBlock = forwardRef<
+ HTMLDivElement,
+ {
+ toast: ToastWithKey
+ onDismiss?: (key: string) => void
+ style?: React.CSSProperties
+ }
+>(({ toast, onDismiss = noop, style }, ref) => {
+ const { message, key, timeout = 5000, variant } = toast
+
+ const [focused, setFocused] = useState(false)
+ const dismiss = useCallback(() => onDismiss(key), [onDismiss, key])
+ const onFocus = useCallback(() => setFocused(true), [])
+ const onBlur = useCallback(() => setFocused(false), [])
+
+ useEffect(() => {
+ if (timeout <= 0 || focused) {
+ return
+ }
+ const timerId = setTimeout(() => dismiss(), timeout)
+ return () => clearTimeout(timerId)
+ }, [dismiss, timeout, focused])
+
+ return (
+
+ {message}
+
+
+
+
+ )
+})
+
+function remToPixels(rem: number) {
+ return (
+ rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
+ )
+}
diff --git a/client/web/src/utils/clipboard.ts b/client/web/src/utils/clipboard.ts
new file mode 100644
index 000000000..f003bc240
--- /dev/null
+++ b/client/web/src/utils/clipboard.ts
@@ -0,0 +1,77 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import { isPromise } from "src/utils/util"
+
+/**
+ * copyText copies text to the clipboard, handling cross-browser compatibility
+ * issues with different clipboard APIs.
+ *
+ * To support copying after running a network request (eg. generating an invite),
+ * pass a promise that resolves to the text to copy.
+ *
+ * @example
+ * copyText("Hello, world!")
+ * copyText(generateInvite().then(res => res.data.inviteCode))
+ */
+export function copyText(text: string | Promise) {
+ if (!navigator.clipboard) {
+ if (isPromise(text)) {
+ return text.then((val) => fallbackCopy(validateString(val)))
+ }
+ return fallbackCopy(text)
+ }
+ if (isPromise(text)) {
+ if (typeof ClipboardItem === "undefined") {
+ return text.then((val) =>
+ navigator.clipboard.writeText(validateString(val))
+ )
+ }
+ return navigator.clipboard.write([
+ new ClipboardItem({
+ "text/plain": text.then(
+ (val) => new Blob([validateString(val)], { type: "text/plain" })
+ ),
+ }),
+ ])
+ }
+ return navigator.clipboard.writeText(text)
+}
+
+function validateString(val: unknown): string {
+ if (typeof val !== "string" || val.length === 0) {
+ throw new TypeError("Expected string, got " + typeof val)
+ }
+ if (val.length === 0) {
+ throw new TypeError("Expected non-empty string")
+ }
+ return val
+}
+
+function fallbackCopy(text: string) {
+ const el = document.createElement("textarea")
+ el.value = text
+ el.setAttribute("readonly", "")
+ el.className = "absolute opacity-0 pointer-events-none"
+ document.body.append(el)
+
+ // Check if text is currently selected
+ let selection = document.getSelection()
+ const selected =
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : false
+
+ el.select()
+ document.execCommand("copy")
+ el.remove()
+
+ // Restore selection
+ if (selected) {
+ selection = document.getSelection()
+ if (selection) {
+ selection.removeAllRanges()
+ selection.addRange(selected)
+ }
+ }
+
+ return Promise.resolve()
+}
diff --git a/client/web/src/util.ts b/client/web/src/utils/util.ts
similarity index 53%
rename from client/web/src/util.ts
rename to client/web/src/utils/util.ts
index fa5cbe3b8..e755f07ec 100644
--- a/client/web/src/util.ts
+++ b/client/web/src/utils/util.ts
@@ -9,6 +9,11 @@ export function assertNever(a: never): never {
return a
}
+/**
+ * noop is an empty function for use as a default value.
+ */
+export function noop() {}
+
/**
* pluralize is a very simple function that returns either
* the singular or plural form of a string based on the given
@@ -19,3 +24,21 @@ export function assertNever(a: never): never {
export function pluralize(signular: string, plural: string, qty: number) {
return qty === 1 ? signular : plural
}
+
+/**
+ * isTailscaleIPv6 returns true when the ip matches
+ * Tailnet's IPv6 format.
+ */
+export function isTailscaleIPv6(ip: string): boolean {
+ return ip.startsWith("fd7a:115c:a1e0")
+}
+
+/**
+ * isPromise returns whether the current value is a promise.
+ */
+export function isPromise(val: unknown): val is Promise {
+ if (!val) {
+ return false
+ }
+ return typeof val === "object" && "then" in val
+}
diff --git a/client/web/web.go b/client/web/web.go
index e17ab7fc2..812bfe9b1 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -536,7 +536,7 @@ type nodeData struct {
DeviceName string
TailnetName string // TLS cert name
DomainName string
- IP string // IPv4
+ IPv4 string
IPv6 string
OS string
IPNVersion string
@@ -630,11 +630,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
}
for _, ip := range st.TailscaleIPs {
if ip.Is4() {
- data.IP = ip.String()
+ data.IPv4 = ip.String()
} else if ip.Is6() {
data.IPv6 = ip.String()
}
- if data.IP != "" && data.IPv6 != "" {
+ if data.IPv4 != "" && data.IPv6 != "" {
break
}
}
diff --git a/client/web/yarn.lock b/client/web/yarn.lock
index efcabd8ab..30ee00096 100644
--- a/client/web/yarn.lock
+++ b/client/web/yarn.lock
@@ -4953,7 +4953,7 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
-use-sync-external-store@^1.0.0:
+use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
@@ -5148,3 +5148,10 @@ yocto-queue@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
+
+zustand@^4.4.7:
+ version "4.4.7"
+ resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.7.tgz#355406be6b11ab335f59a66d2cf9815e8f24038c"
+ integrity sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==
+ dependencies:
+ use-sync-external-store "1.2.0"