diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4d7d6..b856805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. --- diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index c260fff..9e71783 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -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, + }); + } } diff --git a/app/routes/settings/auth-keys/auth-key-row.tsx b/app/routes/settings/auth-keys/auth-key-row.tsx index ce4d7d4..93dc44c 100644 --- a/app/routes/settings/auth-keys/auth-key-row.tsx +++ b/app/routes/settings/auth-keys/auth-key-row.tsx @@ -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 ( -
- - - - - - - -

- To use this key, run the following command on your device: -

- - tailscale up --login-server={url} --authkey {authKey.key} - -
- {(authKey.used && !authKey.reusable) || - new Date(authKey.expiration) < new Date() ? undefined : ( - - )} - -
-
- ); + return ( +
+ + + + + + + + {!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && ( +
+ +
+ )} +
+ ); } diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index ebd92de..b30f8de 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -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(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(users[0]?.id); - return ( - - Create pre-auth key - - Generate auth key - - - - -
-
- Reusable - - Use this key to authenticate more than one device. - -
- { - setReusable(!reusable); - }} - /> -
- -
-
- Ephemeral - - Devices authenticated with this key will be automatically removed - once they go offline.{' '} - - Learn more - - -
- { - setEphemeral(!ephemeral); - }} - /> -
- -
-
- ); + 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 ( + { + if (!open && submittingRef.current) return; + setIsOpen(open); + }} + > + + {createdKey ? ( + + Pre-auth key created + + Copy this key now. You will not be able to see the full key again. + +
+ {createdKey} + +
+ To register a device with this key: + + {`tailscale up --login-server=${url} --authkey ${createdKey}`} + +
+ ) : ( + { + 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"} + > + Generate auth key + + +
+
+ Reusable + + Use this key to authenticate more than one device. + +
+ { + setReusable(!reusable); + }} + /> +
+
+
+ Ephemeral + + Devices authenticated with this key will be automatically removed once they go + offline.{" "} + + Learn more + + +
+ { + setEphemeral(!ephemeral); + }} + /> +
+
+ )} +
+ ); } diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 02bdda4..49a63b6 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -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('active'); - const isDisabled = - !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0; + const [selectedUser, setSelectedUser] = useState("__headplane_all"); + const [status, setStatus] = useState("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 ( -
-

- - Settings - - / Pre-Auth Keys -

- {!access ? ( - - 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. - - ) : missing.length > 0 ? ( - - An error occurred while fetching the authentication keys for the - following users:{' '} - {missing.map(({ user }, index) => ( - <> - {user.name} - {index < missing.length - 1 ? ', ' : '. '} - - ))} - Their keys may not be listed correctly. Please check the server logs - for more information. - - ) : undefined} -

Pre-Auth Keys

-

- 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{' '} - - Tailscale documentation - -

- -
- - -
- - {keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? ( - - -

- No pre-auth keys have been created yet. -

-
- ) : filteredKeys.length === 0 ? ( - - -

- No pre-auth keys match the selected filters. -

-
- ) : ( - 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 ( +
+

+ + Settings + + / Pre-Auth Keys +

+ {!access ? ( + + 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. + + ) : missing.length > 0 ? ( + + An error occurred while fetching the authentication keys for the following users:{" "} + {missing.map(({ user }, index) => ( + <> + {user.name} + {index < missing.length - 1 ? ", " : ". "} + + ))} + Their keys may not be listed correctly. Please check the server logs for more information. + + ) : undefined} +

Pre-Auth Keys

+

+ 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{" "} + + Tailscale documentation + +

+ +
+ + +
+ + {keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? ( + + +

No pre-auth keys have been created yet.

+
+ ) : filteredKeys.length === 0 ? ( + + +

No pre-auth keys match the selected filters.

+
+ ) : ( + 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 ( + + + + ); + }) + )} +
+
+ ); }