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