mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +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.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user