client/web: split out UI components

This commit makes the following structural changes to the web
client interface. No user-visible changes.

1. Splits login, legacy, readonly, and full management clients into
   their own components, and pulls them out into their own view files.
2. Renders the same Login component for all scenarios when the client
   is not logged in, regardless of legacy or debug mode. Styling comes
   from the existing legacy login, which is removed from legacy.tsx
   now that it is shared.
3. Adds a ui folder to hold non-Tailscale-specific components,
   starting with ProfilePic, previously housed in app.tsx.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/9987/head
Sonia Appasamy 7 months ago committed by Sonia Appasamy
parent 89953b015b
commit 6f214dec48

@ -1,35 +1,37 @@
import cx from "classnames"
import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy"
import useAuth, { AuthResponse } from "src/hooks/auth"
import LegacyClientView from "src/components/views/legacy-client-view"
import LoginClientView from "src/components/views/login-client-view"
import ReadonlyClientView from "src/components/views/readonly-client-view"
import useAuth from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
import ManagementClientView from "./views/management-client-view"
export default function App() {
// TODO(sonia): use isPosting value from useNodeData
// to fill loading states.
const { data, refreshData, updateNode } = useNodeData()
if (!data) {
// TODO(sonia): add a loading view
return <div className="text-center py-14">Loading...</div>
}
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
return !needsLogin &&
(data.DebugMode === "login" || data.DebugMode === "full") ? (
<WebClient {...data} />
) : (
// Legacy client UI
<div className="py-14">
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
<State data={data} updateNode={updateNode} />
</main>
<Footer licensesURL={data.LicensesURL} />
return (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
{!data ? (
<div className="text-center">Loading...</div> // TODO(sonia): add a loading view
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
// Client not on a tailnet, render login.
<LoginClientView
data={data}
onLoginClick={() => updateNode({ Reauthenticate: true })}
/>
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
// Render new client interface.
<WebClient {...data} />
) : (
// Render legacy client interface.
<LegacyClientView
data={data}
refreshData={refreshData}
updateNode={updateNode}
/>
)}
{data && <Footer licensesURL={data.LicensesURL} />}
</div>
)
}
@ -41,116 +43,24 @@ function WebClient(props: NodeData) {
return <div className="text-center py-14">Loading...</div>
}
return (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
{props.DebugMode === "full" && auth?.ok ? (
<ManagementView {...props} />
) : (
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
)}
<Footer className="mt-20" licensesURL={props.LicensesURL} />
</div>
)
}
function ReadonlyView({
data,
auth,
waitOnAuth,
}: {
data: NodeData
auth?: AuthResponse
waitOnAuth: () => 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">
Owned 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>
{data.DebugMode === "full" && (
<button
className="button button-blue ml-6"
onClick={() => {
window.open(auth?.authUrl, "_blank")
waitOnAuth()
}}
>
Access
</button>
)}
</div>
</div>
</>
)
}
function ManagementView(props: NodeData) {
return (
<div className="px-5">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div>
return props.DebugMode === "full" && auth?.ok ? (
<ManagementClientView {...props} />
) : (
<ReadonlyClientView data={props} auth={auth} waitOnAuth={waitOnAuth} />
)
}
function ProfilePic({ url }: { url: string }) {
export function Footer(props: { licensesURL: string; className?: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

@ -8,6 +8,52 @@ import { NodeData, NodeUpdate } from "src/hooks/node-data"
// purely to ease migration to the new React-based web client, and will
// eventually be completely removed.
export default function LegacyClientView({
data,
refreshData,
updateNode,
}: {
data: NodeData
refreshData: () => void
updateNode: (update: NodeUpdate) => void
}) {
return (
<div className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
{data.Status === "NeedsMachineAuth" ? (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
) : (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<button
className={cx("button button-medium mb-4", {
"button-red": data.AdvertiseExitNode,
"button-blue": !data.AdvertiseExitNode,
})}
id="enabled"
onClick={() =>
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
}
>
{data.AdvertiseExitNode
? "Stop advertising Exit Node"
: "Advertise as Exit Node"}
</button>
</>
)}
</div>
)
}
export function Header({
data,
refreshData,
@ -184,115 +230,3 @@ export function IP(props: { data: NodeData }) {
</>
)
}
export function State({
data,
updateNode,
}: {
data: NodeData
updateNode: (update: NodeUpdate) => void
}) {
switch (data.Status) {
case "NeedsLogin":
case "NoState":
if (data.IP) {
return (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
>
learn more
</a>
.
</p>
</div>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
)
} else {
return (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
>
tailscale.com
</a>
.
</p>
</div>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Log In
</button>
</>
)
}
case "NeedsMachineAuth":
return (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
)
default:
return (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<button
className={cx("button button-medium mb-4", {
"button-red": data.AdvertiseExitNode,
"button-blue": !data.AdvertiseExitNode,
})}
id="enabled"
onClick={() =>
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
}
>
{data.AdvertiseExitNode
? "Stop advertising Exit Node"
: "Advertise as Exit Node"}
</button>
</>
)
}
}
export function Footer(props: { licensesURL: string; className?: string }) {
return (
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

@ -0,0 +1,65 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
/**
* LoginClientView is rendered when the client is not authenticated
* to a tailnet.
*/
export default function LoginClientView({
data,
onLoginClick,
}: {
data: NodeData
onLoginClick: () => void
}) {
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
{data.IP ? (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
>
learn more
</a>
.
</p>
</div>
<button
onClick={onLoginClick}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
) : (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a href="https://tailscale.com/" className="link" target="_blank">
tailscale.com
</a>
.
</p>
</div>
<button
onClick={onLoginClick}
className="button button-blue w-full mb-4"
>
Log In
</button>
</>
)}
</div>
)
}

@ -0,0 +1,35 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import ProfilePic from "src/ui/profile-pic"
export default function ManagementClientView(props: NodeData) {
return (
<div className="px-5 mb-12">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div>
)
}

@ -0,0 +1,69 @@
import React from "react"
import { AuthResponse } 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,
waitOnAuth,
}: {
data: NodeData
auth?: AuthResponse
waitOnAuth: () => 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>
{data.DebugMode === "full" && (
<button
className="button button-blue ml-6"
onClick={() => {
window.open(auth?.authUrl, "_blank")
waitOnAuth()
}}
>
Access
</button>
)}
</div>
</div>
</>
)
}

@ -0,0 +1,19 @@
import React from "react"
export default function ProfilePic({ url }: { url: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}
Loading…
Cancel
Save