2023-11-28 13:15:19 -05:00
|
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
2023-11-09 16:19:22 -05:00
|
|
|
|
import cx from "classnames"
|
2023-11-15 19:10:26 -05:00
|
|
|
|
import React, { useCallback, useEffect, useState } from "react"
|
2023-11-16 14:27:01 -05:00
|
|
|
|
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
|
|
|
|
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
|
|
|
|
|
import { ReactComponent as User } from "src/assets/icons/user.svg"
|
2023-11-09 16:19:22 -05:00
|
|
|
|
import { AuthResponse, AuthType } from "src/hooks/auth"
|
|
|
|
|
import { NodeData } from "src/hooks/node-data"
|
2023-11-29 16:40:41 -08:00
|
|
|
|
import Button from "src/ui/button"
|
2023-11-09 16:19:22 -05:00
|
|
|
|
import Popover from "src/ui/popover"
|
|
|
|
|
import ProfilePic from "src/ui/profile-pic"
|
|
|
|
|
|
|
|
|
|
export default function LoginToggle({
|
|
|
|
|
node,
|
|
|
|
|
auth,
|
|
|
|
|
newSession,
|
|
|
|
|
}: {
|
|
|
|
|
node: NodeData
|
|
|
|
|
auth: AuthResponse
|
|
|
|
|
newSession: () => Promise<void>
|
|
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState<boolean>(false)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Popover
|
|
|
|
|
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
|
|
|
|
|
content={
|
|
|
|
|
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
|
|
|
|
|
}
|
|
|
|
|
side="bottom"
|
|
|
|
|
align="end"
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={setOpen}
|
|
|
|
|
asChild
|
|
|
|
|
>
|
|
|
|
|
{!auth.canManageNode ? (
|
|
|
|
|
<button
|
|
|
|
|
className={cx(
|
2023-12-01 17:48:09 -05:00
|
|
|
|
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
2023-11-09 16:19:22 -05:00
|
|
|
|
{ "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(
|
2023-12-01 17:48:09 -05:00
|
|
|
|
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex",
|
2023-11-09 16:19:22 -05:00
|
|
|
|
{
|
|
|
|
|
"bg-transparent": !open,
|
2023-12-01 17:48:09 -05:00
|
|
|
|
"bg-gray-300": open,
|
2023-11-09 16:19:22 -05:00
|
|
|
|
}
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button onClick={() => setOpen(!open)}>
|
|
|
|
|
<ProfilePic
|
|
|
|
|
size="medium"
|
|
|
|
|
url={auth.viewerIdentity?.profilePicUrl}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Popover>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function LoginPopoverContent({
|
|
|
|
|
node,
|
|
|
|
|
auth,
|
|
|
|
|
newSession,
|
|
|
|
|
}: {
|
|
|
|
|
node: NodeData
|
|
|
|
|
auth: AuthResponse
|
|
|
|
|
newSession: () => Promise<void>
|
|
|
|
|
}) {
|
2023-11-15 19:10:26 -05:00
|
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
|
|
|
|
|
const checkTSConnection = useCallback(() => {
|
|
|
|
|
if (auth.viewerIdentity) {
|
|
|
|
|
setCanConnectOverTS(true) // already connected over ts
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Otherwise, test connection to the ts IP.
|
|
|
|
|
if (isRunningCheck) {
|
|
|
|
|
return // already checking
|
|
|
|
|
}
|
|
|
|
|
setIsRunningCheck(true)
|
2023-12-05 10:09:33 -05:00
|
|
|
|
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
|
2023-11-15 19:10:26 -05:00
|
|
|
|
.then(() => {
|
|
|
|
|
setIsRunningCheck(false)
|
|
|
|
|
setCanConnectOverTS(true)
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setIsRunningCheck(false))
|
2023-12-05 10:09:33 -05:00
|
|
|
|
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
|
2023-11-15 19:10:26 -05:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2023-11-28 16:31:56 -05:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2023-11-15 19:10:26 -05:00
|
|
|
|
useEffect(() => checkTSConnection(), [])
|
|
|
|
|
|
2023-11-09 16:19:22 -05:00
|
|
|
|
const handleSignInClick = useCallback(() => {
|
|
|
|
|
if (auth.viewerIdentity) {
|
|
|
|
|
newSession()
|
|
|
|
|
} else {
|
|
|
|
|
// Must be connected over Tailscale to log in.
|
2023-12-04 14:17:53 -08:00
|
|
|
|
// Send user to Tailscale IP and start check mode
|
2023-12-05 10:09:33 -05:00
|
|
|
|
const manageURL = `http://${node.IPv4}:5252/?check=now`
|
2023-12-04 14:17:53 -08:00
|
|
|
|
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
|
|
|
|
|
}
|
2023-11-09 16:19:22 -05:00
|
|
|
|
}
|
2023-12-05 10:09:33 -05:00
|
|
|
|
}, [node.IPv4, auth.viewerIdentity, newSession])
|
2023-11-09 16:19:22 -05:00
|
|
|
|
|
|
|
|
|
return (
|
2023-11-15 19:10:26 -05:00
|
|
|
|
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
2023-11-16 13:53:57 -05:00
|
|
|
|
<div className="text-black text-sm font-medium leading-tight mb-1">
|
2023-11-09 16:19:22 -05:00
|
|
|
|
{!auth.canManageNode ? "Viewing" : "Managing"}
|
|
|
|
|
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
|
|
|
|
</div>
|
2023-11-29 16:40:41 -08:00
|
|
|
|
{!auth.canManageNode && (
|
|
|
|
|
<>
|
|
|
|
|
{!auth.viewerIdentity ? (
|
|
|
|
|
// User is not connected over Tailscale.
|
|
|
|
|
// These states are only possible on the login client.
|
|
|
|
|
<>
|
|
|
|
|
{!canConnectOverTS ? (
|
2023-11-09 16:19:22 -05:00
|
|
|
|
<>
|
2023-11-29 16:40:41 -08:00
|
|
|
|
<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>
|
2023-11-09 16:19:22 -05:00
|
|
|
|
</>
|
|
|
|
|
) : (
|
2023-11-29 16:40:41 -08:00
|
|
|
|
// User can connect to Tailcale IP; sign in when ready.
|
2023-11-09 16:19:22 -05:00
|
|
|
|
<>
|
2023-11-29 16:40:41 -08:00
|
|
|
|
<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>
|
|
|
|
|
<SignInButton auth={auth} onClick={handleSignInClick} />
|
2023-11-09 16:19:22 -05:00
|
|
|
|
</>
|
|
|
|
|
)}
|
2023-11-29 16:40:41 -08:00
|
|
|
|
</>
|
|
|
|
|
) : 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.
|
2023-11-09 16:19:22 -05:00
|
|
|
|
</p>
|
2023-11-29 16:40:41 -08:00
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2023-11-09 16:19:22 -05:00
|
|
|
|
{auth.viewerIdentity && (
|
|
|
|
|
<>
|
2023-11-16 13:53:57 -05:00
|
|
|
|
<hr className="my-2" />
|
2023-11-09 16:19:22 -05:00
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<User className="flex-shrink-0" />
|
2023-12-01 15:12:34 -05:00
|
|
|
|
<p className="text-gray-500 text-xs ml-2">
|
2023-11-09 16:19:22 -05:00
|
|
|
|
We recognize you because you are accessing this page from{" "}
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
|
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2023-11-15 19:10:26 -05:00
|
|
|
|
</div>
|
2023-11-09 16:19:22 -05:00
|
|
|
|
)
|
|
|
|
|
}
|
2023-11-29 16:40:41 -08:00
|
|
|
|
|
|
|
|
|
function SignInButton({
|
|
|
|
|
auth,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
auth: AuthResponse
|
|
|
|
|
onClick: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
2023-12-04 15:20:38 -05:00
|
|
|
|
className={cx("text-center w-full mt-2", {
|
2023-11-29 16:40:41 -08:00
|
|
|
|
"mb-2": auth.viewerIdentity,
|
|
|
|
|
})}
|
2023-12-04 15:20:38 -05:00
|
|
|
|
intent="primary"
|
|
|
|
|
sizeVariant="small"
|
2023-11-29 16:40:41 -08:00
|
|
|
|
onClick={onClick}
|
|
|
|
|
>
|
|
|
|
|
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|