feat: fix pre-auth-key dialog to be usable again

This commit is contained in:
Aarnav Tale
2026-02-08 10:54:27 -05:00
parent 9f0bc4c46a
commit 0b8ae8fa2d
5 changed files with 475 additions and 449 deletions
+1
View File
@@ -29,6 +29,7 @@
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID. - Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)). - Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
- Fixed API key login on Headcale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)). - Fixed API key login on Headcale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
--- ---
+81 -82
View File
@@ -1,95 +1,94 @@
import { data } from 'react-router'; import { data } from "react-router";
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) { export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check( const check = await context.sessions.check(request, Capabilities.generate_authkeys);
request,
Capabilities.generate_authkeys,
);
if (!check) { if (!check) {
throw data('You do not have permission to manage pre-auth keys', { throw data("You do not have permission to manage pre-auth keys", {
status: 403, status: 403,
}); });
} }
const formData = await request.formData(); const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
throw data('Missing `action_id` in the form data.', { throw data("Missing `action_id` in the form data.", {
status: 400, status: 400,
}); });
} }
switch (action) { switch (action) {
case 'add_preauthkey': { case "add_preauthkey": {
const user = formData.get('user_id')?.toString(); const user = formData.get("user_id")?.toString();
if (!user) { if (!user) {
return data('Missing `user_id` in the form data.', { return data("Missing `user_id` in the form data.", {
status: 400, status: 400,
}); });
} }
const expiry = formData.get('expiry')?.toString(); const expiry = formData.get("expiry")?.toString();
if (!expiry) { if (!expiry) {
return data('Missing `expiry` in the form data.', { return data("Missing `expiry` in the form data.", {
status: 400, status: 400,
}); });
} }
const reusable = formData.get('reusable')?.toString(); const reusable = formData.get("reusable")?.toString();
if (!reusable) { if (!reusable) {
return data('Missing `reusable` in the form data.', { return data("Missing `reusable` in the form data.", {
status: 400, status: 400,
}); });
} }
const ephemeral = formData.get('ephemeral')?.toString(); const ephemeral = formData.get("ephemeral")?.toString();
if (!ephemeral) { if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', { return data("Missing `ephemeral` in the form data.", {
status: 400, status: 400,
}); });
} }
// Extract the first "word" from expiry which is the day number // Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number // Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]); const day = Number(expiry.toString().split(" ")[0]);
const date = new Date(); const date = new Date();
date.setDate(date.getDate() + day); date.setDate(date.getDate() + day);
await api.createPreAuthKey( const key = await api.createPreAuthKey(
user, user,
ephemeral === 'on', ephemeral === "on",
reusable === 'on', reusable === "on",
date, date,
[], // TODO [], // TODO
); );
return data('Pre-auth key created'); return data({ success: true as const, key: key.key });
} }
case 'expire_preauthkey': { case "expire_preauthkey": {
const key = formData.get('key')?.toString(); const key = formData.get("key")?.toString();
if (!key) { if (!key) {
return data('Missing `key` in the form data.', { return data("Missing `key` in the form data.", {
status: 400, status: 400,
}); });
} }
const user = formData.get('user_id')?.toString(); const user = formData.get("user_id")?.toString();
if (!user) { if (!user) {
return data('Missing `user_id` in the form data.', { return data("Missing `user_id` in the form data.", {
status: 400, status: 400,
}); });
} }
await api.expirePreAuthKey(user, key); await api.expirePreAuthKey(user, key);
return data('Pre-auth key expired'); return data("Pre-auth key expired");
} }
default: default:
return data('Invalid action', { return data("Invalid action", {
status: 400, status: 400,
}); });
} }
} }
+26 -52
View File
@@ -1,58 +1,32 @@
import Attribute from '~/components/Attribute'; import type { PreAuthKey, User } from "~/types";
import Button from '~/components/Button';
import Code from '~/components/Code'; import Attribute from "~/components/Attribute";
import type { PreAuthKey, User } from '~/types';
import toast from '~/utils/toast'; import ExpireAuthKey from "./dialogs/expire-auth-key";
import ExpireAuthKey from './dialogs/expire-auth-key';
interface Props { interface Props {
authKey: PreAuthKey; authKey: PreAuthKey;
user: User; user: User;
url: string;
} }
export default function AuthKeyRow({ authKey, user, url }: Props) { export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString(); const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString(); const expiration = new Date(authKey.expiration).toLocaleString();
return ( return (
<div className="w-full"> <div className="w-full">
<Attribute isCopyable name="Key" value={authKey.key} /> <Attribute name="Key" value={authKey.key} />
<Attribute <Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
isCopyable <Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
name="User" <Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
value={user.name || user.displayName || user.email || user.id} <Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
/> <Attribute name="Created" value={createdAt} />
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} /> <Attribute name="Expiration" value={expiration} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} /> {!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} /> <div className="mt-2" suppressHydrationWarning>
<Attribute name="Created" value={createdAt} /> <ExpireAuthKey authKey={authKey} user={user} />
<Attribute name="Expiration" value={expiration} /> </div>
<p className="mb-1 mt-4"> )}
To use this key, run the following command on your device: </div>
</p> );
<Code className="text-sm">
tailscale up --login-server={url} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center" suppressHydrationWarning>
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireAuthKey authKey={authKey} user={user} />
)}
<Button
className="my-4"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
);
toast('Copied command to clipboard');
}}
variant="light"
>
Copy Tailscale Command
</Button>
</div>
</div>
);
} }
@@ -1,101 +1,166 @@
import { Key, useState } from 'react'; import { Key, useEffect, useRef, useState } from "react";
import Dialog from '~/components/Dialog'; import { useFetcher } from "react-router";
import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput'; import type { User } from "~/types";
import Select from '~/components/Select';
import Switch from '~/components/Switch'; import Button from "~/components/Button";
import type { User } from '~/types'; import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Link from "~/components/Link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
import Switch from "~/components/Switch";
import toast from "~/utils/toast";
interface AddAuthKeyProps { interface AddAuthKeyProps {
users: User[]; users: User[];
url: string;
} }
// TODO: Tags export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
export default function AddAuthKey(data: AddAuthKeyProps) { const fetcher = useFetcher();
const [reusable, setReusable] = useState(false); const submittingRef = useRef(false);
const [ephemeral, setEphemeral] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id); const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id);
return ( const createdKey = fetcher.data?.success ? fetcher.data.key : null;
<Dialog>
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button> useEffect(() => {
<Dialog.Panel> if (fetcher.state === "idle" && fetcher.data) {
<Dialog.Title>Generate auth key</Dialog.Title> submittingRef.current = false;
<input name="action_id" type="hidden" value="add_preauthkey" /> }
<input name="user_id" type="hidden" value={userId?.toString()} /> }, [fetcher.data, fetcher.state]);
<Select
className="mb-2" useEffect(() => {
description="This is the user machines will belong to when they authenticate." if (!isOpen) {
isRequired setReusable(false);
label="User" setEphemeral(false);
name="user" setUserId(users[0]?.id);
onSelectionChange={(value) => { fetcher.data = undefined;
setUserId(value); }
}} }, [isOpen]);
placeholder="Select a user"
> return (
{data.users.map((user) => ( <Dialog
<Select.Item key={user.id}> isOpen={isOpen}
{user.name || user.displayName || user.email || user.id} onOpenChange={(open) => {
</Select.Item> if (!open && submittingRef.current) return;
))} setIsOpen(open);
</Select> }}
<NumberInput >
defaultValue={90} <Button className="my-4" onPress={() => setIsOpen(true)}>
description="Set this key to expire after a certain number of days." Create pre-auth key
formatOptions={{ </Button>
style: 'unit', {createdKey ? (
unit: 'day', <Dialog.Panel variant="unactionable">
unitDisplay: 'short', <Dialog.Title>Pre-auth key created</Dialog.Title>
}} <Dialog.Text>
isRequired Copy this key now. You will not be able to see the full key again.
label="Key Expiration" </Dialog.Text>
maxValue={365_000} // 1000 years <div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
minValue={1} <code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
name="expiry" <Button
/> className="shrink-0"
<div className="flex justify-between items-center gap-2 mt-6"> onPress={async () => {
<div> await navigator.clipboard.writeText(createdKey);
<Dialog.Text className="font-semibold">Reusable</Dialog.Text> toast("Copied key to clipboard");
<Dialog.Text className="text-sm"> }}
Use this key to authenticate more than one device. variant="light"
</Dialog.Text> >
</div> Copy
<Switch </Button>
defaultSelected={reusable} </div>
label="Reusable" <Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
name="reusable" <Code isCopyable className="mt-1 block text-sm">
onChange={() => { {`tailscale up --login-server=${url} --authkey ${createdKey}`}
setReusable(!reusable); </Code>
}} </Dialog.Panel>
/> ) : (
</div> <Dialog.Panel
<input name="reusable" type="hidden" value={reusable.toString()} /> onSubmit={(event) => {
<div className="flex justify-between items-center gap-2 mt-6"> event.preventDefault();
<div> submittingRef.current = true;
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text> const form = new FormData(event.currentTarget as HTMLFormElement);
<Dialog.Text className="text-sm"> form.set("action_id", "add_preauthkey");
Devices authenticated with this key will be automatically removed form.set("user_id", userId?.toString() ?? "");
once they go offline.{' '} form.set("reusable", reusable ? "on" : "off");
<Link form.set("ephemeral", ephemeral ? "on" : "off");
name="Tailscale Ephemeral Nodes Documentation" fetcher.submit(form, { method: "POST" });
to="https://tailscale.com/kb/1111/ephemeral-nodes" }}
> isDisabled={fetcher.state !== "idle"}
Learn more >
</Link> <Dialog.Title>Generate auth key</Dialog.Title>
</Dialog.Text> <Select
</div> className="mb-2"
<Switch description="This is the user machines will belong to when they authenticate."
defaultSelected={ephemeral} isRequired
label="Ephemeral" label="User"
name="ephemeral" onSelectionChange={(value) => {
onChange={() => { setUserId(value);
setEphemeral(!ephemeral); }}
}} placeholder="Select a user"
/> >
</div> {users.map((user) => (
<input name="ephemeral" type="hidden" value={ephemeral.toString()} /> <Select.Item key={user.id}>
</Dialog.Panel> {user.name || user.displayName || user.email || user.id}
</Dialog> </Select.Item>
); ))}
</Select>
<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>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</div>
</Dialog.Panel>
)}
</Dialog>
);
} }
+207 -220
View File
@@ -1,245 +1,232 @@
import { FileKey2 } from 'lucide-react'; import { FileKey2 } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { Link as RemixLink } from 'react-router'; import { Link as RemixLink } from "react-router";
import Code from '~/components/Code';
import Link from '~/components/Link'; import Code from "~/components/Code";
import Notice from '~/components/Notice'; import Link from "~/components/Link";
import Select from '~/components/Select'; import Notice from "~/components/Notice";
import TableList from '~/components/TableList'; import Select from "~/components/Select";
import { Capabilities } from '~/server/web/roles'; import TableList from "~/components/TableList";
import log from '~/utils/log'; import { Capabilities } from "~/server/web/roles";
import type { Route } from './+types/overview'; import log from "~/utils/log";
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row'; import type { Route } from "./+types/overview";
import AddAuthKey from './dialogs/add-auth-key';
import { authKeysAction } from "./actions";
import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers(); const users = await api.getUsers();
const preAuthKeys = await Promise.all( const preAuthKeys = await Promise.all(
users users
.filter((user) => user.name?.length > 0) // Filter out any invalid users .filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => { .map(async (user) => {
try { try {
const preAuthKeys = await api.getPreAuthKeys(user.id); const preAuthKeys = await api.getPreAuthKeys(user.id);
return { return {
success: true, success: true,
user, user,
preAuthKeys, preAuthKeys,
}; };
} catch (error) { } catch (error) {
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error); log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return { return {
success: false, success: false,
user, user,
error, error,
preAuthKeys: [], preAuthKeys: [],
}; };
} }
}), }),
); );
const keys = preAuthKeys const keys = preAuthKeys
.filter(({ success }) => success) .filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ .map(({ user, preAuthKeys }) => ({
user, user,
preAuthKeys, preAuthKeys,
})); }));
const missing = preAuthKeys const missing = preAuthKeys
.filter(({ success }) => !success) .filter(({ success }) => !success)
.map(({ user, error }) => ({ .map(({ user, error }) => ({
user, user,
error, error,
})); }));
return { return {
keys, keys,
missing, missing,
users, users,
access: await context.sessions.check( access: await context.sessions.check(request, Capabilities.generate_authkeys),
request, url: context.config.headscale.public_url ?? context.config.headscale.url,
Capabilities.generate_authkeys, };
),
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
} }
export const action = authKeysAction; export const action = authKeysAction;
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral'; type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({ export default function Page({
loaderData: { keys, missing, users, url, access }, loaderData: { keys, missing, users, url, access },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState('__headplane_all'); const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>('active'); const [status, setStatus] = useState<Status>("active");
const isDisabled = const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
const filteredKeys = useMemo(() => { const filteredKeys = useMemo(() => {
const now = new Date(); const now = new Date();
return keys return keys
.filter(({ user }) => { .filter(({ user }) => {
if (selectedUser === '__headplane_all') { if (selectedUser === "__headplane_all") {
return true; return true;
} }
return user.id === selectedUser; return user.id === selectedUser;
}) })
.flatMap(({ preAuthKeys }) => preAuthKeys) .flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => { .filter((key) => {
if (status === 'all') { if (status === "all") {
return true; return true;
} }
if (status === 'ephemeral') { if (status === "ephemeral") {
return key.ephemeral; return key.ephemeral;
} }
if (status === 'reusable') { if (status === "reusable") {
return key.reusable; return key.reusable;
} }
const expiry = new Date(key.expiration); const expiry = new Date(key.expiration);
if (status === 'expired') { if (status === "expired") {
// Expired keys are either used or expired // Expired keys are either used or expired
// BUT only used if they are not reusable // BUT only used if they are not reusable
if (key.used && !key.reusable) { if (key.used && !key.reusable) {
return true; return true;
} }
return expiry < now; return expiry < now;
} }
if (status === 'active') { if (status === "active") {
// Active keys are either not expired or reusable // Active keys are either not expired or reusable
if (expiry < now) { if (expiry < now) {
return false; return false;
} }
if (!key.used) { if (!key.used) {
return true; return true;
} }
return key.reusable; return key.reusable;
} }
return false; return false;
}); });
}, [keys, selectedUser, status]); }, [keys, selectedUser, status]);
return ( return (
<div className="flex flex-col md:w-2/3"> <div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md"> <p className="text-md mb-8">
<RemixLink className="font-medium" to="/settings"> <RemixLink className="font-medium" to="/settings">
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys <span className="mx-2">/</span> Pre-Auth Keys
</p> </p>
{!access ? ( {!access ? (
<Notice title="Pre-auth key permissions restricted" variant="warning"> <Notice title="Pre-auth key permissions restricted" variant="warning">
You do not have the necessary permissions to generate pre-auth keys. You do not have the necessary permissions to generate pre-auth keys. Please contact your
Please contact your administrator to request access or to generate a administrator to request access or to generate a pre-auth key for you.
pre-auth key for you. </Notice>
</Notice> ) : missing.length > 0 ? (
) : missing.length > 0 ? ( <Notice title="Missing authentication keys" variant="error">
<Notice title="Missing authentication keys" variant="error"> An error occurred while fetching the authentication keys for the following users:{" "}
An error occurred while fetching the authentication keys for the {missing.map(({ user }, index) => (
following users:{' '} <>
{missing.map(({ user }, index) => ( <Code key={user.name}>{user.name}</Code>
<> {index < missing.length - 1 ? ", " : ". "}
<Code key={user.name}>{user.name}</Code> </>
{index < missing.length - 1 ? ', ' : '. '} ))}
</> Their keys may not be listed correctly. Please check the server logs for more information.
))} </Notice>
Their keys may not be listed correctly. Please check the server logs ) : undefined}
for more information. <h1 className="mb-2 text-2xl font-medium">Pre-Auth Keys</h1>
</Notice> <p className="mb-4">
) : undefined} Headscale fully supports pre-authentication keys in order to easily add devices to your
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1> Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<p className="mb-4"> <Link
Headscale fully supports pre-authentication keys in order to easily add name="Tailscale Auth Keys documentation"
devices to your Tailnet. To learn more about using pre-authentication to="https://tailscale.com/kb/1085/auth-keys/"
keys, visit the{' '} >
<Link Tailscale documentation
name="Tailscale Auth Keys documentation" </Link>
to="https://tailscale.com/kb/1085/auth-keys/" </p>
> <AddAuthKey url={url} users={users} />
Tailscale documentation <div className="mt-4 flex items-center gap-4">
</Link> <Select
</p> className="w-full"
<AddAuthKey users={users} /> defaultSelectedKey="__headplane_all"
<div className="flex items-center gap-4 mt-4"> isDisabled={isDisabled}
<Select label="User"
className="w-full" onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
defaultSelectedKey="__headplane_all" placeholder="Select a user"
isDisabled={isDisabled} >
label="User" {[
onSelectionChange={(value) => <Select.Item key="__headplane_all">All</Select.Item>,
setSelectedUser(value?.toString() ?? '') ...keys.map(({ user }) => (
} <Select.Item key={user.id}>
placeholder="Select a user" {user.name || user.displayName || user.email || user.id}
> </Select.Item>
{[ )),
<Select.Item key="__headplane_all">All</Select.Item>, ]}
...keys.map(({ user }) => ( </Select>
<Select.Item key={user.id}> <Select
{user.name || user.displayName || user.email || user.id} className="w-full"
</Select.Item> defaultSelectedKey="active"
)), isDisabled={isDisabled}
]} label="Status"
</Select> onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
<Select placeholder="Select a status"
className="w-full" >
defaultSelectedKey="active" <Select.Item key="all">All</Select.Item>
isDisabled={isDisabled} <Select.Item key="active">Active</Select.Item>
label="Status" <Select.Item key="expired">Used/Expired</Select.Item>
onSelectionChange={(value) => <Select.Item key="reusable">Reusable</Select.Item>
setStatus((value?.toString() ?? 'active') as Status) <Select.Item key="ephemeral">Ephemeral</Select.Item>
} </Select>
placeholder="Select a status" </div>
> <TableList className="mt-4">
<Select.Item key="all">All</Select.Item> {keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
<Select.Item key="active">Active</Select.Item> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<Select.Item key="expired">Used/Expired</Select.Item> <FileKey2 />
<Select.Item key="reusable">Reusable</Select.Item> <p className="font-semibold">No pre-auth keys have been created yet.</p>
<Select.Item key="ephemeral">Ephemeral</Select.Item> </TableList.Item>
</Select> ) : filteredKeys.length === 0 ? (
</div> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TableList className="mt-4"> <FileKey2 />
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? ( <p className="font-semibold">No pre-auth keys match the selected filters.</p>
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> </TableList.Item>
<FileKey2 /> ) : (
<p className="font-semibold"> filteredKeys.map((key) => {
No pre-auth keys have been created yet. // TODO: Why is Headscale using email as the user ID here?
</p> // https://github.com/juanfont/headscale/issues/2520
</TableList.Item> const user = users.find((user) => user.id === key.user.id);
) : filteredKeys.length === 0 ? ( if (!user) {
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> return null;
<FileKey2 /> }
<p className="font-semibold">
No pre-auth keys match the selected filters.
</p>
</TableList.Item>
) : (
filteredKeys.map((key) => {
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
if (!user) {
return null;
}
return ( return (
<TableList.Item key={key.id}> <TableList.Item key={key.id}>
<AuthKeyRow authKey={key} url={url} user={user} /> <AuthKeyRow authKey={key} user={user} />
</TableList.Item> </TableList.Item>
); );
}) })
)} )}
</TableList> </TableList>
</div> </div>
); );
} }