Files
headplane/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx
T
2026-03-19 12:35:24 -04:00

225 lines
7.5 KiB
TypeScript

import type { Key } from "react";
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/button";
import Code from "~/components/Code";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { User } from "~/types";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentSubject?: string;
}
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
if (!subject) {
return undefined;
}
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
});
}
export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
submittingRef.current = false;
}
}, [fetcher.data, fetcher.state]);
useEffect(() => {
if (!isOpen) {
setReusable(false);
setEphemeral(false);
setTagOnly(false);
setUserId(availableUsers[0]?.id);
setTags("");
fetcher.data = undefined;
}
}, [isOpen]);
const parsedTags = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
<Button className="my-4" onClick={() => setIsOpen(true)}>
Create pre-auth key
</Button>
{createdKey ? (
<DialogPanel variant="unactionable">
<Title>Pre-auth key created</Title>
<Text>Copy this key now. You will not be able to see the full key again.</Text>
<div className="mt-4 flex items-center gap-2 rounded-lg bg-mist-100 px-3 py-2 dark:bg-mist-800">
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
<Button
className="shrink-0"
onClick={async () => {
await navigator.clipboard.writeText(createdKey);
toast("Copied key to clipboard");
}}
variant="light"
>
Copy
</Button>
</div>
<Text className="mt-4 text-sm">To register a device with this key:</Text>
<Code isCopyable className="mt-1 block text-sm">
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
</Code>
</DialogPanel>
) : (
<DialogPanel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey");
form.set("user_id", tagOnly ? "" : (userId?.toString() ?? ""));
form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off");
form.set("acl_tags", parsedTags.join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle" || !canSubmit}
>
<Title>Generate auth key</Title>
{!selfServiceOnly && (
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Tag-only key</Text>
<Text className="text-sm">Create a key owned by ACL tags instead of a user.</Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
)}
{!tagOnly && (
<Select
className="mb-2"
description={
selfServiceOnly
? "You can only create keys for your own user."
: "Machines will belong to this user when they authenticate."
}
isDisabled={selfServiceOnly}
isRequired
label="User"
onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user"
selectedKey={userId}
>
{availableUsers.map((user) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
)}
<Input
className="mb-2"
description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
isRequired={tagOnly}
label="ACL Tags"
onChange={(value) => setTags(value)}
placeholder="server, prod"
value={tags}
/>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{
style: "unit",
unit: "day",
unitDisplay: "short",
}}
isRequired
label="Key Expiration"
maxValue={365_000}
minValue={1}
name="expiry"
/>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Reusable</Text>
<Text className="text-sm">Use this key to authenticate more than one device.</Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
onChange={() => setReusable(!reusable)}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Ephemeral</Text>
<Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
onChange={() => setEphemeral(!ephemeral)}
/>
</div>
</DialogPanel>
)}
</Dialog>
);
}