mirror of https://github.com/tailscale/tailscale/
client/web: copy existing UI to basic react components
This copies the existing go template frontend into very crude react components that will be driven by a simple JSON api for fetching and updating data. For now, this returns a static set of test data. This just implements the simple existing UI, so I've put these all in a "legacy" component, with the expectation that we will rebuild this with more properly defined components, some pulled from corp. Updates tailscale/corp#13775 Signed-off-by: Will Norris <will@tailscale.com>pull/8902/head
parent
ddba4824c4
commit
9c4364e0b7
@ -1,4 +1,29 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
<html class="bg-gray-50">
|
||||||
|
<head>
|
||||||
|
<title>Tailscale</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3>
|
||||||
|
<p class="mb-2">
|
||||||
|
Update to a modern browser to access the Tailscale web client. You can use
|
||||||
|
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>,
|
||||||
|
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>,
|
||||||
|
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>,
|
||||||
|
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p>
|
||||||
|
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<noscript>
|
||||||
|
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||||
|
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||||
|
</noscript>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,5 +1,18 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||||
|
import useNodeData from "src/hooks/node-data"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <div className="text-center">Hello world</div>
|
const data = useNodeData()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
<IP data={data} />
|
||||||
|
<State data={data} />
|
||||||
|
</main>
|
||||||
|
<Footer data={data} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,272 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { NodeData } from "src/hooks/node-data"
|
||||||
|
|
||||||
|
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||||
|
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||||
|
// purely to ease migration to the new React-based web client, and will
|
||||||
|
// eventually be completely removed.
|
||||||
|
|
||||||
|
export function Header(props: { data: NodeData }) {
|
||||||
|
const { data } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 23 23"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="flex-shrink-0 mr-4"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
opacity="0.2"
|
||||||
|
cx="3.4"
|
||||||
|
cy="3.25"
|
||||||
|
r="2.7"
|
||||||
|
fill="currentColor"
|
||||||
|
></circle>
|
||||||
|
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||||
|
<circle
|
||||||
|
opacity="0.2"
|
||||||
|
cx="3.4"
|
||||||
|
cy="19.5"
|
||||||
|
r="2.7"
|
||||||
|
fill="currentColor"
|
||||||
|
></circle>
|
||||||
|
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||||
|
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
|
||||||
|
<circle
|
||||||
|
opacity="0.2"
|
||||||
|
cx="11.5"
|
||||||
|
cy="3.25"
|
||||||
|
r="2.7"
|
||||||
|
fill="currentColor"
|
||||||
|
></circle>
|
||||||
|
<circle
|
||||||
|
opacity="0.2"
|
||||||
|
cx="19.5"
|
||||||
|
cy="3.25"
|
||||||
|
r="2.7"
|
||||||
|
fill="currentColor"
|
||||||
|
></circle>
|
||||||
|
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||||
|
<circle
|
||||||
|
opacity="0.2"
|
||||||
|
cx="19.5"
|
||||||
|
cy="19.5"
|
||||||
|
r="2.7"
|
||||||
|
fill="currentColor"
|
||||||
|
></circle>
|
||||||
|
</svg>
|
||||||
|
<div className="flex items-center justify-end space-x-2 w-2/3">
|
||||||
|
{data.Profile && (
|
||||||
|
<>
|
||||||
|
<div className="text-right w-full leading-4">
|
||||||
|
<h4 className="truncate leading-normal">
|
||||||
|
{data.Profile.LoginName}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 text-right">
|
||||||
|
<a href="#" className="hover:text-gray-700 js-loginButton">
|
||||||
|
Switch account
|
||||||
|
</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="#" className="hover:text-gray-700 js-loginButton">
|
||||||
|
Reauthenticate
|
||||||
|
</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="#" className="hover:text-gray-700 js-logoutButton">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||||
|
{data.Profile.ProfilePicURL ? (
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IP(props: { data: NodeData }) {
|
||||||
|
const { data } = props
|
||||||
|
|
||||||
|
if (!data.IP) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
|
||||||
|
<div className="flex items-center min-width-0">
|
||||||
|
<svg
|
||||||
|
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold truncate mr-2">{data.DeviceName}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5>{data.IP}</h5>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
|
||||||
|
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
|
||||||
|
{data.IsSynology && (
|
||||||
|
<>
|
||||||
|
, DSM{data.DSMVersion}
|
||||||
|
{data.TUNMode || (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
<a
|
||||||
|
href="https://tailscale.com/kb/1152/synology-outbound/"
|
||||||
|
className="link-underline text-gray-600"
|
||||||
|
target="_blank"
|
||||||
|
aria-label="Configure outbound synology traffic"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
outgoing access not configured
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function State(props: { data: NodeData }) {
|
||||||
|
const { data } = props
|
||||||
|
|
||||||
|
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>
|
||||||
|
<a href="#" className="mb-4 js-loginButton" target="_blank">
|
||||||
|
<button className="button button-blue w-full">
|
||||||
|
Reauthenticate
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} 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, learn more at{" "}
|
||||||
|
<a
|
||||||
|
href="https://tailscale.com/"
|
||||||
|
className="link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
tailscale.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="#" className="mb-4 js-loginButton" target="_blank">
|
||||||
|
<button className="button button-blue w-full">Log In</button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<div className="mb-4">
|
||||||
|
<a href="#" className="mb-4 js-advertiseExitNode">
|
||||||
|
{data.AdvertiseExitNode ? (
|
||||||
|
<button
|
||||||
|
className="button button-red button-medium"
|
||||||
|
id="enabled"
|
||||||
|
>
|
||||||
|
Stop advertising Exit Node
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="button button-blue button-medium"
|
||||||
|
id="enabled"
|
||||||
|
>
|
||||||
|
Advertise as Exit Node
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer(props: { data: NodeData }) {
|
||||||
|
const { data } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="container max-w-lg mx-auto text-center">
|
||||||
|
<a
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-600"
|
||||||
|
href={data.LicensesURL}
|
||||||
|
>
|
||||||
|
Open Source Licenses
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
export type UserProfile = {
|
||||||
|
LoginName: string
|
||||||
|
DisplayName: string
|
||||||
|
ProfilePicURL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeData = {
|
||||||
|
Profile: UserProfile
|
||||||
|
Status: string
|
||||||
|
DeviceName: string
|
||||||
|
IP: string
|
||||||
|
AdvertiseExitNode: boolean
|
||||||
|
AdvertiseRoutes: string
|
||||||
|
LicensesURL: string
|
||||||
|
TUNMode: boolean
|
||||||
|
IsSynology: boolean
|
||||||
|
DSMVersion: number
|
||||||
|
IsUnraid: boolean
|
||||||
|
UnraidToken: string
|
||||||
|
IPNVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// testData is static set of nodedata used during development.
|
||||||
|
// This can be removed once we have a real node data API.
|
||||||
|
const testData: NodeData = {
|
||||||
|
Profile: {
|
||||||
|
LoginName: "amelie",
|
||||||
|
DisplayName: "Amelie Pangolin",
|
||||||
|
ProfilePicURL: "https://login.tailscale.com/logo192.png",
|
||||||
|
},
|
||||||
|
Status: "Running",
|
||||||
|
DeviceName: "amelies-laptop",
|
||||||
|
IP: "100.1.2.3",
|
||||||
|
AdvertiseExitNode: false,
|
||||||
|
AdvertiseRoutes: "",
|
||||||
|
LicensesURL: "https://tailscale.com/licenses/tailscale",
|
||||||
|
TUNMode: false,
|
||||||
|
IsSynology: true,
|
||||||
|
DSMVersion: 7,
|
||||||
|
IsUnraid: false,
|
||||||
|
UnraidToken: "",
|
||||||
|
IPNVersion: "0.1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// useNodeData returns basic data about the current node.
|
||||||
|
export default function useNodeData() {
|
||||||
|
return testData
|
||||||
|
}
|
@ -1,3 +1,130 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-Tailwind styles begin here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.bg-gray-0 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-50 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
--text-opacity: 1;
|
||||||
|
color: #4b70cc;
|
||||||
|
color: rgba(75, 112, 204, var(--text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover,
|
||||||
|
.link:active {
|
||||||
|
--text-opacity: 1;
|
||||||
|
color: #19224a;
|
||||||
|
color: rgba(25, 34, 74, var(--text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-underline:hover,
|
||||||
|
.link-underline:active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-muted {
|
||||||
|
/* same as text-gray-500 */
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-muted:hover,
|
||||||
|
.link-muted:active {
|
||||||
|
/* same as text-gray-500 */
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-weight: 500;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
transition-property: background-color, border-color, color, box-shadow;
|
||||||
|
transition-duration: 120ms;
|
||||||
|
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-blue {
|
||||||
|
--bg-opacity: 1;
|
||||||
|
background-color: #4b70cc;
|
||||||
|
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||||
|
--border-opacity: 1;
|
||||||
|
border-color: #4b70cc;
|
||||||
|
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||||
|
--text-opacity: 1;
|
||||||
|
color: #fff;
|
||||||
|
color: rgba(255, 255, 255, var(--text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-blue:enabled:hover {
|
||||||
|
--bg-opacity: 1;
|
||||||
|
background-color: #3f5db3;
|
||||||
|
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||||
|
--border-opacity: 1;
|
||||||
|
border-color: #3f5db3;
|
||||||
|
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-blue:disabled {
|
||||||
|
--text-opacity: 1;
|
||||||
|
color: #cedefd;
|
||||||
|
color: rgba(206, 222, 253, var(--text-opacity));
|
||||||
|
--bg-opacity: 1;
|
||||||
|
background-color: #6c94ec;
|
||||||
|
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||||
|
--border-opacity: 1;
|
||||||
|
border-color: #6c94ec;
|
||||||
|
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-red {
|
||||||
|
background-color: #d04841;
|
||||||
|
border-color: #d04841;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-red:enabled:hover {
|
||||||
|
background-color: #b22d30;
|
||||||
|
border-color: #b22d30;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue