Browse Source

client/web: add readonly/manage toggle

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy 2 years ago
parent
commit
86c8ab7502

+ 1 - 0
client/web/package.json

@@ -8,6 +8,7 @@
   },
   "private": true,
   "dependencies": {
+    "@radix-ui/react-popover": "^1.0.6",
     "classnames": "^2.3.1",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",

+ 4 - 4
client/web/qnap.go

@@ -20,16 +20,16 @@ import (
 // authorizeQNAP authenticates the logged-in QNAP user and verifies that they
 // are authorized to use the web client.
 // If the user is not authorized to use the client, an error is returned.
-func authorizeQNAP(r *http.Request) (ar authResponse, err error) {
+func authorizeQNAP(r *http.Request) (authorized bool, err error) {
 	_, resp, err := qnapAuthn(r)
 	if err != nil {
-		return ar, err
+		return false, err
 	}
 	if resp.IsAdmin == 0 {
-		return ar, errors.New("user is not an admin")
+		return false, errors.New("user is not an admin")
 	}
 
-	return authResponse{OK: true}, nil
+	return true, nil
 }
 
 type qnapAuthResponse struct {

+ 47 - 70
client/web/src/components/app.tsx

@@ -1,22 +1,21 @@
 import cx from "classnames"
 import React, { useEffect } from "react"
+import LoginToggle from "src/components/login-toggle"
+import DeviceDetailsView from "src/components/views/device-details-view"
+import HomeView from "src/components/views/home-view"
 import LegacyClientView from "src/components/views/legacy-client-view"
 import LoginClientView from "src/components/views/login-client-view"
-import ManagementClientView from "src/components/views/management-client-view"
-import ReadonlyClientView from "src/components/views/readonly-client-view"
 import useAuth, { AuthResponse } from "src/hooks/auth"
-import useNodeData, { NodeData, NodeUpdate } from "src/hooks/node-data"
+import useNodeData, { NodeData } from "src/hooks/node-data"
 import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
-import ProfilePic from "src/ui/profile-pic"
 import { Link, Route, Router, Switch, useLocation } from "wouter"
-import DeviceDetailsView from "./views/device-details-view"
 
 export default function App() {
   const { data: auth, loading: loadingAuth, newSession } = useAuth()
 
   return (
     <main className="min-w-sm max-w-lg mx-auto py-14 px-5">
-      {loadingAuth ? (
+      {loadingAuth || !auth ? (
         <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
       ) : (
         <WebClient auth={auth} newSession={newSession} />
@@ -29,7 +28,7 @@ function WebClient({
   auth,
   newSession,
 }: {
-  auth?: AuthResponse
+  auth: AuthResponse
   newSession: () => Promise<void>
 }) {
   const { data, refreshData, updateNode } = useNodeData()
@@ -37,36 +36,44 @@ function WebClient({
     refreshData()
   }, [auth, refreshData])
 
-  if (!data) {
-    return <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
-  }
-
-  return (
+  return !data ? (
+    <div className="text-center py-14">Loading...</div>
+  ) : data.Status === "NeedsLogin" || data.Status === "NoState" ? (
+    // Client not on a tailnet, render login.
+    <LoginClientView
+      data={data}
+      onLoginClick={() => updateNode({ Reauthenticate: true })}
+    />
+  ) : data.DebugMode !== "full" && data.DebugMode !== "login" ? (
+    // Render legacy client interface.
+    <>
+      <LegacyClientView
+        data={data}
+        refreshData={refreshData}
+        updateNode={updateNode}
+      />
+      {/* TODO: add license to new client */}
+      <Footer licensesURL={data.LicensesURL} />
+    </>
+  ) : (
+    // Otherwise render the new web client.
     <>
-      {/* TODO(sonia): get rid of the conditions here once full/readonly
-       * views live on same components */}
-      {data.DebugMode === "full" && auth?.ok && <Header node={data} />}
+      <Header node={data} auth={auth} newSession={newSession} />
       <Router base={data.URLPrefix}>
         <Switch>
           <Route path="/">
             <HomeView
-              auth={auth}
-              data={data}
-              newSession={newSession}
-              refreshData={refreshData}
+              readonly={!auth.canManageNode}
+              node={data}
               updateNode={updateNode}
             />
           </Route>
-          {data.DebugMode !== "" && (
-            <>
-              <Route path="/details">
-                <DeviceDetailsView node={data} />
-              </Route>
-              <Route path="/subnets">{/* TODO */}Subnet router</Route>
-              <Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
-              <Route path="/serve">{/* TODO */}Share local content</Route>
-            </>
-          )}
+          <Route path="/details">
+            <DeviceDetailsView readonly={!auth.canManageNode} node={data} />
+          </Route>
+          <Route path="/subnets">{/* TODO */}Subnet router</Route>
+          <Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
+          <Route path="/serve">{/* TODO */}Share local content</Route>
           <Route>
             <h2 className="mt-8">Page not found</h2>
           </Route>
@@ -76,57 +83,27 @@ function WebClient({
   )
 }
 
-function HomeView({
+function Header({
+  node,
   auth,
-  data,
   newSession,
-  refreshData,
-  updateNode,
 }: {
-  auth?: AuthResponse
-  data: NodeData
+  node: NodeData
+  auth: AuthResponse
   newSession: () => Promise<void>
-  refreshData: () => Promise<void>
-  updateNode: (update: NodeUpdate) => Promise<void> | undefined
 }) {
-  return (
-    <>
-      {data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
-        // Client not on a tailnet, render login.
-        <LoginClientView
-          data={data}
-          onLoginClick={() => updateNode({ Reauthenticate: true })}
-        />
-      ) : data.DebugMode === "full" && auth?.ok ? (
-        // Render new client interface in management mode.
-        <ManagementClientView node={data} updateNode={updateNode} />
-      ) : data.DebugMode === "login" || data.DebugMode === "full" ? (
-        // Render new client interface in readonly mode.
-        <ReadonlyClientView data={data} auth={auth} newSession={newSession} />
-      ) : (
-        // Render legacy client interface.
-        <LegacyClientView
-          data={data}
-          refreshData={refreshData}
-          updateNode={updateNode}
-        />
-      )}
-      {<Footer licensesURL={data.LicensesURL} />}
-    </>
-  )
-}
-
-function Header({ node }: { node: NodeData }) {
   const [loc] = useLocation()
 
   return (
     <>
       <div className="flex justify-between mb-12">
-        <TailscaleIcon />
-        <div className="flex">
-          <p className="mr-2">{node.Profile.LoginName}</p>
-          <ProfilePic url={node.Profile.ProfilePicURL} />
+        <div className="flex gap-3">
+          <TailscaleIcon />
+          <div className="inline text-neutral-800 text-lg font-medium leading-snug">
+            {node.DomainName}
+          </div>
         </div>
+        <LoginToggle node={node} auth={auth} newSession={newSession} />
       </div>
       {loc !== "/" && (
         <Link
@@ -140,7 +117,7 @@ function Header({ node }: { node: NodeData }) {
   )
 }
 
-export function Footer({
+function Footer({
   licensesURL,
   className,
 }: {

+ 5 - 1
client/web/src/components/exit-node-selector.tsx

@@ -12,10 +12,12 @@ export default function ExitNodeSelector({
   className,
   node,
   updateNode,
+  disabled,
 }: {
   className?: string
   node: NodeData
   updateNode: (update: NodeUpdate) => Promise<void> | undefined
+  disabled?: boolean
 }) {
   const [open, setOpen] = useState<boolean>(false)
   const [selected, setSelected] = useState(
@@ -78,12 +80,14 @@ export default function ExitNodeSelector({
         )}
       >
         <button
-          className={cx("flex-1 px-2 py-1.5 rounded-[1px] cursor-pointer", {
+          className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
             "bg-white hover:bg-stone-100": none,
             "bg-amber-600 hover:bg-orange-400": advertising,
             "bg-indigo-500 hover:bg-indigo-400": using,
+            "cursor-not-allowed": disabled,
           })}
           onClick={() => setOpen(!open)}
+          disabled={disabled}
         >
           <p
             className={cx(

+ 149 - 0
client/web/src/components/login-toggle.tsx

@@ -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>
+        </>
+      )}
+    </>
+  )
+}

+ 12 - 2
client/web/src/components/views/device-details-view.tsx

@@ -5,7 +5,13 @@ import { NodeData } from "src/hooks/node-data"
 import { useLocation } from "wouter"
 import ACLTag from "../acl-tag"
 
-export default function DeviceDetailsView({ node }: { node: NodeData }) {
+export default function DeviceDetailsView({
+  readonly,
+  node,
+}: {
+  readonly: boolean
+  node: NodeData
+}) {
   const [, setLocation] = useLocation()
 
   return (
@@ -24,12 +30,16 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
               />
             </div>
             <button
-              className="px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium"
+              className={cx(
+                "px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
+                { "cursor-not-allowed": readonly }
+              )}
               onClick={() =>
                 apiFetch("/local/v0/logout", "POST")
                   .then(() => setLocation("/"))
                   .catch((err) => alert("Logout failed: " + err.message))
               }
+              disabled={readonly}
             >
               Disconnect…
             </button>

+ 4 - 1
client/web/src/components/views/management-client-view.tsx → client/web/src/components/views/home-view.tsx

@@ -6,10 +6,12 @@ import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
 import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
 import { Link } from "wouter"
 
-export default function ManagementClientView({
+export default function HomeView({
+  readonly,
   node,
   updateNode,
 }: {
+  readonly: boolean
   node: NodeData
   updateNode: (update: NodeUpdate) => Promise<void> | undefined
 }) {
@@ -34,6 +36,7 @@ export default function ManagementClientView({
           className="mb-5"
           node={node}
           updateNode={updateNode}
+          disabled={readonly}
         />
         <Link
           className="text-indigo-500 font-medium leading-snug"

+ 0 - 75
client/web/src/components/views/readonly-client-view.tsx

@@ -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>
-    </>
-  )
-}

+ 7 - 1
client/web/src/hooks/auth.ts

@@ -7,8 +7,14 @@ export enum AuthType {
 }
 
 export type AuthResponse = {
-  ok: boolean
   authNeeded?: AuthType
+  canManageNode: boolean
+  viewerIdentity?: {
+    loginName: string
+    nodeName: string
+    nodeIP: string
+    profilePicUrl?: string
+  }
 }
 
 // useAuth reports and refreshes Tailscale auth status

+ 1 - 0
client/web/src/hooks/node-data.ts

@@ -21,6 +21,7 @@ export type NodeData = {
   UnraidToken: string
   IPNVersion: string
   URLPrefix: string
+  DomainName: string
   TailnetName: string
   IsTagged: boolean
   Tags: string[]

+ 11 - 0
client/web/src/icons/eye.svg

@@ -0,0 +1,11 @@
+<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_15367_14595)">
+<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<defs>
+<clipPath id="clip0_15367_14595">
+<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
+</clipPath>
+</defs>
+</svg>

+ 4 - 0
client/web/src/icons/user.svg

@@ -0,0 +1,4 @@
+<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 106 - 0
client/web/src/ui/popover.tsx

@@ -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
+)

+ 14 - 7
client/web/src/ui/profile-pic.tsx

@@ -3,17 +3,24 @@ import React from "react"
 
 export default function ProfilePic({
   url,
-  size = "medium",
+  size = "large",
+  className,
 }: {
-  url: string
-  size?: "small" | "medium"
+  url?: string
+  size?: "small" | "medium" | "large"
+  className?: string
 }) {
   return (
     <div
-      className={cx("relative flex-shrink-0 rounded-full overflow-hidden", {
-        "w-5 h-5": size === "small",
-        "w-8 h-8": size === "medium",
-      })}
+      className={cx(
+        "relative flex-shrink-0 rounded-full overflow-hidden",
+        {
+          "w-5 h-5": size === "small",
+          "w-[26px] h-[26px]": size === "medium",
+          "w-8 h-8": size === "large",
+        },
+        className
+      )}
     >
       {url ? (
         <div

+ 6 - 8
client/web/synology.go

@@ -18,32 +18,30 @@ import (
 
 // authorizeSynology authenticates the logged-in Synology user and verifies
 // that they are authorized to use the web client.
-// The returned authResponse indicates if the user is authorized,
-// and if additional steps are needed to authenticate the user.
 // If the user is authenticated, but not authorized to use the client, an error is returned.
-func authorizeSynology(r *http.Request) (resp authResponse, err error) {
+func authorizeSynology(r *http.Request) (authorized bool, err error) {
 	if !hasSynoToken(r) {
-		return authResponse{OK: false, AuthNeeded: synoAuth}, nil
+		return false, nil
 	}
 
 	// authenticate the Synology user
 	cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
 	out, err := cmd.CombinedOutput()
 	if err != nil {
-		return resp, fmt.Errorf("auth: %v: %s", err, out)
+		return false, fmt.Errorf("auth: %v: %s", err, out)
 	}
 	user := strings.TrimSpace(string(out))
 
 	// check if the user is in the administrators group
 	isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
 	if err != nil {
-		return resp, err
+		return false, err
 	}
 	if !isAdmin {
-		return resp, errors.New("not a member of administrators group")
+		return false, errors.New("not a member of administrators group")
 	}
 
-	return authResponse{OK: true}, nil
+	return true, nil
 }
 
 // hasSynoToken returns true if the request include a SynoToken used for synology auth.

+ 39 - 14
client/web/web.go

@@ -329,11 +329,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
 	// Client using system-specific auth.
 	switch distro.Get() {
 	case distro.Synology:
-		resp, _ := authorizeSynology(r)
-		return resp.OK
+		authorized, _ := authorizeSynology(r)
+		return authorized
 	case distro.QNAP:
-		resp, _ := authorizeQNAP(r)
-		return resp.OK
+		authorized, _ := authorizeQNAP(r)
+		return authorized
 	default:
 		return true // no additional auth for this distro
 	}
@@ -366,8 +366,18 @@ var (
 )
 
 type authResponse struct {
-	OK         bool     `json:"ok"`                   // true when user has valid auth session
-	AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
+	AuthNeeded     authType        `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
+	CanManageNode  bool            `json:"canManageNode"`
+	ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
+}
+
+// viewerIdentity is the Tailscale identity of the source node
+// connected to this web client.
+type viewerIdentity struct {
+	LoginName     string `json:"loginName"`
+	NodeName      string `json:"nodeName"`
+	NodeIP        string `json:"nodeIP"`
+	ProfilePicURL string `json:"profilePicUrl,omitempty"`
 }
 
 // serverAPIAuth handles requests to the /api/auth endpoint
@@ -375,25 +385,27 @@ type authResponse struct {
 func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
 	var resp authResponse
 
-	session, _, err := s.getSession(r)
+	session, whois, err := s.getSession(r)
 	switch {
 	case err != nil && errors.Is(err, errNotUsingTailscale):
 		// not using tailscale, so perform platform auth
 		switch distro.Get() {
 		case distro.Synology:
-			resp, err = authorizeSynology(r)
+			authorized, err := authorizeSynology(r)
 			if err != nil {
 				http.Error(w, err.Error(), http.StatusUnauthorized)
 				return
 			}
+			if !authorized {
+				resp.AuthNeeded = synoAuth
+			}
 		case distro.QNAP:
-			resp, err = authorizeQNAP(r)
-			if err != nil {
+			if _, err := authorizeQNAP(r); err != nil {
 				http.Error(w, err.Error(), http.StatusUnauthorized)
 				return
 			}
 		default:
-			resp.OK = true // no additional auth for this distro
+			// no additional auth for this distro
 		}
 	case err != nil && (errors.Is(err, errNotOwner) ||
 		errors.Is(err, errNotUsingTailscale) ||
@@ -401,17 +413,28 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
 		errors.Is(err, errTaggedRemoteSource)):
 		// These cases are all restricted to the readonly view.
 		// No auth action to take.
-		resp = authResponse{OK: false}
+		resp.AuthNeeded = ""
 	case err != nil && !errors.Is(err, errNoSession):
 		// Any other error.
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	case session.isAuthorized(s.timeNow()):
-		resp = authResponse{OK: true}
+		resp.CanManageNode = true
+		resp.AuthNeeded = ""
 	default:
-		resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
+		resp.AuthNeeded = tailscaleAuth
 	}
 
+	if whois != nil {
+		resp.ViewerIdentity = &viewerIdentity{
+			LoginName:     whois.UserProfile.LoginName,
+			NodeName:      whois.Node.Name,
+			ProfilePicURL: whois.UserProfile.ProfilePicURL,
+		}
+		if addrs := whois.Node.Addresses; len(addrs) > 0 {
+			resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
+		}
+	}
 	writeJSON(w, resp)
 }
 
@@ -494,6 +517,7 @@ type nodeData struct {
 	Status      string
 	DeviceName  string
 	TailnetName string // TLS cert name
+	DomainName  string
 	IP          string // IPv4
 	IPv6        string
 	OS          string
@@ -543,6 +567,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
 		Status:      st.BackendState,
 		DeviceName:  strings.Split(st.Self.DNSName, ".")[0],
 		TailnetName: st.CurrentTailnet.MagicDNSSuffix,
+		DomainName:  st.CurrentTailnet.Name,
 		OS:          st.Self.OS,
 		IPNVersion:  strings.Split(st.Version, "-")[0],
 		Profile:     st.User[st.Self.UserID],

+ 18 - 5
client/web/web_test.go

@@ -410,14 +410,27 @@ func TestAuthorizeRequest(t *testing.T) {
 }
 
 func TestServeAuth(t *testing.T) {
-	user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
+	user := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(1)}
 	self := &ipnstate.PeerStatus{
 		ID:           "self",
 		UserID:       user.ID,
 		TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
 	}
-	remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
 	remoteIP := "100.100.100.101"
+	remoteNode := &apitype.WhoIsResponse{
+		Node: &tailcfg.Node{
+			Name:      "nodey",
+			ID:        1,
+			Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
+		},
+		UserProfile: user,
+	}
+	vi := &viewerIdentity{
+		LoginName:     user.LoginName,
+		NodeName:      remoteNode.Node.Name,
+		NodeIP:        remoteIP,
+		ProfilePicURL: user.ProfilePicURL,
+	}
 
 	lal := memnet.Listen("local-tailscaled.sock:80")
 	defer lal.Close()
@@ -481,7 +494,7 @@ func TestServeAuth(t *testing.T) {
 			name:          "no-session",
 			path:          "/api/auth",
 			wantStatus:    http.StatusOK,
-			wantResp:      &authResponse{OK: false, AuthNeeded: tailscaleAuth},
+			wantResp:      &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
 			wantNewCookie: false,
 			wantSession:   nil,
 		},
@@ -506,7 +519,7 @@ func TestServeAuth(t *testing.T) {
 			path:       "/api/auth",
 			cookie:     successCookie,
 			wantStatus: http.StatusOK,
-			wantResp:   &authResponse{OK: false, AuthNeeded: tailscaleAuth},
+			wantResp:   &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
 			wantSession: &browserSession{
 				ID:            successCookie,
 				SrcNode:       remoteNode.Node.ID,
@@ -554,7 +567,7 @@ func TestServeAuth(t *testing.T) {
 			path:       "/api/auth",
 			cookie:     successCookie,
 			wantStatus: http.StatusOK,
-			wantResp:   &authResponse{OK: true},
+			wantResp:   &authResponse{CanManageNode: true, ViewerIdentity: vi},
 			wantSession: &browserSession{
 				ID:            successCookie,
 				SrcNode:       remoteNode.Node.ID,

+ 303 - 1
client/web/yarn.lock

@@ -202,6 +202,13 @@
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
   integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
 
+"@babel/runtime@^7.13.10":
+  version "7.23.2"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
+  integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.22.15":
   version "7.22.15"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
@@ -369,6 +376,33 @@
   resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
   integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
 
+"@floating-ui/core@^1.4.2":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c"
+  integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==
+  dependencies:
+    "@floating-ui/utils" "^0.1.3"
+
+"@floating-ui/dom@^1.5.1":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa"
+  integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==
+  dependencies:
+    "@floating-ui/core" "^1.4.2"
+    "@floating-ui/utils" "^0.1.3"
+
+"@floating-ui/react-dom@^2.0.0":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec"
+  integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==
+  dependencies:
+    "@floating-ui/dom" "^1.5.1"
+
+"@floating-ui/utils@^0.1.3":
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
+  integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
+
 "@jest/schemas@^29.6.0":
   version "29.6.0"
   resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
@@ -429,6 +463,197 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd"
+  integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d"
+  integrity sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-primitive" "1.0.3"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
+  integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
+  integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
+  integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+    "@radix-ui/react-use-escape-keydown" "1.0.3"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
+  integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
+  integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
+  integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-layout-effect" "1.0.1"
+
+"@radix-ui/react-popover@^1.0.6":
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
+  integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-context" "1.0.1"
+    "@radix-ui/react-dismissable-layer" "1.0.5"
+    "@radix-ui/react-focus-guards" "1.0.1"
+    "@radix-ui/react-focus-scope" "1.0.4"
+    "@radix-ui/react-id" "1.0.1"
+    "@radix-ui/react-popper" "1.1.3"
+    "@radix-ui/react-portal" "1.0.4"
+    "@radix-ui/react-presence" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-slot" "1.0.2"
+    "@radix-ui/react-use-controllable-state" "1.0.1"
+    aria-hidden "^1.1.1"
+    react-remove-scroll "2.5.5"
+
+"@radix-ui/[email protected]":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
+  integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@floating-ui/react-dom" "^2.0.0"
+    "@radix-ui/react-arrow" "1.0.3"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-context" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+    "@radix-ui/react-use-layout-effect" "1.0.1"
+    "@radix-ui/react-use-rect" "1.0.1"
+    "@radix-ui/react-use-size" "1.0.1"
+    "@radix-ui/rect" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
+  integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-primitive" "1.0.3"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
+  integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-use-layout-effect" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
+  integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-slot" "1.0.2"
+
+"@radix-ui/[email protected]":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
+  integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
+  integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
+  integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
+  integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
+  integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"
+  integrity sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/rect" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2"
+  integrity sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-layout-effect" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f"
+  integrity sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
 "@rollup/pluginutils@^5.0.2":
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.2.tgz#012b8f53c71e4f6f9cb317e311df1404f56e7a33"
@@ -741,6 +966,13 @@ argparse@^2.0.1:
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
+aria-hidden@^1.1.1:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
+  integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==
+  dependencies:
+    tslib "^2.0.0"
+
 assertion-error@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
@@ -936,6 +1168,11 @@ deep-eql@^4.1.2:
   dependencies:
     type-detect "^4.0.0"
 
+detect-node-es@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
+  integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
+
 didyoumean@^1.2.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@@ -1066,6 +1303,11 @@ get-func-name@^2.0.0:
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
   integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
 
+get-nonce@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
+  integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
+
 glob-parent@^5.1.2, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -1140,6 +1382,13 @@ inherits@2:
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+invariant@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+  dependencies:
+    loose-envify "^1.0.0"
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -1228,7 +1477,7 @@ local-pkg@^0.4.3:
   resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
   integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
 
-loose-envify@^1.1.0:
+loose-envify@^1.0.0, loose-envify@^1.1.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
   integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -1510,6 +1759,34 @@ react-is@^18.0.0:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
 
+react-remove-scroll-bar@^2.3.3:
+  version "2.3.4"
+  resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
+  integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==
+  dependencies:
+    react-style-singleton "^2.2.1"
+    tslib "^2.0.0"
+
[email protected]:
+  version "2.5.5"
+  resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
+  integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
+  dependencies:
+    react-remove-scroll-bar "^2.3.3"
+    react-style-singleton "^2.2.1"
+    tslib "^2.1.0"
+    use-callback-ref "^1.3.0"
+    use-sidecar "^1.1.2"
+
+react-style-singleton@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
+  integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
+  dependencies:
+    get-nonce "^1.0.0"
+    invariant "^2.2.4"
+    tslib "^2.0.0"
+
 react@^18.2.0:
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@@ -1542,6 +1819,11 @@ recrawl-sync@^2.0.3:
     sucrase "^3.20.3"
     tslib "^1.9.3"
 
+regenerator-runtime@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
+  integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
+
 resolve-from@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -1742,6 +2024,11 @@ tslib@^1.9.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
+tslib@^2.0.0, tslib@^2.1.0:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
+  integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+
 type-detect@^4.0.0, type-detect@^4.0.5:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
@@ -1765,6 +2052,21 @@ update-browserslist-db@^1.0.11:
     escalade "^3.1.1"
     picocolors "^1.0.0"
 
+use-callback-ref@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
+  integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==
+  dependencies:
+    tslib "^2.0.0"
+
+use-sidecar@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
+  integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
+  dependencies:
+    detect-node-es "^1.1.0"
+    tslib "^2.0.0"
+
 use-sync-external-store@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"