client/web: add advanced login options

This adds an expandable section of the login view to allow users to
specify an auth key and an alternate control URL.

Input and Collapsible components and accompanying styles were brought
over from the adminpanel.

Updates #10261

Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
Will Norris 2023-11-17 16:05:14 -08:00 committed by Will Norris
parent f0613ab606
commit 42dc843a87
8 changed files with 206 additions and 11 deletions

View File

@ -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",

View File

@ -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<string>("")
const [authKey, setAuthKey] = useState<string>("")
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(refreshData)
@ -76,11 +81,44 @@ export default function LoginView({
</p>
</div>
<button
onClick={() => login({ Reauthenticate: true })}
onClick={() =>
login({
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
>
Log In
</button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">
Connect with a pre-authenticated key.{" "}
<a
href="https://tailscale.com/kb/1085/auth-keys/"
className="link"
target="_blank"
>
Learn more &rarr;
</a>
</p>
<Input
className="mt-2"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder="tskey-auth-XXX"
/>
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
<p className="text-sm text-gray-500">Base URL of control server.</p>
<Input
className="mt-2"
value={controlURL}
onChange={(e) => setControlURL(e.target.value)}
placeholder="https://login.tailscale.com/"
/>
</Collapsible>
</>
)}
</div>
@ -89,6 +127,8 @@ export default function LoginView({
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
function tailscaleUp(options: TailscaleUpOptions) {

View File

@ -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];
}
}
/**

View File

@ -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 (
<Primitive.Root
open={open}
onOpenChange={(open) => {
setOpen(open)
onOpenChange?.(open)
}}
>
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span>
{trigger}
</Primitive.Trigger>
<Primitive.Content className="mt-2">{children}</Primitive.Content>
</Primitive.Root>
)
}

View File

@ -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<HTMLInputElement>
// Input is styled in a way that only works for text inputs.
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const {
className,
inputClassName,
error,
prefix,
suffix,
disabled,
...rest
} = props
return (
<div className={cx("relative", className)}>
<input
ref={ref}
className={cx("input z-10", inputClassName, {
"input-error": error,
})}
disabled={disabled}
{...rest}
/>
{suffix ? (
<div className="bg-white top-1 bottom-1 right-1 rounded-r-md absolute flex items-center">
{suffix}
</div>
) : null}
</div>
)
})
export default Input

View File

@ -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"] &',
])
}),
],
}

View File

@ -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.

View File

@ -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"