diff --git a/client/web/package.json b/client/web/package.json index a986265d8..a0f70c87c 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -9,6 +9,7 @@ "private": true, "dependencies": { "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-collapsible": "^1.0.3", "classnames": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/web/src/components/views/login-view.tsx b/client/web/src/components/views/login-view.tsx index 2adeaeab9..498ff6c61 100644 --- a/client/web/src/components/views/login-view.tsx +++ b/client/web/src/components/views/login-view.tsx @@ -1,7 +1,9 @@ -import React, { useCallback } from "react" +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 Collapsible from "src/ui/collapsible" +import Input from "src/ui/input" /** * LoginView is rendered when the client is not authenticated @@ -14,6 +16,9 @@ export default function LoginView({ data: NodeData refreshData: () => void }) { + const [controlURL, setControlURL] = useState("") + const [authKey, setAuthKey] = useState("") + const login = useCallback( (opt: TailscaleUpOptions) => { tailscaleUp(opt).then(refreshData) @@ -76,11 +81,44 @@ export default function LoginView({

+ +

Auth Key

+

+ Connect with a pre-authenticated key.{" "} + + Learn more → + +

+ setAuthKey(e.target.value)} + placeholder="tskey-auth-XXX" + /> +

Server URL

+

Base URL of control server.

+ setControlURL(e.target.value)} + placeholder="https://login.tailscale.com/" + /> +
)} @@ -89,6 +127,8 @@ export default function LoginView({ type TailscaleUpOptions = { Reauthenticate?: boolean // force reauthentication + ControlURL?: string + AuthKey?: string } function tailscaleUp(options: TailscaleUpOptions) { diff --git a/client/web/src/index.css b/client/web/src/index.css index 5a2fca28b..9f6bedab9 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -45,7 +45,7 @@ } .description { - @apply text-neutral-500 leading-snug + @apply text-neutral-500 leading-snug; } /** @@ -144,6 +144,48 @@ .toggle-small:checked:enabled:active::after { @apply w-[0.675rem] translate-x-[0.55rem]; } + + /** + * .input defines default text input field styling. These styles should + * correspond to .button, sharing a similar height and rounding, since .input + * and .button are commonly used together. + */ + + .input, + .input-wrapper { + @apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors w-full h-input; + } + + .input { + @apply px-3; + } + + .input::placeholder, + .input-wrapper::placeholder { + @apply text-gray-400; + } + + .input:disabled, + .input-wrapper:disabled { + @apply border-gray-300; + @apply bg-gray-0; + @apply cursor-not-allowed; + } + + .input:focus, + .input-wrapper:focus-within { + @apply outline-none ring border-gray-400; + } + + .input-error { + @apply border-red-200; + } +} + +@layer utilities { + .h-input { + @apply h-[2.375rem]; + } } /** diff --git a/client/web/src/ui/collapsible.tsx b/client/web/src/ui/collapsible.tsx new file mode 100644 index 000000000..51faa9aad --- /dev/null +++ b/client/web/src/ui/collapsible.tsx @@ -0,0 +1,33 @@ +import * as Primitive from "@radix-ui/react-collapsible" +import React, { useState } from "react" +import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg" + +type CollapsibleProps = { + trigger?: string + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export default function Collapsible(props: CollapsibleProps) { + const { children, trigger, onOpenChange } = props + const [open, setOpen] = useState(props.open) + + return ( + { + setOpen(open) + onOpenChange?.(open) + }} + > + + + + + {trigger} + + {children} + + ) +} diff --git a/client/web/src/ui/input.tsx b/client/web/src/ui/input.tsx new file mode 100644 index 000000000..2cba2251e --- /dev/null +++ b/client/web/src/ui/input.tsx @@ -0,0 +1,41 @@ +import cx from "classnames" +import React, { InputHTMLAttributes } from "react" + +type Props = { + className?: string + inputClassName?: string + error?: boolean + suffix?: JSX.Element +} & InputHTMLAttributes + +// Input is styled in a way that only works for text inputs. +const Input = React.forwardRef((props, ref) => { + const { + className, + inputClassName, + error, + prefix, + suffix, + disabled, + ...rest + } = props + return ( +
+ + {suffix ? ( +
+ {suffix} +
+ ) : null} +
+ ) +}) + +export default Input diff --git a/client/web/tailwind.config.js b/client/web/tailwind.config.js index c59f4cf0b..2cd07fa28 100644 --- a/client/web/tailwind.config.js +++ b/client/web/tailwind.config.js @@ -1,9 +1,8 @@ +const plugin = require("tailwindcss/plugin") + /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { fontFamily: { sans: [ @@ -32,6 +31,16 @@ module.exports = { }, extend: {}, }, - plugins: [], + plugins: [ + plugin(function ({ addVariant }) { + addVariant("state-open", [ + '&[data-state="open"]', + '[data-state="open"] &', + ]) + addVariant("state-closed", [ + '&[data-state="closed"]', + '[data-state="closed"] &', + ]) + }), + ], } - diff --git a/client/web/web.go b/client/web/web.go index 404d2a945..8929580c2 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -790,10 +790,18 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails go func() { if !isRunning { - s.lc.Start(ctx, ipn.Options{}) + ipnOptions := ipn.Options{AuthKey: opt.AuthKey} + if opt.ControlURL != "" { + ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL} + } + if err := s.lc.Start(ctx, ipnOptions); err != nil { + s.logf("start: %v", err) + } } if opt.Reauthenticate { - s.lc.StartLoginInteractive(ctx) + if err := s.lc.StartLoginInteractive(ctx); err != nil { + s.logf("startLogin: %v", err) + } } }() @@ -802,6 +810,9 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails if err != nil { return "", err } + if n.State != nil && *n.State == ipn.Running { + return "", nil + } if n.ErrMessage != nil { msg := *n.ErrMessage return "", fmt.Errorf("backend error: %v", msg) @@ -816,6 +827,9 @@ type tailscaleUpOptions struct { // If true, force reauthentication of the client. // Otherwise simply reconnect, the same as running `tailscale up`. Reauthenticate bool + + ControlURL string + AuthKey string } // serveTailscaleUp serves requests to /api/up. diff --git a/client/web/yarn.lock b/client/web/yarn.lock index 8ce8b9e70..50eaf3616 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -478,6 +478,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-collapsible@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" + integrity sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-compose-refs@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"