client/web: add readonly/manage toggle

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy
2023-11-09 16:19:22 -05:00
committed by Sonia Appasamy
parent c54d680682
commit 86c8ab7502
18 changed files with 731 additions and 189 deletions

View File

@ -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,
}: {

View File

@ -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(

View 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 dont 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>
</>
)}
</>
)
}

View File

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

View File

@ -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"

View File

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