@ -2,15 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React , { useCallback , use Effect , useState } from "react"
import React , { useCallback , use Memo , useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Eye from "src/assets/icons/eye.svg?react"
import User from "src/assets/icons/user.svg?react"
import { AuthResponse , AuthType } from "src/hooks/auth"
import { AuthResponse , hasAnyEditCapabilities } from "src/hooks/auth"
import { useTSWebConnected } from "src/hooks/ts-web-connected"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
import { assertNever , isHTTPS } from "src/utils/util"
export default function LoginToggle ( {
node ,
@ -22,12 +24,29 @@ export default function LoginToggle({
newSession : ( ) = > Promise < void >
} ) {
const [ open , setOpen ] = useState < boolean > ( false )
const { tsWebConnected , checkTSWebConnection } = useTSWebConnected (
auth . serverMode ,
node . IPv4
)
return (
< Popover
className = "p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
className = "p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
content = {
< LoginPopoverContent node = { node } auth = { auth } newSession = { newSession } / >
auth . serverMode === "readonly" ? (
< ReadonlyModeContent auth = { auth } / >
) : auth . serverMode === "login" ? (
< LoginModeContent
auth = { auth }
node = { node }
tsWebConnected = { tsWebConnected }
checkTSWebConnection = { checkTSWebConnection }
/ >
) : auth . serverMode === "manage" ? (
< ManageModeContent auth = { auth } node = { node } newSession = { newSession } / >
) : (
assertNever ( auth . serverMode )
)
}
side = "bottom"
align = "end"
@ -35,231 +54,306 @@ export default function LoginToggle({
onOpenChange = { setOpen }
asChild
>
{ ! auth . canManageNode ? (
< button
className = { cx (
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]" ,
{ "pr-1" : auth . viewerIdentity , "pr-3" : ! auth . viewerIdentity }
) }
onClick = { ( ) = > setOpen ( ! open ) }
>
< Eye / >
< div className = "text-white leading-snug ml-2 mr-1" > Viewing < / div >
< ChevronDown className = "stroke-white w-[15px] h-[15px]" / >
{ auth . viewerIdentity && (
< ProfilePic
className = "ml-2"
size = "medium"
url = { auth . viewerIdentity . profilePicUrl }
/ >
) }
< / button >
) : (
< div
className = { cx (
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300" ,
{
"bg-transparent" : ! open ,
"bg-gray-300" : open ,
}
) }
>
< button onClick = { ( ) = > setOpen ( ! open ) } >
< ProfilePic
size = "medium"
url = { auth . viewerIdentity ? . profilePicUrl }
/ >
< / button >
< / div >
) }
< div >
{ auth . authorized ? (
< TriggerWhenManaging auth = { auth } open = { open } setOpen = { setOpen } / >
) : (
< TriggerWhenReading auth = { auth } open = { open } setOpen = { setOpen } / >
) }
< / div >
< / Popover >
)
}
function LoginPopoverContent ( {
/ * *
* TriggerWhenManaging is displayed as the trigger for the login popover
* when the user has an active authorized managment session .
* /
function TriggerWhenManaging ( {
auth ,
open ,
setOpen ,
} : {
auth : AuthResponse
open : boolean
setOpen : ( next : boolean ) = > void
} ) {
return (
< div
className = { cx (
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300" ,
{
"bg-transparent" : ! open ,
"bg-gray-300" : open ,
}
) }
>
< button onClick = { ( ) = > setOpen ( ! open ) } >
< ProfilePic size = "medium" url = { auth . viewerIdentity ? . profilePicUrl } / >
< / button >
< / div >
)
}
/ * *
* TriggerWhenReading is displayed as the trigger for the login popover
* when the user is currently in read mode ( doesn ' t have an authorized
* management session ) .
* /
function TriggerWhenReading ( {
auth ,
open ,
setOpen ,
} : {
auth : AuthResponse
open : boolean
setOpen : ( next : boolean ) = > void
} ) {
return (
< button
className = { cx (
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]" ,
{ "pr-1" : auth . viewerIdentity , "pr-3" : ! auth . viewerIdentity }
) }
onClick = { ( ) = > setOpen ( ! open ) }
>
< Eye / >
< div className = "text-white leading-snug ml-2 mr-1" > Viewing < / div >
< ChevronDown className = "stroke-white w-[15px] h-[15px]" / >
{ auth . viewerIdentity && (
< ProfilePic
className = "ml-2"
size = "medium"
url = { auth . viewerIdentity . profilePicUrl }
/ >
) }
< / button >
)
}
/ * *
* PopoverContentHeader is the header for the login popover .
* /
function PopoverContentHeader ( { auth } : { auth : AuthResponse } ) {
return (
< div className = "text-black text-sm font-medium leading-tight mb-1" >
{ auth . authorized ? "Managing" : "Viewing" }
{ auth . viewerIdentity && ` as ${ auth . viewerIdentity . loginName } ` }
< / div >
)
}
/ * *
* PopoverContentFooter is the footer for the login popover .
* /
function PopoverContentFooter ( { auth } : { auth : AuthResponse } ) {
return auth . viewerIdentity ? (
< >
< hr className = "my-2" / >
< div className = "flex items-center" >
< User className = "flex-shrink-0" / >
< p className = "text-gray-500 text-xs ml-2" >
We recognize you because you are accessing this page from { " " }
< span className = "font-medium" >
{ auth . viewerIdentity . nodeName || auth . viewerIdentity . nodeIP }
< / span >
< / p >
< / div >
< / >
) : null
}
/ * *
* ReadonlyModeContent is the body of the login popover when the web
* client is being run in "readonly" server mode .
* /
function ReadonlyModeContent ( { auth } : { auth : AuthResponse } ) {
return (
< >
< PopoverContentHeader auth = { auth } / >
< p className = "text-gray-500 text-xs" >
This web interface is running in read - only mode . { " " }
< a
href = "https://tailscale.com/s/web-client-read-only"
className = "text-blue-700"
target = "_blank"
rel = "noreferrer"
>
Learn more & rarr ;
< / a >
< / p >
< PopoverContentFooter auth = { auth } / >
< / >
)
}
/ * *
* LoginModeContent is the body of the login popover when the web
* client is being run in "login" server mode .
* /
function LoginModeContent ( {
node ,
auth ,
newSession ,
tsWebConnected ,
checkTSWebConnection ,
} : {
node : NodeData
auth : AuthResponse
newSession : ( ) = > Promise < void >
tsWebConnected : boolean
checkTSWebConnection : ( ) = > void
} ) {
/ * *
* canConnectOverTS indicates whether the current viewer
* is able to hit the node 's web client that' s being served
* at http : //${node.IP}:5252. If false, this means that the
* viewer must connect to the correct tailnet before being
* able to sign in .
* /
const [ canConnectOverTS , setCanConnectOverTS ] = useState < boolean > ( false )
const [ isRunningCheck , setIsRunningCheck ] = useState < boolean > ( false )
// Whether the current page is loaded over HTTPS.
// If it is, then the connectivity check to the management client
// will fail with a mixed-content error.
const isHTTPS = window . location . protocol === "https:"
const https = isHTTPS ( )
// We can't run the ts web connection test when the webpage is loaded
// over HTTPS. So in this case, we default to presenting a login button
// with some helper text reminding the user to check their connection
// themselves.
const hasACLAccess = https || tsWebConnected
const checkTSConnection = useCallback ( ( ) = > {
if ( auth . viewerIdentity || isHTTPS ) {
// Skip the connectivity check if we either already know we're connected over Tailscale,
// or know the connectivity check will fail because the current page is loaded over HTTPS.
setCanConnectOverTS ( true )
return
const hasEditCaps = useMemo ( ( ) = > {
if ( ! auth . viewerIdentity ) {
// If not connected to login client over tailscale, we won't know the viewer's
// identity. So we must assume they may be able to edit something and have the
// management client handle permissions once the user gets there.
return true
}
// Otherwise, test connection to the ts IP.
if ( isRunningCheck ) {
return // already checking
}
setIsRunningCheck ( true )
fetch ( ` http:// ${ node . IPv4 } :5252/ok ` , { mode : "no-cors" } )
. then ( ( ) = > {
setCanConnectOverTS ( true )
setIsRunningCheck ( false )
} )
. catch ( ( ) = > setIsRunningCheck ( false ) )
} , [ auth . viewerIdentity , isRunningCheck , node . IPv4 , isHTTPS ] )
/ * *
* Checking connection for first time on page load .
*
* While not connected , we check again whenever the mouse
* enters the popover component , to pick up on the user
* leaving to turn on Tailscale then returning to the view .
* See ` onMouseEnter ` on the div below .
* /
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect ( ( ) = > checkTSConnection ( ) , [ ] )
return hasAnyEditCapabilities ( auth )
} , [ auth ] )
const handleSignInClick = useCallback ( ( ) = > {
if ( auth . viewerIdentity && auth . serverMode === "manage" ) {
if ( window . self !== window . top ) {
// if we're inside an iframe, start session in new window
let url = new URL ( window . location . href )
url . searchParams . set ( "check" , "now" )
window . open ( url , "_blank" )
} else {
newSession ( )
}
const handleLogin = useCallback ( ( ) = > {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = ` http:// ${ node . IPv4 } :5252/?check=now `
if ( window . self !== window . top ) {
// If we're inside an iframe, open management client in new window.
window . open ( manageURL , "_blank" )
} else {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = ` http:// ${ node . IPv4 } :5252/?check=now `
if ( window . self !== window . top ) {
// if we're inside an iframe, open management client in new window
window . open ( manageURL , "_blank" )
} else {
window . location . href = manageURL
}
window . location . href = manageURL
}
} , [ auth . viewerIdentity , auth . serverMode , newSession , node . IPv4 ] )
} , [ node . IPv4 ] )
return (
< div onMouseEnter = { ! canConnectOverTS ? checkTSConnection : undefined } >
< div className = "text-black text-sm font-medium leading-tight mb-1" >
{ ! auth . canManageNode ? "Viewing" : "Managing" }
{ auth . viewerIdentity && ` as ${ auth . viewerIdentity . loginName } ` }
< / div >
{ ! auth . canManageNode && (
< div
onMouseEnter = {
hasEditCaps && ! hasACLAccess ? checkTSWebConnection : undefined
}
>
< PopoverContentHeader auth = { auth } / >
{ ! hasACLAccess || ! hasEditCaps ? (
< >
{ auth . serverMode === "readonly" ? (
< p className = "text-gray-500 text-xs" >
This web interface is running in read - only mode . { " " }
< a
href = "https://tailscale.com/s/web-client-read-only"
className = "text-blue-700"
target = "_blank"
rel = "noreferrer"
>
Learn more & rarr ;
< / a >
< / p >
) : ! auth . viewerIdentity ? (
// User is not connected over Tailscale.
// These states are only possible on the login client.
< >
{ ! canConnectOverTS ? (
< >
< p className = "text-gray-500 text-xs" >
{ ! node . ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access.
< >
The current tailnet policy file does not allow
connecting to this device .
< / >
) : (
// ACLs allow access, but user can't connect.
< >
Cannot access this device ’ s Tailscale IP . Make sure you
are connected to your tailnet , and that your policy file
allows access .
< / >
) } { " " }
< a
href = "https://tailscale.com/s/web-client-connection"
className = "text-blue-700"
target = "_blank"
rel = "noreferrer"
>
Learn more & rarr ;
< / a >
< / p >
< / >
) : (
// User can connect to Tailcale IP; sign in when ready.
< >
< p className = "text-gray-500 text-xs" >
You can see most of this device ’ s details . To make changes ,
you need to sign in .
< / p >
{ isHTTPS && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
< p className = "text-gray-500 text-xs font-semibold pt-2" >
Make sure you are connected to your tailnet , and that your
policy file allows access .
< / p >
) }
< SignInButton auth = { auth } onClick = { handleSignInClick } / >
< / >
) }
< / >
) : auth . authNeeded === AuthType . tailscale ? (
// User is connected over Tailscale, but needs to complete check mode.
< >
< p className = "text-gray-500 text-xs" >
To make changes , sign in to confirm your identity . This extra
step helps us keep your device secure .
< / p >
< SignInButton auth = { auth } onClick = { handleSignInClick } / >
< / >
) : (
// User is connected over tailscale, but doesn't have permission to manage.
< p className = "text-gray-500 text-xs" >
You don ’ t have permission to make changes to this device , but you
can view most of its details .
< / p >
) }
< p className = "text-gray-500 text-xs" >
{ ! hasEditCaps ? (
// ACLs allow access, but user isn't allowed to edit any features,
// restricted to readonly. No point in sending them over to the
// tailscaleIP:5252 address.
< >
You don ’ t have permission to make changes to this device , but
you can view most of its details .
< / >
) : ! node . ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access to anyone.
< >
The current tailnet policy file does not allow connecting to
this device .
< / >
) : (
// ACLs don't allow access to this user specifically.
< >
Cannot access this device ’ s Tailscale IP . Make sure you are
connected to your tailnet , and that your policy file allows
access .
< / >
) } { " " }
< a
href = "https://tailscale.com/s/web-client-access"
className = "text-blue-700"
target = "_blank"
rel = "noreferrer"
>
Learn more & rarr ;
< / a >
< / p >
< / >
) }
{ auth . viewerIdentity && (
) : (
// User can connect to Tailcale IP; sign in when ready.
< >
< hr className = "my-2" / >
< div className = "flex items-center" >
< User className = "flex-shrink-0" / >
< p className = "text-gray-500 text-xs ml-2" >
We recognize you because you are accessing this page from { " " }
< span className = "font-medium" >
{ auth . viewerIdentity . nodeName || auth . viewerIdentity . nodeIP }
< / span >
< p className = "text-gray-500 text-xs" >
You can see most of this device ’ s details . To make changes , you need
to sign in .
< / p >
{ https && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
< p className = "text-gray-500 text-xs font-semibold pt-2" >
Make sure you are connected to your tailnet , and that your policy
file allows access .
< / p >
< / div >
) }
< SignInButton auth = { auth } onClick = { handleLogin } / >
< / >
) }
< PopoverContentFooter auth = { auth } / >
< / div >
)
}
/ * *
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode .
* /
function ManageModeContent ( {
auth ,
newSession ,
} : {
node : NodeData
auth : AuthResponse
newSession : ( ) = > void
} ) {
const handleLogin = useCallback ( ( ) = > {
if ( window . self !== window . top ) {
// If we're inside an iframe, start session in new window.
let url = new URL ( window . location . href )
url . searchParams . set ( "check" , "now" )
window . open ( url , "_blank" )
} else {
newSession ( )
}
} , [ newSession ] )
const hasAnyPermissions = useMemo ( ( ) = > hasAnyEditCapabilities ( auth ) , [ auth ] )
return (
< >
< PopoverContentHeader auth = { auth } / >
{ ! auth . authorized &&
( hasAnyPermissions ? (
// User is connected over Tailscale, but needs to complete check mode.
< >
< p className = "text-gray-500 text-xs" >
To make changes , sign in to confirm your identity . This extra step
helps us keep your device secure .
< / p >
< SignInButton auth = { auth } onClick = { handleLogin } / >
< / >
) : (
// User is connected over tailscale, but doesn't have permission to manage.
< p className = "text-gray-500 text-xs" >
You don ’ t have permission to make changes to this device , but you
can view most of its details . { " " }
< a
href = "https://tailscale.com/s/web-client-access"
className = "text-blue-700"
target = "_blank"
rel = "noreferrer"
>
Learn more & rarr ;
< / a >
< / p >
) ) }
< PopoverContentFooter auth = { auth } / >
< / >
)
}
function SignInButton ( {
auth ,
onClick ,