From bbc535d39efee463cf9d4b679af10d37f63130cf Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 6 Apr 2025 15:41:03 -0400 Subject: [PATCH] feat: rework the entire pre-auth keys page --- CHANGELOG.md | 1 + app/components/Select.tsx | 4 +- app/routes/machines/dialogs/new.tsx | 7 +- app/routes/machines/overview.tsx | 5 + app/routes/settings/auth-keys/actions.ts | 118 ++++++++ .../settings/auth-keys/auth-key-row.tsx | 16 +- .../auth-keys/dialogs/add-auth-key.tsx | 11 +- .../auth-keys/dialogs/expire-auth-key.tsx | 14 +- app/routes/settings/auth-keys/overview.tsx | 283 ++++++++++-------- 9 files changed, 315 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a28bae..ee32288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102)) - The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration. - Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file. +- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)). ### 0.5.10 (April 4, 2025) - Fix an issue where other preferences to skip onboarding affected every user. diff --git a/app/components/Select.tsx b/app/components/Select.tsx index 79da75a..ecc5ae0 100644 --- a/app/components/Select.tsx +++ b/app/components/Select.tsx @@ -78,7 +78,9 @@ function Select(props: SelectProps) { className={cn( 'flex items-center justify-center p-1 rounded-lg m-1', 'bg-headplane-100 dark:bg-headplane-700/30 font-medium', - 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30', + props.isDisabled + ? 'opacity-50 cursor-not-allowed' + : 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30', )} > diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx index f245243..11aaa5c 100644 --- a/app/routes/machines/dialogs/new.tsx +++ b/app/routes/machines/dialogs/new.tsx @@ -1,4 +1,4 @@ -import { Computer, KeySquare } from 'lucide-react'; +import { Computer, FileKey2 } from 'lucide-react'; import { useState } from 'react'; import { useNavigate } from 'react-router'; import Code from '~/components/Code'; @@ -12,6 +12,7 @@ export interface NewMachineProps { server: string; users: User[]; isDisabled?: boolean; + disabledKeys?: string[]; } export default function NewMachine(data: NewMachineProps) { @@ -51,7 +52,7 @@ export default function NewMachine(data: NewMachineProps) { - + Add Device { @@ -74,7 +75,7 @@ export default function NewMachine(data: NewMachineProps) {
- + Generate Pre-auth Key
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 750ce4e..63b8923 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -69,6 +69,10 @@ export async function loader({ agents: context.agents?.tailnetIDs(), stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)), writable: writablePermission, + preAuth: await context.sessions.check( + request, + Capabilities.generate_authkeys, + ), subject: user.subject, }; } @@ -99,6 +103,7 @@ export default function Page() { server={data.publicServer ?? data.server} users={data.users} isDisabled={!data.writable} + disabledKeys={data.preAuth ? [] : ['pre-auth']} /> diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index e69de29..e7a42b2 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -0,0 +1,118 @@ +import { ActionFunctionArgs, data } from 'react-router'; +import { LoadContext } from '~/server'; +import { Capabilities } from '~/server/web/roles'; +import { PreAuthKey } from '~/types'; + +export async function authKeysAction({ + request, + context, +}: ActionFunctionArgs) { + 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, + }); + } + + const formData = await request.formData(); + const apiKey = session.get('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': + return await addPreAuthKey(formData, apiKey, context); + case 'expire_preauthkey': + return await expirePreAuthKey(formData, apiKey, context); + default: + return data('Invalid action', { + status: 400, + }); + } +} + +async function addPreAuthKey( + formData: FormData, + apiKey: string, + context: LoadContext, +) { + const user = formData.get('user')?.toString(); + if (!user) { + return data('Missing `user` 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 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 context.client.post<{ preAuthKey: PreAuthKey }>( + 'v1/preauthkey', + apiKey, + { + user: user, + ephemeral: ephemeral === 'on', + reusable: reusable === 'on', + expiration: date.toISOString(), + aclTags: [], // TODO + }, + ); + + return data('Pre-auth key created'); +} + +async function expirePreAuthKey( + formData: FormData, + apiKey: string, + context: LoadContext, +) { + const key = formData.get('key')?.toString(); + if (!key) { + return data('Missing `key` in the form data.', { + status: 400, + }); + } + + const user = formData.get('user')?.toString(); + if (!user) { + return data('Missing `user` in the form data.', { + status: 400, + }); + } + + await context.client.post('v1/preauthkey/expire', apiKey, { user, key }); + return data('Pre-auth key expired'); +} diff --git a/app/routes/settings/auth-keys/auth-key-row.tsx b/app/routes/settings/auth-keys/auth-key-row.tsx index 2cf073c..77a34a2 100644 --- a/app/routes/settings/auth-keys/auth-key-row.tsx +++ b/app/routes/settings/auth-keys/auth-key-row.tsx @@ -1,24 +1,24 @@ -import type { PreAuthKey } from '~/types'; - 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'; interface Props { authKey: PreAuthKey; - server: string; + user: User; + url: string; } -export default function AuthKeyRow({ authKey, server }: Props) { +export default function AuthKeyRow({ authKey, user, url }: Props) { const createdAt = new Date(authKey.createdAt).toLocaleString(); const expiration = new Date(authKey.expiration).toLocaleString(); return (
- + @@ -32,19 +32,19 @@ export default function AuthKeyRow({ authKey, server }: Props) { To use this key, run the following command on your device:

- tailscale up --login-server {server} --authkey {authKey.key} + tailscale up --login-server={url} --authkey {authKey.key}
{(authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date() ? undefined : ( - + )}