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