mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 11:05:45 +00:00
client/web: add readonly/manage toggle
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
c54d680682
commit
86c8ab7502
@ -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",
|
||||
|
@ -20,16 +20,16 @@
|
||||
// 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 {
|
||||
|
@ -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.
|
||||
<>
|
||||
{/* TODO(sonia): get rid of the conditions here once full/readonly
|
||||
* views live on same components */}
|
||||
{data.DebugMode === "full" && auth?.ok && <Header node={data} />}
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
{/* TODO: add license to new client */}
|
||||
<Footer licensesURL={data.LicensesURL} />
|
||||
</>
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<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,
|
||||
}: {
|
||||
|
@ -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
client/web/src/components/login-toggle.tsx
Normal file
149
client/web/src/components/login-toggle.tsx
Normal file
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -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"
|
@ -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,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
|
||||
|
@ -21,6 +21,7 @@ export type NodeData = {
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
|
11
client/web/src/icons/eye.svg
Normal file
11
client/web/src/icons/eye.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 738 B |
4
client/web/src/icons/user.svg
Normal file
4
client/web/src/icons/user.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 635 B |
106
client/web/src/ui/popover.tsx
Normal file
106
client/web/src/ui/popover.tsx
Normal file
@ -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
|
||||
)
|
@ -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
|
||||
|
@ -18,32 +18,30 @@
|
||||
|
||||
// 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.
|
||||
|
@ -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 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
|
||||
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],
|
||||
|
@ -410,14 +410,27 @@ func() *ipnstate.PeerStatus { return self },
|
||||
}
|
||||
|
||||
func TestServeAuth(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
user := &tailcfg.UserProfile{LoginName: "user@example.com", 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() *ipnstate.PeerStatus { return self },
|
||||
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() *ipnstate.PeerStatus { return self },
|
||||
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() *ipnstate.PeerStatus { return self },
|
||||
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,
|
||||
|
@ -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/primitive@1.0.1":
|
||||
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/react-arrow@1.0.3":
|
||||
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/react-compose-refs@1.0.1":
|
||||
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/react-context@1.0.1":
|
||||
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/react-dismissable-layer@1.0.5":
|
||||
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/react-focus-guards@1.0.1":
|
||||
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/react-focus-scope@1.0.4":
|
||||
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/react-id@1.0.1":
|
||||
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/react-popper@1.1.3":
|
||||
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/react-portal@1.0.4":
|
||||
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/react-presence@1.0.1":
|
||||
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/react-primitive@1.0.3":
|
||||
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/react-slot@1.0.2":
|
||||
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/react-use-callback-ref@1.0.1":
|
||||
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/react-use-controllable-state@1.0.1":
|
||||
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/react-use-escape-keydown@1.0.3":
|
||||
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/react-use-layout-effect@1.0.1":
|
||||
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/react-use-rect@1.0.1":
|
||||
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/react-use-size@1.0.1":
|
||||
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/rect@1.0.1":
|
||||
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"
|
||||
|
||||
react-remove-scroll@2.5.5:
|
||||
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"
|
||||
|
Loading…
Reference in New Issue
Block a user