Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>main
parent
c54d680682
commit
86c8ab7502
@ -0,0 +1,149 @@ |
|||||||
|
import cx from "classnames" |
||||||
|
import React, { useCallback, useState } from "react" |
||||||
|
import { AuthResponse, AuthType } from "src/hooks/auth" |
||||||
|
import { NodeData } from "src/hooks/node-data" |
||||||
|
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg" |
||||||
|
import { ReactComponent as Eye } from "src/icons/eye.svg" |
||||||
|
import { ReactComponent as User } from "src/icons/user.svg" |
||||||
|
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-zinc-800 rounded-full flex justify-start items-center", |
||||||
|
{ "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 items-center inline-flex", |
||||||
|
{ |
||||||
|
"bg-transparent": !open, |
||||||
|
"bg-neutral-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> |
||||||
|
}) { |
||||||
|
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 className="text-black text-sm font-medium leading-tight"> |
||||||
|
{!auth.canManageNode ? "Viewing" : "Managing"} |
||||||
|
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} |
||||||
|
</div> |
||||||
|
{!auth.canManageNode && |
||||||
|
(!auth.viewerIdentity || auth.authNeeded == AuthType.tailscale ? ( |
||||||
|
<> |
||||||
|
<p className="text-neutral-500 text-xs"> |
||||||
|
{auth.viewerIdentity ? ( |
||||||
|
<> |
||||||
|
To make changes, sign in to confirm your identity. This extra |
||||||
|
step helps us keep your device secure. |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
You can see most of this device's details. To make changes, |
||||||
|
you need to sign in. |
||||||
|
</> |
||||||
|
)} |
||||||
|
</p> |
||||||
|
<button |
||||||
|
className={cx( |
||||||
|
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2", |
||||||
|
{ "mb-2": auth.viewerIdentity } |
||||||
|
)} |
||||||
|
onClick={handleSignInClick} |
||||||
|
> |
||||||
|
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"} |
||||||
|
</button> |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<p className="text-neutral-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 /> |
||||||
|
<div className="flex items-center"> |
||||||
|
<User className="flex-shrink-0" /> |
||||||
|
<p className="text-neutral-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> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,75 +0,0 @@ |
|||||||
import React from "react" |
|
||||||
import { AuthResponse, AuthType } from "src/hooks/auth" |
|
||||||
import { NodeData } from "src/hooks/node-data" |
|
||||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" |
|
||||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg" |
|
||||||
import ProfilePic from "src/ui/profile-pic" |
|
||||||
|
|
||||||
/** |
|
||||||
* ReadonlyClientView is rendered when the web interface is either |
|
||||||
* |
|
||||||
* 1. being viewed by a user not allowed to manage the node |
|
||||||
* (e.g. user does not own the node) |
|
||||||
* |
|
||||||
* 2. or the user is allowed to manage the node but does not |
|
||||||
* yet have a valid browser session. |
|
||||||
*/ |
|
||||||
export default function ReadonlyClientView({ |
|
||||||
data, |
|
||||||
auth, |
|
||||||
newSession, |
|
||||||
}: { |
|
||||||
data: NodeData |
|
||||||
auth?: AuthResponse |
|
||||||
newSession: () => Promise<void> |
|
||||||
}) { |
|
||||||
return ( |
|
||||||
<> |
|
||||||
<div className="pb-52 mx-auto"> |
|
||||||
<TailscaleLogo /> |
|
||||||
</div> |
|
||||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4"> |
|
||||||
<div className="flex gap-2.5"> |
|
||||||
<ProfilePic url={data.Profile.ProfilePicURL} /> |
|
||||||
<div className="font-medium"> |
|
||||||
<div className="text-neutral-500 text-xs uppercase tracking-wide"> |
|
||||||
Managed by |
|
||||||
</div> |
|
||||||
<div className="text-neutral-800 text-sm leading-tight"> |
|
||||||
{/* TODO(sonia): support tagged node profile view more eloquently */} |
|
||||||
{data.Profile.LoginName} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex"> |
|
||||||
<div className="flex gap-3"> |
|
||||||
<ConnectedDeviceIcon /> |
|
||||||
<div className="text-neutral-800"> |
|
||||||
<div className="text-lg font-medium leading-[25.20px]"> |
|
||||||
{data.DeviceName} |
|
||||||
</div> |
|
||||||
<div className="text-sm leading-tight">{data.IP}</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{auth?.authNeeded == AuthType.tailscale ? ( |
|
||||||
<button className="button button-blue ml-6" onClick={newSession}> |
|
||||||
Access |
|
||||||
</button> |
|
||||||
) : ( |
|
||||||
window.location.hostname != data.IP && ( |
|
||||||
// TODO: check connectivity to tailscale IP
|
|
||||||
<button |
|
||||||
className="button button-blue ml-6" |
|
||||||
onClick={() => { |
|
||||||
window.location.href = `http://${data.IP}:5252/?check=now` |
|
||||||
}} |
|
||||||
> |
|
||||||
Manage |
|
||||||
</button> |
|
||||||
) |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 635 B |
@ -0,0 +1,106 @@ |
|||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover" |
||||||
|
import cx from "classnames" |
||||||
|
import React, { ReactNode } from "react" |
||||||
|
|
||||||
|
type Props = { |
||||||
|
className?: string |
||||||
|
content: ReactNode |
||||||
|
children: ReactNode |
||||||
|
|
||||||
|
/** |
||||||
|
* asChild renders the trigger element without wrapping it in a button. Use |
||||||
|
* this when you want to use a `button` element as the trigger. |
||||||
|
*/ |
||||||
|
asChild?: boolean |
||||||
|
/** |
||||||
|
* side is the side of the direction from the target element to render the |
||||||
|
* popover. |
||||||
|
*/ |
||||||
|
side?: "top" | "bottom" | "left" | "right" |
||||||
|
/** |
||||||
|
* sideOffset is how far from a give side to render the popover. |
||||||
|
*/ |
||||||
|
sideOffset?: number |
||||||
|
/** |
||||||
|
* align is how to align the popover with the target element. |
||||||
|
*/ |
||||||
|
align?: "start" | "center" | "end" |
||||||
|
/** |
||||||
|
* alignOffset is how far off of the alignment point to render the popover. |
||||||
|
*/ |
||||||
|
alignOffset?: number |
||||||
|
|
||||||
|
open?: boolean |
||||||
|
onOpenChange?: (open: boolean) => void |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Popover is a UI component that allows rendering unique controls in a floating |
||||||
|
* popover, attached to a trigger element. It appears on click and manages focus |
||||||
|
* on its own behalf. |
||||||
|
* |
||||||
|
* To use the Popover, pass the content as children, and give it a `trigger`: |
||||||
|
* |
||||||
|
* <Popover trigger={<span>Open popover</span>}> |
||||||
|
* <p>Hello world!</p> |
||||||
|
* </Popover> |
||||||
|
* |
||||||
|
* By default, the toggle is wrapped in an accessible <button> tag. You can |
||||||
|
* customize by providing your own button and using the `asChild` prop. |
||||||
|
* |
||||||
|
* <Popover trigger={<Button>Hello</Button>} asChild> |
||||||
|
* <p>Hello world!</p> |
||||||
|
* </Popover> |
||||||
|
* |
||||||
|
* The former style is recommended whenever possible. |
||||||
|
*/ |
||||||
|
export default function Popover(props: Props) { |
||||||
|
const { |
||||||
|
children, |
||||||
|
className, |
||||||
|
content, |
||||||
|
side, |
||||||
|
sideOffset, |
||||||
|
align, |
||||||
|
alignOffset, |
||||||
|
asChild, |
||||||
|
open, |
||||||
|
onOpenChange, |
||||||
|
} = props |
||||||
|
|
||||||
|
return ( |
||||||
|
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}> |
||||||
|
<PopoverPrimitive.Trigger asChild={asChild}> |
||||||
|
{children} |
||||||
|
</PopoverPrimitive.Trigger> |
||||||
|
<PortalContainerContext.Consumer> |
||||||
|
{(portalContainer) => ( |
||||||
|
<PopoverPrimitive.Portal container={portalContainer}> |
||||||
|
<PopoverPrimitive.Content |
||||||
|
className={cx( |
||||||
|
"origin-radix-popover shadow-popover bg-white rounded-md z-50", |
||||||
|
"state-open:animate-scale-in state-closed:animate-scale-out", |
||||||
|
className |
||||||
|
)} |
||||||
|
side={side} |
||||||
|
sideOffset={sideOffset} |
||||||
|
align={align} |
||||||
|
alignOffset={alignOffset} |
||||||
|
collisionPadding={12} |
||||||
|
> |
||||||
|
{content} |
||||||
|
</PopoverPrimitive.Content> |
||||||
|
</PopoverPrimitive.Portal> |
||||||
|
)} |
||||||
|
</PortalContainerContext.Consumer> |
||||||
|
</PopoverPrimitive.Root> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
Popover.defaultProps = { |
||||||
|
sideOffset: 10, |
||||||
|
} |
||||||
|
|
||||||
|
const PortalContainerContext = React.createContext<HTMLElement | undefined>( |
||||||
|
undefined |
||||||
|
) |
||||||
Loading…
Reference in new issue