client/web: button, link, and other small UI updates

Makes the following changes:
* Use “link” class in various spots
* Remove button appearance on Exit Node dropdown in readonly mode
* Update `-stone-` colors to `-gray-` (couple spots missed by
  original color config commit)
* Pull full ui/button component from admin panel, and update
  buttons throughout UI to use this component
* Remove various buttons in readonly view to match mocks
* Add route (and “pending approval”) highlights to Subnet router
  settings card
* Delete legacy client button styles from index.css
* Fix overflow of IPv6 address on device details view

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy
2023-12-04 15:20:38 -05:00
committed by Sonia Appasamy
parent 64a26b221b
commit 95e9d22a16
14 changed files with 545 additions and 333 deletions

View File

@@ -153,10 +153,7 @@ function Header({
<LoginToggle node={node} auth={auth} newSession={newSession} />
</div>
{loc !== "/" && loc !== "/update" && (
<Link
to="/"
className="text-blue-500 font-medium leading-snug block mb-[10px]"
>
<Link to="/" className="link font-medium block mb-[10px]">
&larr; Back to {node.DeviceName}
</Link>
)}

View File

@@ -85,10 +85,12 @@ export default function ExitNodeSelector({
>
<button
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-blue-500 hover:bg-blue-400": using,
"cursor-not-allowed": disabled,
"bg-white": none,
"hover:bg-gray-100": none && !disabled,
"bg-orange-600": advertising,
"hover:bg-orange-400": advertising && !disabled,
"bg-blue-500": using,
"hover:bg-blue-400": using && !disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
@@ -116,27 +118,27 @@ export default function ExitNodeSelector({
? "Running as exit node"
: selected.Name}
</p>
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
{!disabled && (
<ChevronDown
className={cx("ml-1", {
"stroke-gray-800": none,
"stroke-white": advertising || using,
})}
/>
)}
</div>
</button>
{(advertising || using) && (
{!disabled && (advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white", {
"bg-orange-400": advertising,
"bg-blue-400": using,
"cursor-not-allowed": disabled,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
disabled={disabled}
>
Disable
</button>
@@ -252,7 +254,7 @@ function ExitNodeSelectorItem({
return (
<button
key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100"
onClick={onSelect}
>
<div>

View File

@@ -230,9 +230,11 @@ function SignInButton({
}) {
return (
<Button
className={cx("w-full text-sm mt-2", {
className={cx("text-center w-full mt-2", {
"mb-2": auth.viewerIdentity,
})}
intent="primary"
sizeVariant="small"
onClick={onClick}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}

View File

@@ -3,13 +3,16 @@
import React from "react"
import { VersionInfo } from "src/hooks/self-update"
import { Link } from "wouter"
import Button from "src/ui/button"
import { useLocation } from "wouter"
export function UpdateAvailableNotification({
details,
}: {
details: VersionInfo
}) {
const [, setLocation] = useLocation()
return (
<div className="card">
<h2 className="mb-2">
@@ -22,12 +25,13 @@ export function UpdateAvailableNotification({
: "A new update"}{" "}
is now available. <ChangelogText version={details.LatestVersion} />
</p>
<Link
className="button button-blue mt-3 text-sm inline-block"
to="/update"
<Button
className="mt-3 inline-block"
sizeVariant="small"
onClick={() => setLocation("/update")}
>
Update now
</Link>
</Button>
</div>
)
}

View File

@@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
import { useLocation } from "wouter"
export default function DeviceDetailsView({
@@ -34,20 +35,18 @@ export default function DeviceDetailsView({
})}
/>
</div>
<button
className={cx(
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-gray-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>
{!readonly && (
<Button
sizeVariant="small"
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
>
Disconnect
</Button>
)}
</div>
</div>
{node.Features["auto-update"] &&

View File

@@ -2,12 +2,13 @@
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import React, { useMemo } from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { Link } from "wouter"
import { pluralize } from "src/util"
import { Link, useLocation } from "wouter"
export default function HomeView({
readonly,
@@ -18,6 +19,14 @@ export default function HomeView({
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
node.AdvertisedRoutes?.length,
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
],
[node.AdvertisedRoutes]
)
return (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
@@ -42,41 +51,63 @@ export default function HomeView({
disabled={readonly}
/>
)}
<Link className="text-blue-500 font-medium leading-snug" to="/details">
<Link className="link font-medium" to="/details">
View device details &rarr;
</Link>
</div>
<h2 className="mb-3">Settings</h2>
{node.Features["advertise-routes"] && (
<SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
className="mb-3"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
}
: undefined
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
<div className="grid gap-3">
{node.Features["advertise-routes"] && (
<SettingsCard
link="/subnets"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
badge={
allSubnetRoutes
? {
text: `${allSubnetRoutes} ${pluralize(
"route",
"routes",
allSubnetRoutes
)}`,
}
: undefined
}
footer={
pendingSubnetRoutes
? `${pendingSubnetRoutes} ${pluralize(
"route",
"routes",
pendingSubnetRoutes
)} pending approval`
: undefined
}
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: (
<div className="w-2 h-2 bg-emerald-500 rounded-full" />
),
}
: undefined
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */}
</div>
</div>
)
}
@@ -86,6 +117,7 @@ function SettingsCard({
link,
body,
badge,
footer,
className,
}: {
title: string
@@ -95,35 +127,43 @@ function SettingsCard({
text: string
icon?: JSX.Element
}
footer?: string
className?: string
}) {
const [, setLocation] = useLocation()
return (
<Link
to={link}
className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
<button
className={cx("-mx-5 card cursor-pointer", className)}
onClick={() => setLocation(link)}
>
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
<div className="flex justify-between items-center">
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
</div>
</div>
</div>
)}
)}
</div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
</Link>
{footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
</>
)}
</button>
)
}

View File

@@ -5,6 +5,7 @@ import React, { useCallback, useState } from "react"
import { apiFetch } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
@@ -40,12 +41,13 @@ export default function LoginView({
Your device is disconnected from Tailscale.
</p>
</div>
<button
<Button
onClick={() => login({})}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Connect to Tailscale
</button>
</Button>
</>
) : data.IP ? (
<>
@@ -64,12 +66,13 @@ export default function LoginView({
.
</p>
</div>
<button
<Button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Reauthenticate
</button>
</Button>
</>
) : (
<>
@@ -89,7 +92,7 @@ export default function LoginView({
.
</p>
</div>
<button
<Button
onClick={() =>
login({
Reauthenticate: true,
@@ -97,10 +100,11 @@ export default function LoginView({
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
className="w-full mb-4"
intent="primary"
>
Log In
</button>
</Button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">

View File

@@ -19,10 +19,11 @@ export default function SubnetRouterView({
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const advertisedRoutes = useMemo(
() => node.AdvertisedRoutes || [],
[node.AdvertisedRoutes]
)
const [advertisedRoutes, hasUnapprovedRoutes] = useMemo(() => {
const routes = node.AdvertisedRoutes || []
return [routes, routes.find((r) => !r.Approved)]
}, [node.AdvertisedRoutes])
const [inputOpen, setInputOpen] = useState<boolean>(
advertisedRoutes.length === 0 && !readonly
)
@@ -42,42 +43,49 @@ export default function SubnetRouterView({
Learn more &rarr;
</a>
</p>
{inputOpen ? (
<div className="-mx-5 card shadow">
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
<Input
type="text"
className="text-sm"
placeholder="192.168.0.0/24"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<p className="my-2 h-6 text-gray-500 text-sm leading-tight">
Add multiple routes by providing a comma-separated list.
</p>
{!readonly &&
(inputOpen ? (
<div className="-mx-5 card shadow">
<p className="font-medium leading-snug mb-3">
Advertise new routes
</p>
<Input
type="text"
className="text-sm"
placeholder="192.168.0.0/24"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<p className="my-2 h-6 text-gray-500 text-sm leading-tight">
Add multiple routes by providing a comma-separated list.
</p>
<Button
intent="primary"
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={!inputText}
>
Advertise routes
</Button>
</div>
) : (
<Button
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={readonly || !inputText}
intent="primary"
prefixIcon={<Plus />}
onClick={() => setInputOpen(true)}
>
Advertise routes
Advertise new route
</Button>
</div>
) : (
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
<Plus />
Advertise new route
</Button>
)}
))}
<div className="-mx-5 mt-10">
{advertisedRoutes.length > 0 ? (
<>
@@ -96,7 +104,7 @@ export default function SubnetRouterView({
<Clock className="w-4 h-4" />
)}
{r.Approved ? (
<div className="text-emerald-800 text-sm leading-tight">
<div className="text-green-500 text-sm leading-tight">
Approved
</div>
) : (
@@ -105,37 +113,39 @@ export default function SubnetRouterView({
</div>
)}
</div>
<Button
intent="secondary"
className="text-sm font-medium"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
.map((it) => it.Route)
.filter((it) => it !== r.Route)
)
}
disabled={readonly}
>
Stop advertising
</Button>
{!readonly && (
<Button
sizeVariant="small"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
.map((it) => it.Route)
.filter((it) => it !== r.Route)
)
}
>
Stop advertising
</Button>
)}
</div>
</div>
))}
</div>
<Control.AdminContainer
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
node={node}
>
To approve routes, in the admin console go to{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
the machines route settings
</Control.AdminLink>
.
</Control.AdminContainer>
{hasUnapprovedRoutes && (
<Control.AdminContainer
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
node={node}
>
To approve routes, in the admin console go to{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
the machines route settings
</Control.AdminLink>
.
</Control.AdminContainer>
)}
</>
) : (
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-gray-500">
<div className="px-5 py-4 bg-gray-50 rounded-lg border border-gray-200 text-center text-gray-500">
Not advertising any routes
</div>
)}

View File

@@ -10,8 +10,9 @@ import {
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
import Button from "src/ui/button"
import Spinner from "src/ui/spinner"
import { Link } from "wouter"
import { useLocation } from "wouter"
/**
* UpdatingView is rendered when the user initiates a Tailscale update, and
@@ -24,6 +25,7 @@ export function UpdatingView({
versionInfo?: VersionInfo
currentVersion: string
}) {
const [, setLocation] = useLocation()
const { updateState, updateLog } = useInstallUpdate(
currentVersion,
versionInfo
@@ -51,9 +53,13 @@ export function UpdatingView({
: null}
. <ChangelogText version={versionInfo?.LatestVersion} />
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Log in to access
</Link>
</Button>
</>
) : updateState === UpdateState.UpToDate ? (
<>
@@ -63,9 +69,13 @@ export function UpdatingView({
You are already running Tailscale {currentVersion}, which is the
newest version available.
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return
</Link>
</Button>
</>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
@@ -79,9 +89,13 @@ export function UpdatingView({
: null}{" "}
failed.
</p>
<Link className="button button-blue text-sm m-3" to="/">
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
Return
</Link>
</Button>
</>
)}
<pre className="h-64 overflow-scroll m-3">