mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-07 00:34:42 +00:00

Makes the following changes: * Use “link” class in various spots * Remove button appearance on Exit Node dropdown in readonly mode * Update `-stone-` colors to `-gray-` (couple spots missed by original color config commit) * Pull full ui/button component from admin panel, and update buttons throughout UI to use this component * Remove various buttons in readonly view to match mocks * Add route (and “pending approval”) highlights to Subnet router settings card * Delete legacy client button styles from index.css * Fix overflow of IPv6 address on device details view Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
// Copyright (c) Tailscale Inc & AUTHORS
|
||
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
||
import cx from "classnames"
|
||
import React, { useCallback, useEffect, useState } from "react"
|
||
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"
|
||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||
import { NodeData } from "src/hooks/node-data"
|
||
import Button from "src/ui/button"
|
||
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(
|
||
"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",
|
||
{
|
||
"bg-transparent": !open,
|
||
"bg-gray-300": open,
|
||
}
|
||
)}
|
||
>
|
||
<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>
|
||
}) {
|
||
/**
|
||
* 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)
|
||
fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" })
|
||
.then(() => {
|
||
setIsRunningCheck(false)
|
||
setCanConnectOverTS(true)
|
||
})
|
||
.catch(() => setIsRunningCheck(false))
|
||
}, [auth.viewerIdentity, isRunningCheck, node.IP])
|
||
|
||
/**
|
||
* 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(), [])
|
||
|
||
const handleSignInClick = useCallback(() => {
|
||
if (auth.viewerIdentity) {
|
||
newSession()
|
||
} else {
|
||
// Must be connected over Tailscale to log in.
|
||
// If not already connected, reroute to the Tailscale IP
|
||
// before sending user through check mode.
|
||
window.location.href = `http://${node.IP}:5252/?check=now`
|
||
}
|
||
}, [node.IP, auth.viewerIdentity, newSession])
|
||
|
||
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 && (
|
||
<>
|
||
{!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>
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
{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>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SignInButton({
|
||
auth,
|
||
onClick,
|
||
}: {
|
||
auth: AuthResponse
|
||
onClick: () => void
|
||
}) {
|
||
return (
|
||
<Button
|
||
className={cx("text-center w-full mt-2", {
|
||
"mb-2": auth.viewerIdentity,
|
||
})}
|
||
intent="primary"
|
||
sizeVariant="small"
|
||
onClick={onClick}
|
||
>
|
||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||
</Button>
|
||
)
|
||
}
|