diff --git a/client/web/src/api.ts b/client/web/src/api.ts index f0d46980b..3d41605dc 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -10,7 +10,7 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8 // (i.e. provide `/data` rather than `api/data`). export function apiFetch( endpoint: string, - method: "GET" | "POST", + method: "GET" | "POST" | "PATCH", body?: any, params?: Record ): Promise { diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 95d19e70e..cc5ed366e 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -5,6 +5,7 @@ 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 SSHView from "src/components/views/ssh-view" import useAuth, { AuthResponse } from "src/hooks/auth" import useNodeData, { NodeData } from "src/hooks/node-data" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" @@ -31,7 +32,7 @@ function WebClient({ auth: AuthResponse newSession: () => Promise }) { - const { data, refreshData, updateNode } = useNodeData() + const { data, refreshData, updateNode, updatePrefs } = useNodeData() useEffect(() => { refreshData() }, [auth, refreshData]) @@ -72,7 +73,13 @@ function WebClient({ {/* TODO */}Subnet router - {/* TODO */}Tailscale SSH server + + + {/* TODO */}Share local content

Page not found

diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index c11efcf9b..7942d770c 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -15,7 +15,7 @@ export default function DeviceDetailsView({ const [, setLocation] = useLocation() return ( -
+ <>

Device details

@@ -123,6 +123,6 @@ export default function DeviceDetailsView({ in the admin console.

-
+ ) } diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 3177e1c71..10dbbb824 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -57,6 +57,14 @@ export default function HomeView({ 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:
, + } + : undefined + } />
-

- {title} -

+
+

+ {title} +

+ {badge && ( +
+ {badge.icon} +
+ {badge.text} +
+
+ )} +

{body}

diff --git a/client/web/src/components/views/ssh-view.tsx b/client/web/src/components/views/ssh-view.tsx new file mode 100644 index 000000000..e3d1a03f2 --- /dev/null +++ b/client/web/src/components/views/ssh-view.tsx @@ -0,0 +1,51 @@ +import React from "react" +import { PrefsUpdate } from "src/hooks/node-data" +import Toggle from "src/ui/toggle" + +export default function SSHView({ + readonly, + runningSSH, + updatePrefs, +}: { + readonly: boolean + runningSSH: boolean + updatePrefs: (p: PrefsUpdate) => Promise +}) { + return ( + <> +

Tailscale SSH server

+

+ Run a Tailscale SSH server on this device and allow other devices in + your tailnet to SSH into it.{" "} + + Learn more → + +

+
+ updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })} + disabled={readonly} + /> +
+ Run Tailscale SSH server +
+
+

+ Remember to make sure that the{" "} + + tailnet policy file + {" "} + allows other devices to SSH into this device. +

+ + ) +} diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index a5f624fee..434ba02e4 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -25,6 +25,7 @@ export type NodeData = { TailnetName: string IsTagged: boolean Tags: string[] + RunningSSHServer: boolean DebugMode: "" | "login" | "full" // empty when not running in any debug mode } @@ -50,6 +51,11 @@ export type NodeUpdate = { ForceLogout?: boolean } +export type PrefsUpdate = { + RunSSHSet?: boolean + RunSSH?: boolean +} + // useNodeData returns basic data about the current node. export default function useNodeData() { const [data, setData] = useState() @@ -108,6 +114,7 @@ export default function useNodeData() { refreshData() }) .catch((err) => { + setIsPosting(false) alert("Failed operation: " + err.message) throw err }) @@ -115,6 +122,36 @@ export default function useNodeData() { [data] ) + const updatePrefs = useCallback( + (p: PrefsUpdate) => { + setIsPosting(true) + if (data) { + const optimisticUpdates = data + if (p.RunSSHSet) { + optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH) + } + // Reflect the pref change immediatley on the frontend, + // then make the prefs PATCH. If the request fails, + // data will be updated to it's previous value in + // onComplete below. + setData(optimisticUpdates) + } + + const onComplete = () => { + setIsPosting(false) + refreshData() // refresh data after PATCH finishes + } + + return apiFetch("/local/v0/prefs", "PATCH", p) + .then(onComplete) + .catch(() => { + onComplete() + alert("Failed to update prefs") + }) + }, + [setIsPosting, refreshData, setData, data] + ) + useEffect( () => { // Initial data load. @@ -134,5 +171,5 @@ export default function useNodeData() { [] ) - return { data, refreshData, updateNode, isPosting } + return { data, refreshData, updateNode, updatePrefs, isPosting } } diff --git a/client/web/src/index.css b/client/web/src/index.css index ff72ac040..4863dc97b 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -31,6 +31,107 @@ .card td:last-child { @apply text-neutral-800 text-sm leading-tight; } + + .description { + @apply text-neutral-500 leading-snug + } + + /** + * .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements. + * You can use the -large and -small modifiers for size variants. + */ + .toggle { + @apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer; + transition: background-color 200ms ease-in-out; + } + + .toggle:disabled { + @apply bg-neutral-200; + @apply cursor-not-allowed; + } + + .toggle:checked { + @apply bg-indigo-500; + } + + .toggle:checked:disabled { + @apply bg-indigo-300; + } + + .toggle:focus { + @apply outline-none ring; + } + + .toggle::after { + @apply absolute bg-white rounded-full will-change-[width]; + @apply w-3.5 h-3.5 m-[0.1875rem] translate-x-0; + content: " "; + transition: width 200ms ease, transform 200ms ease; + } + + .toggle:checked::after { + @apply translate-x-5; + } + + .toggle:checked:disabled::after { + @apply bg-indigo-50; + } + + .toggle:enabled:active::after { + @apply w-[1.125rem]; + } + + .toggle:checked:enabled:active::after { + @apply w-[1.125rem] translate-x-3.5; + } + + .toggle-large { + @apply w-12 h-6; + } + + .toggle-large::after { + @apply m-1 w-4 h-4; + } + + .toggle-large:checked::after { + @apply translate-x-6; + } + + .toggle-large:enabled:active::after { + @apply w-6; + } + + .toggle-large:checked:enabled:active::after { + @apply w-6 translate-x-4; + } + + .toggle-small { + @apply w-6 h-3; + } + + .toggle-small:focus { + /** + * We disable ring for .toggle-small because it is a + * small, inline element. + */ + @apply outline-none shadow-none; + } + + .toggle-small::after { + @apply w-2 h-2 m-0.5; + } + + .toggle-small:checked::after { + @apply translate-x-3; + } + + .toggle-small:enabled:active::after { + @apply w-[0.675rem]; + } + + .toggle-small:checked:enabled:active::after { + @apply w-[0.675rem] translate-x-[0.55rem]; + } } /** diff --git a/client/web/src/ui/toggle.tsx b/client/web/src/ui/toggle.tsx new file mode 100644 index 000000000..312d963bb --- /dev/null +++ b/client/web/src/ui/toggle.tsx @@ -0,0 +1,41 @@ +import cx from "classnames" +import React, { ChangeEvent } from "react" + +type Props = { + id?: string + className?: string + disabled?: boolean + checked: boolean + sizeVariant?: "small" | "medium" | "large" + onChange: (checked: boolean) => void +} + +export default function Toggle(props: Props) { + const { className, id, disabled, checked, sizeVariant, onChange } = props + + function handleChange(e: ChangeEvent) { + onChange(e.target.checked) + } + + return ( + + ) +} + +Toggle.defaultProps = { + sizeVariant: "medium", +} diff --git a/client/web/web.go b/client/web/web.go index 7a2c469de..3852c9df1 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -539,6 +539,7 @@ type nodeData struct { AdvertiseExitNode bool AdvertiseRoutes string + RunningSSHServer bool LicensesURL string @@ -563,24 +564,25 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { debugMode = "login" } data := &nodeData{ - ID: st.Self.ID, - Status: st.BackendState, - DeviceName: strings.Split(st.Self.DNSName, ".")[0], - TailnetName: st.CurrentTailnet.MagicDNSSuffix, - DomainName: st.CurrentTailnet.Name, - OS: st.Self.OS, - IPNVersion: strings.Split(st.Version, "-")[0], - Profile: st.User[st.Self.UserID], - IsTagged: st.Self.IsTagged(), - KeyExpired: st.Self.Expired, - TUNMode: st.TUN, - IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"), - DSMVersion: distro.DSMVersion(), - IsUnraid: distro.Get() == distro.Unraid, - UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), - URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"), - LicensesURL: licenses.LicensesURL(), - DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly? + ID: st.Self.ID, + Status: st.BackendState, + DeviceName: strings.Split(st.Self.DNSName, ".")[0], + TailnetName: st.CurrentTailnet.MagicDNSSuffix, + DomainName: st.CurrentTailnet.Name, + OS: st.Self.OS, + IPNVersion: strings.Split(st.Version, "-")[0], + Profile: st.User[st.Self.UserID], + IsTagged: st.Self.IsTagged(), + KeyExpired: st.Self.Expired, + TUNMode: st.TUN, + IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"), + DSMVersion: distro.DSMVersion(), + IsUnraid: distro.Get() == distro.Unraid, + UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), + RunningSSHServer: prefs.RunSSH, + URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"), + LicensesURL: licenses.LicensesURL(), + DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly? } for _, ip := range st.TailscaleIPs { if ip.Is4() { @@ -800,12 +802,9 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) // Rather than exposing all localapi endpoints over the proxy, // this limits to just the ones actually used from the web // client frontend. -// -// TODO(sonia,will): Shouldn't expand this beyond the existing -// localapi endpoints until the larger web client auth story -// is worked out (tailscale/corp#14335). var localapiAllowlist = []string{ "/v0/logout", + "/v0/prefs", } // csrfKey returns a key that can be used for CSRF protection.