mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: fix pre-auth-key dialog to be usable again
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
- 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)).
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,95 +1,94 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
);
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.generate_authkeys);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage pre-auth keys', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (!check) {
|
||||
throw data("You do not have permission to manage pre-auth keys", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("Missing `action_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_preauthkey': {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
switch (action) {
|
||||
case "add_preauthkey": {
|
||||
const user = formData.get("user_id")?.toString();
|
||||
if (!user) {
|
||||
return data("Missing `user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const expiry = formData.get("expiry")?.toString();
|
||||
if (!expiry) {
|
||||
return data("Missing `expiry` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const reusable = formData.get("reusable")?.toString();
|
||||
if (!reusable) {
|
||||
return data("Missing `reusable` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const ephemeral = formData.get("ephemeral")?.toString();
|
||||
if (!ephemeral) {
|
||||
return data("Missing `ephemeral` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
await api.createPreAuthKey(
|
||||
user,
|
||||
ephemeral === 'on',
|
||||
reusable === 'on',
|
||||
date,
|
||||
[], // TODO
|
||||
);
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(" ")[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
const key = await api.createPreAuthKey(
|
||||
user,
|
||||
ephemeral === "on",
|
||||
reusable === "on",
|
||||
date,
|
||||
[], // TODO
|
||||
);
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
case 'expire_preauthkey': {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return data({ success: true as const, key: key.key });
|
||||
}
|
||||
case "expire_preauthkey": {
|
||||
const key = formData.get("key")?.toString();
|
||||
if (!key) {
|
||||
return data("Missing `key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const user = formData.get("user_id")?.toString();
|
||||
if (!user) {
|
||||
return data("Missing `user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.expirePreAuthKey(user, key);
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
default:
|
||||
return data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.expirePreAuthKey(user, key);
|
||||
return data("Pre-auth key expired");
|
||||
}
|
||||
default:
|
||||
return data("Invalid action", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,32 @@
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import toast from '~/utils/toast';
|
||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
||||
import type { PreAuthKey, User } from "~/types";
|
||||
|
||||
import Attribute from "~/components/Attribute";
|
||||
|
||||
import ExpireAuthKey from "./dialogs/expire-auth-key";
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
url: string;
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
export default function AuthKeyRow({ authKey, user }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute isCopyable name="Key" value={authKey.key} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="User"
|
||||
value={user.name || user.displayName || user.email || user.id}
|
||||
/>
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</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>
|
||||
);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} />
|
||||
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
|
||||
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
|
||||
<div className="mt-2" suppressHydrationWarning>
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,166 @@
|
||||
import { Key, useState } from 'react';
|
||||
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 type { User } from '~/types';
|
||||
import { Key, useEffect, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import type { User } from "~/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
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 {
|
||||
users: User[];
|
||||
users: User[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
|
||||
export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
|
||||
const fetcher = useFetcher();
|
||||
const submittingRef = useRef(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [userId, setUserId] = useState<Key | null>(users[0]?.id);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<input name="action_id" type="hidden" value="add_preauthkey" />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
className="mb-2"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
isRequired
|
||||
label="User"
|
||||
name="user"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</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} // 1000 years
|
||||
minValue={1}
|
||||
name="expiry"
|
||||
/>
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<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"
|
||||
name="reusable"
|
||||
onChange={() => {
|
||||
setReusable(!reusable);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input name="reusable" type="hidden" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<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"
|
||||
name="ephemeral"
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
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);
|
||||
setUserId(users[0]?.id);
|
||||
fetcher.data = undefined;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && submittingRef.current) return;
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<Button className="my-4" onPress={() => setIsOpen(true)}>
|
||||
Create pre-auth key
|
||||
</Button>
|
||||
{createdKey ? (
|
||||
<Dialog.Panel variant="unactionable">
|
||||
<Dialog.Title>Pre-auth key created</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Copy this key now. You will not be able to see the full key again.
|
||||
</Dialog.Text>
|
||||
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
toast("Copied key to clipboard");
|
||||
}}
|
||||
variant="light"
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
|
||||
<Code isCopyable className="mt-1 block text-sm">
|
||||
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
|
||||
</Code>
|
||||
</Dialog.Panel>
|
||||
) : (
|
||||
<Dialog.Panel
|
||||
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", userId?.toString() ?? "");
|
||||
form.set("reusable", reusable ? "on" : "off");
|
||||
form.set("ephemeral", ephemeral ? "on" : "off");
|
||||
fetcher.submit(form, { method: "POST" });
|
||||
}}
|
||||
isDisabled={fetcher.state !== "idle"}
|
||||
>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<Select
|
||||
className="mb-2"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
isRequired
|
||||
label="User"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,245 +1,232 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/overview';
|
||||
import { authKeysAction } from './actions';
|
||||
import AuthKeyRow from './auth-key-row';
|
||||
import AddAuthKey from './dialogs/add-auth-key';
|
||||
import { FileKey2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link as RemixLink } from "react-router";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Link from "~/components/Link";
|
||||
import Notice from "~/components/Notice";
|
||||
import Select from "~/components/Select";
|
||||
import TableList from "~/components/TableList";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
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) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
const users = await api.getUsers();
|
||||
const preAuthKeys = await Promise.all(
|
||||
users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
preAuthKeys,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
|
||||
return {
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
const users = await api.getUsers();
|
||||
const preAuthKeys = await Promise.all(
|
||||
users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
preAuthKeys,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
|
||||
return {
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const keys = preAuthKeys
|
||||
.filter(({ success }) => success)
|
||||
.map(({ user, preAuthKeys }) => ({
|
||||
user,
|
||||
preAuthKeys,
|
||||
}));
|
||||
const keys = preAuthKeys
|
||||
.filter(({ success }) => success)
|
||||
.map(({ user, preAuthKeys }) => ({
|
||||
user,
|
||||
preAuthKeys,
|
||||
}));
|
||||
|
||||
const missing = preAuthKeys
|
||||
.filter(({ success }) => !success)
|
||||
.map(({ user, error }) => ({
|
||||
user,
|
||||
error,
|
||||
}));
|
||||
const missing = preAuthKeys
|
||||
.filter(({ success }) => !success)
|
||||
.map(({ user, error }) => ({
|
||||
user,
|
||||
error,
|
||||
}));
|
||||
|
||||
return {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
access: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
),
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
return {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
access: await context.sessions.check(request, Capabilities.generate_authkeys),
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = authKeysAction;
|
||||
|
||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||
export default function Page({
|
||||
loaderData: { keys, missing, users, url, access },
|
||||
loaderData: { keys, missing, users, url, access },
|
||||
}: Route.ComponentProps) {
|
||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState<Status>('active');
|
||||
const isDisabled =
|
||||
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||
const [status, setStatus] = useState<Status>("active");
|
||||
const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === '__headplane_all') {
|
||||
return true;
|
||||
}
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === "__headplane_all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === 'all') {
|
||||
return true;
|
||||
}
|
||||
return user.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
if (status === "ephemeral") {
|
||||
return key.ephemeral;
|
||||
}
|
||||
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
if (status === "reusable") {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === 'expired') {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === "expired") {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiry < now;
|
||||
}
|
||||
return expiry < now;
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
if (status === "active") {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return key.reusable;
|
||||
}
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
return false;
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys.
|
||||
Please contact your administrator to request access or to generate a
|
||||
pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the
|
||||
following users:{' '}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<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>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add
|
||||
devices to your Tailnet. To learn more about using pre-authentication
|
||||
keys, visit the{' '}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) =>
|
||||
setSelectedUser(value?.toString() ?? '')
|
||||
}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) =>
|
||||
setStatus((value?.toString() ?? 'active') as Status)
|
||||
}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys have been created yet.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<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 (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="text-md mb-8">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys. Please contact your
|
||||
administrator to request access or to generate a pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the following users:{" "}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<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>
|
||||
) : undefined}
|
||||
<h1 className="mb-2 text-2xl font-medium">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add devices to your
|
||||
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey url={url} users={users} />
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">No pre-auth keys have been created yet.</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<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 (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user