mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
Merge pull request #466 from drifterza/feature/list-all-preauth-keys
This commit is contained in:
@@ -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 (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} />
|
||||
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
|
||||
<Attribute name="User" value={userDisplay} />
|
||||
<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()) && (
|
||||
{!isExpired && user && (
|
||||
<div className="mt-2" suppressHydrationWarning>
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
</div>
|
||||
|
||||
@@ -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<string | null, PreAuthKey[]>();
|
||||
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<FetchResult, { success: false }> => !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({
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
)),
|
||||
...keys
|
||||
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
|
||||
.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
)),
|
||||
...(keys.some(({ user }) => user === null)
|
||||
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
|
||||
: []),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
@@ -209,9 +243,18 @@ export default function Page({
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => {
|
||||
// Tag-only keys have no user
|
||||
if (!key.user) {
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} user={null} />
|
||||
</TableList.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
const user = users.find((user) => user.id === key.user?.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { PreAuthKey } from "~/types";
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface PreAuthKeyEndpoints {
|
||||
/**
|
||||
* List all pre-auth keys. Requires Headscale 0.28+.
|
||||
*/
|
||||
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
@@ -33,6 +38,14 @@ export interface PreAuthKeyEndpoints {
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
getAllPreAuthKeys: async () => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, {});
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { User } from './User';
|
||||
export interface PreAuthKey {
|
||||
id: string;
|
||||
key: string;
|
||||
user: User;
|
||||
user: User | null;
|
||||
reusable: boolean;
|
||||
ephemeral: boolean;
|
||||
used: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "./setup/env";
|
||||
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "./setup/env";
|
||||
|
||||
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
|
||||
test("pre-auth keys can be created", async () => {
|
||||
@@ -13,7 +13,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
|
||||
|
||||
expect(preAuthKey).toBeDefined();
|
||||
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
|
||||
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
|
||||
expect(preAuthKey.ephemeral).toBe(false);
|
||||
expect(preAuthKey.reusable).toBe(false);
|
||||
expect(preAuthKey.aclTags).toEqual([]);
|
||||
@@ -30,7 +30,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
|
||||
|
||||
expect(preAuthKey).toBeDefined();
|
||||
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
|
||||
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
|
||||
expect(preAuthKey.ephemeral).toBe(true);
|
||||
expect(preAuthKey.reusable).toBe(true);
|
||||
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
|
||||
@@ -64,6 +64,47 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
|
||||
const isAtLeast = await getIsAtLeast(version);
|
||||
if (!isAtLeast("0.28.0")) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
|
||||
const allKeys = await client.getAllPreAuthKeys();
|
||||
expect(Array.isArray(allKeys)).toBe(true);
|
||||
expect(allKeys.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
|
||||
for (const userKey of userSpecificKeys) {
|
||||
const found = allKeys.find((k) => k.key === userKey.key);
|
||||
expect(found).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
|
||||
const isAtLeast = await getIsAtLeast(version);
|
||||
if (!isAtLeast("0.28.0")) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const allKeys = await client.getAllPreAuthKeys();
|
||||
|
||||
for (const key of allKeys) {
|
||||
expect(key.id).toBeDefined();
|
||||
expect(key.key).toBeDefined();
|
||||
expect(typeof key.reusable).toBe("boolean");
|
||||
expect(typeof key.ephemeral).toBe("boolean");
|
||||
expect(typeof key.used).toBe("boolean");
|
||||
expect(key.expiration).toBeDefined();
|
||||
expect(key.createdAt).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("pre-auth keys can be expired", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
|
||||
Reference in New Issue
Block a user