From 624b9ab4793d16ceceff42146647d9726105a044 Mon Sep 17 00:00:00 2001 From: drifterza Date: Tue, 24 Feb 2026 13:12:00 +0200 Subject: [PATCH] list all preauth keys without user filter headscale 0.28 added GET /v1/preauthkey without a user param which returns all keys including tag-only ones. this change adds getAllPreAuthKeys() and uses it when available, falling back to per-user fetching on older versions. the UI now handles keys where user is null, showing them as Tag Only in the filter dropdown. closes #432 Amp-Thread-ID: https://ampcode.com/threads/T-019c9861-2d45-73ef-ab94-5fce62f63670 Co-authored-by: Amp --- .../settings/auth-keys/auth-key-row.tsx | 9 +- app/routes/settings/auth-keys/overview.tsx | 115 ++++++++++++------ .../headscale/api/endpoints/pre-auth-keys.ts | 13 ++ app/types/PreAuthKey.ts | 2 +- tests/integration/pre-auth-keys.test.ts | 47 ++++++- 5 files changed, 143 insertions(+), 43 deletions(-) diff --git a/app/routes/settings/auth-keys/auth-key-row.tsx b/app/routes/settings/auth-keys/auth-key-row.tsx index 93dc44c..1811959 100644 --- a/app/routes/settings/auth-keys/auth-key-row.tsx +++ b/app/routes/settings/auth-keys/auth-key-row.tsx @@ -6,23 +6,26 @@ import ExpireAuthKey from "./dialogs/expire-auth-key"; interface Props { authKey: PreAuthKey; - user: User; + user: User | null; } export default function AuthKeyRow({ authKey, user }: Props) { const createdAt = new Date(authKey.createdAt).toLocaleString(); const expiration = new Date(authKey.expiration).toLocaleString(); + const isExpired = + (authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date(); + const userDisplay = user ? user.name || user.displayName || user.email || user.id : "(Tag Only)"; return (
- + - {!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && ( + {!isExpired && user && (
diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 26389c7..b3739d6 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -8,6 +8,8 @@ import Notice from "~/components/Notice"; import Select from "~/components/Select"; import TableList from "~/components/TableList"; import { Capabilities } from "~/server/web/roles"; +import type { PreAuthKey } from "~/types"; +import type { User } from "~/types/User"; import log from "~/utils/log"; import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user"; @@ -22,40 +24,63 @@ export async function loader({ request, context }: Route.LoaderArgs) { const api = context.hsApi.getRuntimeClient(session.api_key); const users = await api.getUsers(); - const preAuthKeys = await Promise.all( - filterUsersWithValidIds(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: [], - }; + + let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[]; + let missing: { user: User; error: unknown }[] = []; + + // Try fetching all keys at once (Headscale 0.28+), fall back to per-user + let allKeys: PreAuthKey[] | null = null; + try { + allKeys = await api.getAllPreAuthKeys(); + } catch { + // older versions don't support this endpoint + } + + if (allKeys !== null) { + const keysByUser = new Map(); + for (const key of allKeys) { + const userId = key.user?.id ?? null; + const existing = keysByUser.get(userId) ?? []; + existing.push(key); + keysByUser.set(userId, existing); + } + + keys = []; + const tagOnly = keysByUser.get(null); + if (tagOnly?.length) { + keys.push({ user: null, preAuthKeys: tagOnly }); + } + for (const user of users) { + const userKeys = keysByUser.get(user.id); + if (userKeys?.length) { + keys.push({ user, preAuthKeys: userKeys }); } - }), - ); + } + } else { + type FetchResult = + | { success: true; user: User; preAuthKeys: PreAuthKey[] } + | { success: false; user: User; error: unknown; preAuthKeys: [] }; - const keys = preAuthKeys - .filter(({ success }) => success) - .map(({ user, preAuthKeys }) => ({ - user, - preAuthKeys, - })); + const results: FetchResult[] = await Promise.all( + filterUsersWithValidIds(users).map(async (user) => { + try { + const preAuthKeys = await api.getPreAuthKeys(user.id); + return { success: true as const, user, preAuthKeys }; + } catch (error) { + log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error); + return { success: false as const, user, error, preAuthKeys: [] as const }; + } + }), + ); - const missing = preAuthKeys - .filter(({ success }) => !success) - .map(({ user, error }) => ({ - user, - error, - })); + keys = results + .filter(({ success }) => success) + .map(({ user, preAuthKeys }) => ({ user, preAuthKeys })); + + missing = results + .filter((r): r is Extract => !r.success) + .map(({ user, error }) => ({ user, error })); + } return { keys, @@ -84,7 +109,11 @@ export default function Page({ return true; } - return user.id === selectedUser; + if (selectedUser === "__headplane_tag_only") { + return user === null; + } + + return user?.id === selectedUser; }) .flatMap(({ preAuthKeys }) => preAuthKeys) .filter((key) => { @@ -176,9 +205,14 @@ export default function Page({ > {[ All, - ...keys.map(({ user }) => ( - {getUserDisplayName(user)} - )), + ...keys + .filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null) + .map(({ user }) => ( + {getUserDisplayName(user)} + )), + ...(keys.some(({ user }) => user === null) + ? [Tag Only] + : []), ]}