|
@@ -2,15 +2,17 @@
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
import cx from "classnames"
|
|
import cx from "classnames"
|
|
|
-import React, { useCallback, useEffect, useState } from "react"
|
|
|
|
|
|
|
+import React, { useCallback, useMemo, useState } from "react"
|
|
|
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
|
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
|
|
import Eye from "src/assets/icons/eye.svg?react"
|
|
import Eye from "src/assets/icons/eye.svg?react"
|
|
|
import User from "src/assets/icons/user.svg?react"
|
|
import User from "src/assets/icons/user.svg?react"
|
|
|
-import { AuthResponse, AuthType } from "src/hooks/auth"
|
|
|
|
|
|
|
+import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
|
|
|
|
|
+import { useTSWebConnected } from "src/hooks/ts-web-connected"
|
|
|
import { NodeData } from "src/types"
|
|
import { NodeData } from "src/types"
|
|
|
import Button from "src/ui/button"
|
|
import Button from "src/ui/button"
|
|
|
import Popover from "src/ui/popover"
|
|
import Popover from "src/ui/popover"
|
|
|
import ProfilePic from "src/ui/profile-pic"
|
|
import ProfilePic from "src/ui/profile-pic"
|
|
|
|
|
+import { assertNever, isHTTPS } from "src/utils/util"
|
|
|
|
|
|
|
|
export default function LoginToggle({
|
|
export default function LoginToggle({
|
|
|
node,
|
|
node,
|
|
@@ -22,12 +24,29 @@ export default function LoginToggle({
|
|
|
newSession: () => Promise<void>
|
|
newSession: () => Promise<void>
|
|
|
}) {
|
|
}) {
|
|
|
const [open, setOpen] = useState<boolean>(false)
|
|
const [open, setOpen] = useState<boolean>(false)
|
|
|
|
|
+ const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
|
|
|
|
|
+ auth.serverMode,
|
|
|
|
|
+ node.IPv4
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<Popover
|
|
<Popover
|
|
|
- className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
|
|
|
|
|
|
|
+ className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
|
|
|
content={
|
|
content={
|
|
|
- <LoginPopoverContent node={node} auth={auth} newSession={newSession} />
|
|
|
|
|
|
|
+ auth.serverMode === "readonly" ? (
|
|
|
|
|
+ <ReadonlyModeContent auth={auth} />
|
|
|
|
|
+ ) : auth.serverMode === "login" ? (
|
|
|
|
|
+ <LoginModeContent
|
|
|
|
|
+ auth={auth}
|
|
|
|
|
+ node={node}
|
|
|
|
|
+ tsWebConnected={tsWebConnected}
|
|
|
|
|
+ checkTSWebConnection={checkTSWebConnection}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : auth.serverMode === "manage" ? (
|
|
|
|
|
+ <ManageModeContent auth={auth} node={node} newSession={newSession} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ assertNever(auth.serverMode)
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
side="bottom"
|
|
side="bottom"
|
|
|
align="end"
|
|
align="end"
|
|
@@ -35,231 +54,306 @@ export default function LoginToggle({
|
|
|
onOpenChange={setOpen}
|
|
onOpenChange={setOpen}
|
|
|
asChild
|
|
asChild
|
|
|
>
|
|
>
|
|
|
- {!auth.canManageNode ? (
|
|
|
|
|
- <button
|
|
|
|
|
- className={cx(
|
|
|
|
|
- "pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
|
|
|
|
- { "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
|
|
|
|
- )}
|
|
|
|
|
- onClick={() => setOpen(!open)}
|
|
|
|
|
- >
|
|
|
|
|
- <Eye />
|
|
|
|
|
- <div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
|
|
|
|
- <ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
|
|
|
|
- {auth.viewerIdentity && (
|
|
|
|
|
- <ProfilePic
|
|
|
|
|
- className="ml-2"
|
|
|
|
|
- size="medium"
|
|
|
|
|
- url={auth.viewerIdentity.profilePicUrl}
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- </button>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div
|
|
|
|
|
- className={cx(
|
|
|
|
|
- "w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
|
|
|
|
- {
|
|
|
|
|
- "bg-transparent": !open,
|
|
|
|
|
- "bg-gray-300": open,
|
|
|
|
|
- }
|
|
|
|
|
- )}
|
|
|
|
|
- >
|
|
|
|
|
- <button onClick={() => setOpen(!open)}>
|
|
|
|
|
- <ProfilePic
|
|
|
|
|
- size="medium"
|
|
|
|
|
- url={auth.viewerIdentity?.profilePicUrl}
|
|
|
|
|
- />
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {auth.authorized ? (
|
|
|
|
|
+ <TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</Popover>
|
|
</Popover>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function LoginPopoverContent({
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * TriggerWhenManaging is displayed as the trigger for the login popover
|
|
|
|
|
+ * when the user has an active authorized managment session.
|
|
|
|
|
+ */
|
|
|
|
|
+function TriggerWhenManaging({
|
|
|
|
|
+ auth,
|
|
|
|
|
+ open,
|
|
|
|
|
+ setOpen,
|
|
|
|
|
+}: {
|
|
|
|
|
+ auth: AuthResponse
|
|
|
|
|
+ open: boolean
|
|
|
|
|
+ setOpen: (next: boolean) => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={cx(
|
|
|
|
|
+ "w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
|
|
|
|
+ {
|
|
|
|
|
+ "bg-transparent": !open,
|
|
|
|
|
+ "bg-gray-300": open,
|
|
|
|
|
+ }
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ <button onClick={() => setOpen(!open)}>
|
|
|
|
|
+ <ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * TriggerWhenReading is displayed as the trigger for the login popover
|
|
|
|
|
+ * when the user is currently in read mode (doesn't have an authorized
|
|
|
|
|
+ * management session).
|
|
|
|
|
+ */
|
|
|
|
|
+function TriggerWhenReading({
|
|
|
|
|
+ auth,
|
|
|
|
|
+ open,
|
|
|
|
|
+ setOpen,
|
|
|
|
|
+}: {
|
|
|
|
|
+ auth: AuthResponse
|
|
|
|
|
+ open: boolean
|
|
|
|
|
+ setOpen: (next: boolean) => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ className={cx(
|
|
|
|
|
+ "pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
|
|
|
|
+ { "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
|
|
|
|
+ )}
|
|
|
|
|
+ onClick={() => setOpen(!open)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Eye />
|
|
|
|
|
+ <div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
|
|
|
|
+ <ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
|
|
|
|
+ {auth.viewerIdentity && (
|
|
|
|
|
+ <ProfilePic
|
|
|
|
|
+ className="ml-2"
|
|
|
|
|
+ size="medium"
|
|
|
|
|
+ url={auth.viewerIdentity.profilePicUrl}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * PopoverContentHeader is the header for the login popover.
|
|
|
|
|
+ */
|
|
|
|
|
+function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="text-black text-sm font-medium leading-tight mb-1">
|
|
|
|
|
+ {auth.authorized ? "Managing" : "Viewing"}
|
|
|
|
|
+ {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * PopoverContentFooter is the footer for the login popover.
|
|
|
|
|
+ */
|
|
|
|
|
+function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
|
|
|
|
|
+ return auth.viewerIdentity ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <hr className="my-2" />
|
|
|
|
|
+ <div className="flex items-center">
|
|
|
|
|
+ <User className="flex-shrink-0" />
|
|
|
|
|
+ <p className="text-gray-500 text-xs ml-2">
|
|
|
|
|
+ We recognize you because you are accessing this page from{" "}
|
|
|
|
|
+ <span className="font-medium">
|
|
|
|
|
+ {auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * ReadonlyModeContent is the body of the login popover when the web
|
|
|
|
|
+ * client is being run in "readonly" server mode.
|
|
|
|
|
+ */
|
|
|
|
|
+function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <PopoverContentHeader auth={auth} />
|
|
|
|
|
+ <p className="text-gray-500 text-xs">
|
|
|
|
|
+ This web interface is running in read-only mode.{" "}
|
|
|
|
|
+ <a
|
|
|
|
|
+ href="https://tailscale.com/s/web-client-read-only"
|
|
|
|
|
+ className="text-blue-700"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ Learn more →
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <PopoverContentFooter auth={auth} />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * LoginModeContent is the body of the login popover when the web
|
|
|
|
|
+ * client is being run in "login" server mode.
|
|
|
|
|
+ */
|
|
|
|
|
+function LoginModeContent({
|
|
|
node,
|
|
node,
|
|
|
auth,
|
|
auth,
|
|
|
- newSession,
|
|
|
|
|
|
|
+ tsWebConnected,
|
|
|
|
|
+ checkTSWebConnection,
|
|
|
}: {
|
|
}: {
|
|
|
node: NodeData
|
|
node: NodeData
|
|
|
auth: AuthResponse
|
|
auth: AuthResponse
|
|
|
- newSession: () => Promise<void>
|
|
|
|
|
|
|
+ tsWebConnected: boolean
|
|
|
|
|
+ checkTSWebConnection: () => void
|
|
|
}) {
|
|
}) {
|
|
|
- /**
|
|
|
|
|
- * canConnectOverTS indicates whether the current viewer
|
|
|
|
|
- * is able to hit the node's web client that's being served
|
|
|
|
|
- * at http://${node.IP}:5252. If false, this means that the
|
|
|
|
|
- * viewer must connect to the correct tailnet before being
|
|
|
|
|
- * able to sign in.
|
|
|
|
|
- */
|
|
|
|
|
- const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
|
|
|
|
|
- const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
|
|
|
|
|
-
|
|
|
|
|
- // Whether the current page is loaded over HTTPS.
|
|
|
|
|
- // If it is, then the connectivity check to the management client
|
|
|
|
|
- // will fail with a mixed-content error.
|
|
|
|
|
- const isHTTPS = window.location.protocol === "https:"
|
|
|
|
|
|
|
+ const https = isHTTPS()
|
|
|
|
|
+ // We can't run the ts web connection test when the webpage is loaded
|
|
|
|
|
+ // over HTTPS. So in this case, we default to presenting a login button
|
|
|
|
|
+ // with some helper text reminding the user to check their connection
|
|
|
|
|
+ // themselves.
|
|
|
|
|
+ const hasACLAccess = https || tsWebConnected
|
|
|
|
|
|
|
|
- const checkTSConnection = useCallback(() => {
|
|
|
|
|
- if (auth.viewerIdentity || isHTTPS) {
|
|
|
|
|
- // Skip the connectivity check if we either already know we're connected over Tailscale,
|
|
|
|
|
- // or know the connectivity check will fail because the current page is loaded over HTTPS.
|
|
|
|
|
- setCanConnectOverTS(true)
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ const hasEditCaps = useMemo(() => {
|
|
|
|
|
+ if (!auth.viewerIdentity) {
|
|
|
|
|
+ // If not connected to login client over tailscale, we won't know the viewer's
|
|
|
|
|
+ // identity. So we must assume they may be able to edit something and have the
|
|
|
|
|
+ // management client handle permissions once the user gets there.
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
- // Otherwise, test connection to the ts IP.
|
|
|
|
|
- if (isRunningCheck) {
|
|
|
|
|
- return // already checking
|
|
|
|
|
- }
|
|
|
|
|
- setIsRunningCheck(true)
|
|
|
|
|
- fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
|
|
|
|
|
- .then(() => {
|
|
|
|
|
- setCanConnectOverTS(true)
|
|
|
|
|
- setIsRunningCheck(false)
|
|
|
|
|
- })
|
|
|
|
|
- .catch(() => setIsRunningCheck(false))
|
|
|
|
|
- }, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * Checking connection for first time on page load.
|
|
|
|
|
- *
|
|
|
|
|
- * While not connected, we check again whenever the mouse
|
|
|
|
|
- * enters the popover component, to pick up on the user
|
|
|
|
|
- * leaving to turn on Tailscale then returning to the view.
|
|
|
|
|
- * See `onMouseEnter` on the div below.
|
|
|
|
|
- */
|
|
|
|
|
- // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
- useEffect(() => checkTSConnection(), [])
|
|
|
|
|
|
|
+ return hasAnyEditCapabilities(auth)
|
|
|
|
|
+ }, [auth])
|
|
|
|
|
|
|
|
- const handleSignInClick = useCallback(() => {
|
|
|
|
|
- if (auth.viewerIdentity && auth.serverMode === "manage") {
|
|
|
|
|
- if (window.self !== window.top) {
|
|
|
|
|
- // if we're inside an iframe, start session in new window
|
|
|
|
|
- let url = new URL(window.location.href)
|
|
|
|
|
- url.searchParams.set("check", "now")
|
|
|
|
|
- window.open(url, "_blank")
|
|
|
|
|
- } else {
|
|
|
|
|
- newSession()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const handleLogin = useCallback(() => {
|
|
|
|
|
+ // Must be connected over Tailscale to log in.
|
|
|
|
|
+ // Send user to Tailscale IP and start check mode
|
|
|
|
|
+ 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")
|
|
|
} else {
|
|
} else {
|
|
|
- // Must be connected over Tailscale to log in.
|
|
|
|
|
- // Send user to Tailscale IP and start check mode
|
|
|
|
|
- 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")
|
|
|
|
|
- } else {
|
|
|
|
|
- window.location.href = manageURL
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ window.location.href = manageURL
|
|
|
}
|
|
}
|
|
|
- }, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
|
|
|
|
|
|
|
+ }, [node.IPv4])
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
|
|
|
|
- <div className="text-black text-sm font-medium leading-tight mb-1">
|
|
|
|
|
- {!auth.canManageNode ? "Viewing" : "Managing"}
|
|
|
|
|
- {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
|
|
|
|
- </div>
|
|
|
|
|
- {!auth.canManageNode && (
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ onMouseEnter={
|
|
|
|
|
+ hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <PopoverContentHeader auth={auth} />
|
|
|
|
|
+ {!hasACLAccess || !hasEditCaps ? (
|
|
|
<>
|
|
<>
|
|
|
- {auth.serverMode === "readonly" ? (
|
|
|
|
|
- <p className="text-gray-500 text-xs">
|
|
|
|
|
- This web interface is running in read-only mode.{" "}
|
|
|
|
|
- <a
|
|
|
|
|
- href="https://tailscale.com/s/web-client-read-only"
|
|
|
|
|
- className="text-blue-700"
|
|
|
|
|
- target="_blank"
|
|
|
|
|
- rel="noreferrer"
|
|
|
|
|
- >
|
|
|
|
|
- Learn more →
|
|
|
|
|
- </a>
|
|
|
|
|
- </p>
|
|
|
|
|
- ) : !auth.viewerIdentity ? (
|
|
|
|
|
- // User is not connected over Tailscale.
|
|
|
|
|
- // These states are only possible on the login client.
|
|
|
|
|
- <>
|
|
|
|
|
- {!canConnectOverTS ? (
|
|
|
|
|
- <>
|
|
|
|
|
- <p className="text-gray-500 text-xs">
|
|
|
|
|
- {!node.ACLAllowsAnyIncomingTraffic ? (
|
|
|
|
|
- // Tailnet ACLs don't allow access.
|
|
|
|
|
- <>
|
|
|
|
|
- The current tailnet policy file does not allow
|
|
|
|
|
- connecting to this device.
|
|
|
|
|
- </>
|
|
|
|
|
- ) : (
|
|
|
|
|
- // ACLs allow access, but user can't connect.
|
|
|
|
|
- <>
|
|
|
|
|
- Cannot access this device’s Tailscale IP. Make sure you
|
|
|
|
|
- are connected to your tailnet, and that your policy file
|
|
|
|
|
- allows access.
|
|
|
|
|
- </>
|
|
|
|
|
- )}{" "}
|
|
|
|
|
- <a
|
|
|
|
|
- href="https://tailscale.com/s/web-client-connection"
|
|
|
|
|
- className="text-blue-700"
|
|
|
|
|
- target="_blank"
|
|
|
|
|
- rel="noreferrer"
|
|
|
|
|
- >
|
|
|
|
|
- Learn more →
|
|
|
|
|
- </a>
|
|
|
|
|
- </p>
|
|
|
|
|
- </>
|
|
|
|
|
- ) : (
|
|
|
|
|
- // User can connect to Tailcale IP; sign in when ready.
|
|
|
|
|
- <>
|
|
|
|
|
- <p className="text-gray-500 text-xs">
|
|
|
|
|
- You can see most of this device’s details. To make changes,
|
|
|
|
|
- you need to sign in.
|
|
|
|
|
- </p>
|
|
|
|
|
- {isHTTPS && (
|
|
|
|
|
- // we don't know if the user can connect over TS, so
|
|
|
|
|
- // provide extra tips in case they have trouble.
|
|
|
|
|
- <p className="text-gray-500 text-xs font-semibold pt-2">
|
|
|
|
|
- Make sure you are connected to your tailnet, and that your
|
|
|
|
|
- policy file allows access.
|
|
|
|
|
- </p>
|
|
|
|
|
- )}
|
|
|
|
|
- <SignInButton auth={auth} onClick={handleSignInClick} />
|
|
|
|
|
- </>
|
|
|
|
|
- )}
|
|
|
|
|
- </>
|
|
|
|
|
- ) : auth.authNeeded === AuthType.tailscale ? (
|
|
|
|
|
- // User is connected over Tailscale, but needs to complete check mode.
|
|
|
|
|
- <>
|
|
|
|
|
- <p className="text-gray-500 text-xs">
|
|
|
|
|
- To make changes, sign in to confirm your identity. This extra
|
|
|
|
|
- step helps us keep your device secure.
|
|
|
|
|
- </p>
|
|
|
|
|
- <SignInButton auth={auth} onClick={handleSignInClick} />
|
|
|
|
|
- </>
|
|
|
|
|
- ) : (
|
|
|
|
|
- // User is connected over tailscale, but doesn't have permission to manage.
|
|
|
|
|
- <p className="text-gray-500 text-xs">
|
|
|
|
|
- You don’t have permission to make changes to this device, but you
|
|
|
|
|
- can view most of its details.
|
|
|
|
|
- </p>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ <p className="text-gray-500 text-xs">
|
|
|
|
|
+ {!hasEditCaps ? (
|
|
|
|
|
+ // ACLs allow access, but user isn't allowed to edit any features,
|
|
|
|
|
+ // restricted to readonly. No point in sending them over to the
|
|
|
|
|
+ // tailscaleIP:5252 address.
|
|
|
|
|
+ <>
|
|
|
|
|
+ You don’t have permission to make changes to this device, but
|
|
|
|
|
+ you can view most of its details.
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : !node.ACLAllowsAnyIncomingTraffic ? (
|
|
|
|
|
+ // Tailnet ACLs don't allow access to anyone.
|
|
|
|
|
+ <>
|
|
|
|
|
+ The current tailnet policy file does not allow connecting to
|
|
|
|
|
+ this device.
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ // ACLs don't allow access to this user specifically.
|
|
|
|
|
+ <>
|
|
|
|
|
+ Cannot access this device’s Tailscale IP. Make sure you are
|
|
|
|
|
+ connected to your tailnet, and that your policy file allows
|
|
|
|
|
+ access.
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}{" "}
|
|
|
|
|
+ <a
|
|
|
|
|
+ href="https://tailscale.com/s/web-client-access"
|
|
|
|
|
+ className="text-blue-700"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ Learn more →
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </p>
|
|
|
</>
|
|
</>
|
|
|
- )}
|
|
|
|
|
- {auth.viewerIdentity && (
|
|
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ // User can connect to Tailcale IP; sign in when ready.
|
|
|
<>
|
|
<>
|
|
|
- <hr className="my-2" />
|
|
|
|
|
- <div className="flex items-center">
|
|
|
|
|
- <User className="flex-shrink-0" />
|
|
|
|
|
- <p className="text-gray-500 text-xs ml-2">
|
|
|
|
|
- We recognize you because you are accessing this page from{" "}
|
|
|
|
|
- <span className="font-medium">
|
|
|
|
|
- {auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <p className="text-gray-500 text-xs">
|
|
|
|
|
+ You can see most of this device’s details. To make changes, you need
|
|
|
|
|
+ to sign in.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ {https && (
|
|
|
|
|
+ // we don't know if the user can connect over TS, so
|
|
|
|
|
+ // provide extra tips in case they have trouble.
|
|
|
|
|
+ <p className="text-gray-500 text-xs font-semibold pt-2">
|
|
|
|
|
+ Make sure you are connected to your tailnet, and that your policy
|
|
|
|
|
+ file allows access.
|
|
|
</p>
|
|
</p>
|
|
|
- </div>
|
|
|
|
|
|
|
+ )}
|
|
|
|
|
+ <SignInButton auth={auth} onClick={handleLogin} />
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ <PopoverContentFooter auth={auth} />
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * ManageModeContent is the body of the login popover when the web
|
|
|
|
|
+ * client is being run in "manage" server mode.
|
|
|
|
|
+ */
|
|
|
|
|
+function ManageModeContent({
|
|
|
|
|
+ auth,
|
|
|
|
|
+ newSession,
|
|
|
|
|
+}: {
|
|
|
|
|
+ node: NodeData
|
|
|
|
|
+ auth: AuthResponse
|
|
|
|
|
+ newSession: () => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ const handleLogin = useCallback(() => {
|
|
|
|
|
+ if (window.self !== window.top) {
|
|
|
|
|
+ // If we're inside an iframe, start session in new window.
|
|
|
|
|
+ let url = new URL(window.location.href)
|
|
|
|
|
+ url.searchParams.set("check", "now")
|
|
|
|
|
+ window.open(url, "_blank")
|
|
|
|
|
+ } else {
|
|
|
|
|
+ newSession()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [newSession])
|
|
|
|
|
+
|
|
|
|
|
+ const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <PopoverContentHeader auth={auth} />
|
|
|
|
|
+ {!auth.authorized &&
|
|
|
|
|
+ (hasAnyPermissions ? (
|
|
|
|
|
+ // User is connected over Tailscale, but needs to complete check mode.
|
|
|
|
|
+ <>
|
|
|
|
|
+ <p className="text-gray-500 text-xs">
|
|
|
|
|
+ To make changes, sign in to confirm your identity. This extra step
|
|
|
|
|
+ helps us keep your device secure.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <SignInButton auth={auth} onClick={handleLogin} />
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ // User is connected over tailscale, but doesn't have permission to manage.
|
|
|
|
|
+ <p className="text-gray-500 text-xs">
|
|
|
|
|
+ You don’t have permission to make changes to this device, but you
|
|
|
|
|
+ can view most of its details.{" "}
|
|
|
|
|
+ <a
|
|
|
|
|
+ href="https://tailscale.com/s/web-client-access"
|
|
|
|
|
+ className="text-blue-700"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ Learn more →
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <PopoverContentFooter auth={auth} />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function SignInButton({
|
|
function SignInButton({
|
|
|
auth,
|
|
auth,
|
|
|
onClick,
|
|
onClick,
|