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"