diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 2b885f14d..8fcdaf6f6 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -73,7 +73,7 @@ function WebClient({ diff --git a/client/web/src/components/control-components.tsx b/client/web/src/components/control-components.tsx new file mode 100644 index 000000000..d961e0684 --- /dev/null +++ b/client/web/src/components/control-components.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import React from "react" +import { NodeData } from "src/hooks/node-data" + +/** + * AdminContainer renders its contents only if the node's control + * server has an admin panel. + * + * TODO(sonia,will): Similarly, this could also hide the contents + * if the viewing user is a non-admin. + */ +export function AdminContainer({ + node, + children, + className, +}: { + node: NodeData + children: React.ReactNode + className?: string +}) { + if (!node.ControlAdminURL.includes("tailscale.com")) { + // Admin panel only exists on Tailscale control servers. + return null + } + return
{children}
+} + +/** + * AdminLink renders its contents wrapped in a link to the node's control + * server admin panel. + * + * AdminLink is meant for use only inside of a AdminContainer component, + * to avoid rendering a link when the node's control server does not have + * an admin panel. + */ +export function AdminLink({ + node, + children, + path, +}: { + node: NodeData + children: React.ReactNode + path: string // admin path, e.g. "/settings/webhooks" +}) { + return ( + + {children} + + ) +} diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index e8a4b0b52..f183f457f 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -4,10 +4,11 @@ import cx from "classnames" import React from "react" import { apiFetch } from "src/api" +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 { useLocation } from "wouter" -import ACLTag from "../acl-tag" export default function DeviceDetailsView({ readonly, @@ -63,7 +64,7 @@ export default function DeviceDetailsView({ {node.IsTagged ? node.Tags.map((t) => ) - : node.Profile.DisplayName} + : node.Profile?.DisplayName} @@ -119,19 +120,16 @@ export default function DeviceDetailsView({ -

+ Want even more details? Visit{" "} - + this device’s page - {" "} + {" "} in the admin console. -

+ ) diff --git a/client/web/src/components/views/ssh-view.tsx b/client/web/src/components/views/ssh-view.tsx index d24fb6a83..1a80edf99 100644 --- a/client/web/src/components/views/ssh-view.tsx +++ b/client/web/src/components/views/ssh-view.tsx @@ -2,16 +2,17 @@ // SPDX-License-Identifier: BSD-3-Clause import React from "react" -import { NodeUpdaters } from "src/hooks/node-data" +import * as Control from "src/components/control-components" +import { NodeData, NodeUpdaters } from "src/hooks/node-data" import Toggle from "src/ui/toggle" export default function SSHView({ readonly, - runningSSH, + node, nodeUpdaters, }: { readonly: boolean - runningSSH: boolean + node: NodeData nodeUpdaters: NodeUpdaters }) { return ( @@ -31,9 +32,12 @@ export default function SSHView({

- nodeUpdaters.patchPrefs({ RunSSHSet: true, RunSSH: !runningSSH }) + nodeUpdaters.patchPrefs({ + RunSSHSet: true, + RunSSH: !node.RunningSSHServer, + }) } disabled={readonly} /> @@ -41,18 +45,16 @@ export default function SSHView({ 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/components/views/subnet-router-view.tsx b/client/web/src/components/views/subnet-router-view.tsx index d800a13c0..6f72c1875 100644 --- a/client/web/src/components/views/subnet-router-view.tsx +++ b/client/web/src/components/views/subnet-router-view.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useState } from "react" import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg" import { ReactComponent as Clock } from "src/assets/icons/clock.svg" import { ReactComponent as Plus } from "src/assets/icons/plus.svg" +import * as Control from "src/components/control-components" import { NodeData, NodeUpdaters } from "src/hooks/node-data" import Button from "src/ui/button" import Input from "src/ui/input" @@ -122,18 +123,16 @@ export default function SubnetRouterView({ ))} -
+ To approve routes, in the admin console go to{" "} - + the machine’s route settings - + . -
+ ) : (
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 1a115985e..31884f5ee 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -19,7 +19,6 @@ export type NodeData = { UsingExitNode?: ExitNode AdvertisingExitNode: boolean AdvertisedRoutes?: SubnetRoute[] - LicensesURL: string TUNMode: boolean IsSynology: boolean DSMVersion: number @@ -33,6 +32,8 @@ export type NodeData = { IsTagged: boolean Tags: string[] RunningSSHServer: boolean + ControlAdminURL: string + LicensesURL: string } type NodeState = @@ -204,5 +205,10 @@ export default function useNodeData() { ] ) - return { data, refreshData, nodeUpdaters, isPosting } + return { + data: { ...data, ControlAdminURL: "somehting.com" }, + refreshData, + nodeUpdaters, + isPosting, + } } diff --git a/client/web/web.go b/client/web/web.go index 46b4c7508..5bc45898d 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -561,7 +561,8 @@ type nodeData struct { ClientVersion *tailcfg.ClientVersion - LicensesURL string + ControlAdminURL string + LicensesURL string } type subnetRoute struct { @@ -596,8 +597,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), RunningSSHServer: prefs.RunSSH, URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"), + ControlAdminURL: prefs.AdminPageURL(), LicensesURL: licenses.LicensesURL(), } + cv, err := s.lc.CheckUpdate(r.Context()) if err != nil { s.logf("could not check for updates: %v", err) diff --git a/ipn/prefs.go b/ipn/prefs.go index 71aef0733..5e8033a28 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -570,7 +570,7 @@ func (p *Prefs) AdminPageURL() string { // TODO(crawshaw): In future release, make this https://console.tailscale.com url = "https://login.tailscale.com" } - return url + "/admin/machines" + return url + "/admin" } // AdvertisesExitNode reports whether p is advertising both the v4 and