feat: rework the entire pre-auth keys page
This commit is contained in:
parent
785d5a663f
commit
3dca401837
@ -4,6 +4,7 @@
|
|||||||
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
- 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.
|
- 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.
|
- 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)
|
### 0.5.10 (April 4, 2025)
|
||||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||||
|
|||||||
@ -78,7 +78,9 @@ function Select(props: SelectProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
'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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronDown className="p-0.5" />
|
<ChevronDown className="p-0.5" />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Computer, KeySquare } from 'lucide-react';
|
import { Computer, FileKey2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
@ -12,6 +12,7 @@ export interface NewMachineProps {
|
|||||||
server: string;
|
server: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
disabledKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewMachine(data: NewMachineProps) {
|
export default function NewMachine(data: NewMachineProps) {
|
||||||
@ -51,7 +52,7 @@ export default function NewMachine(data: NewMachineProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Menu isDisabled={data.isDisabled}>
|
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
||||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
onAction={(key) => {
|
onAction={(key) => {
|
||||||
@ -74,7 +75,7 @@ export default function NewMachine(data: NewMachineProps) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex items-center gap-x-3">
|
||||||
<KeySquare className="w-4" />
|
<FileKey2 className="w-4" />
|
||||||
Generate Pre-auth Key
|
Generate Pre-auth Key
|
||||||
</div>
|
</div>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -69,6 +69,10 @@ export async function loader({
|
|||||||
agents: context.agents?.tailnetIDs(),
|
agents: context.agents?.tailnetIDs(),
|
||||||
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
||||||
writable: writablePermission,
|
writable: writablePermission,
|
||||||
|
preAuth: await context.sessions.check(
|
||||||
|
request,
|
||||||
|
Capabilities.generate_authkeys,
|
||||||
|
),
|
||||||
subject: user.subject,
|
subject: user.subject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -99,6 +103,7 @@ export default function Page() {
|
|||||||
server={data.publicServer ?? data.server}
|
server={data.publicServer ?? data.server}
|
||||||
users={data.users}
|
users={data.users}
|
||||||
isDisabled={!data.writable}
|
isDisabled={!data.writable}
|
||||||
|
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
|
|||||||
@ -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<LoadContext>) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@ -1,24 +1,24 @@
|
|||||||
import type { PreAuthKey } from '~/types';
|
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute';
|
import Attribute from '~/components/Attribute';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
|
import type { PreAuthKey, User } from '~/types';
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
import ExpireAuthKey from './dialogs/expire-auth-key';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
authKey: PreAuthKey;
|
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 createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||||
<Attribute name="User" value={authKey.user} isCopyable />
|
<Attribute name="User" value={user.name} isCopyable />
|
||||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||||
@ -32,19 +32,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
|||||||
To use this key, run the following command on your device:
|
To use this key, run the following command on your device:
|
||||||
</p>
|
</p>
|
||||||
<Code className="text-sm">
|
<Code className="text-sm">
|
||||||
tailscale up --login-server {server} --authkey {authKey.key}
|
tailscale up --login-server={url} --authkey {authKey.key}
|
||||||
</Code>
|
</Code>
|
||||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||||
{(authKey.used && !authKey.reusable) ||
|
{(authKey.used && !authKey.reusable) ||
|
||||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||||
<ExpireAuthKey authKey={authKey} />
|
<ExpireAuthKey authKey={authKey} user={user} />
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
className="my-4"
|
className="my-4"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`,
|
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
toast('Copied command to clipboard');
|
toast('Copied command to clipboard');
|
||||||
|
|||||||
@ -21,13 +21,14 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
|||||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
<input type="hidden" name="action_id" value="add_preauthkey" />
|
||||||
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
|
|
||||||
<Select
|
<Select
|
||||||
isRequired
|
isRequired
|
||||||
label="Owner"
|
label="User"
|
||||||
name="user"
|
name="user"
|
||||||
placeholder="Select a user"
|
placeholder="Select a user"
|
||||||
|
description="This is the user machines will belong to when they authenticate."
|
||||||
|
className="mb-2"
|
||||||
>
|
>
|
||||||
{data.users.map((user) => (
|
{data.users.map((user) => (
|
||||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||||
@ -47,7 +48,7 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
|||||||
unitDisplay: 'short',
|
unitDisplay: 'short',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-6">
|
<div className="flex justify-between items-center gap-2 mt-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||||
<Dialog.Text className="text-sm">
|
<Dialog.Text className="text-sm">
|
||||||
@ -64,7 +65,7 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||||
<div className="flex justify-between items-center mt-6">
|
<div className="flex justify-between items-center gap-2 mt-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||||
<Dialog.Text className="text-sm">
|
<Dialog.Text className="text-sm">
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import type { PreAuthKey } from '~/types';
|
import type { PreAuthKey, User } from '~/types';
|
||||||
|
|
||||||
interface ExpireAuthKeyProps {
|
interface ExpireAuthKeyProps {
|
||||||
authKey: PreAuthKey;
|
authKey: PreAuthKey;
|
||||||
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpireAuthKey({ authKey }: ExpireAuthKeyProps) {
|
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button>Expire Key</Dialog.Button>
|
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||||
<Dialog.Panel method="DELETE" variant="destructive">
|
<Dialog.Panel variant="destructive">
|
||||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||||
<input type="hidden" name="user" value={authKey.user} />
|
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
||||||
|
{/* TODO: Why is Headscale using email as the user ID here?
|
||||||
|
https://github.com/juanfont/headscale/issues/2520 */}
|
||||||
|
<input type="hidden" name="user" value={user.name} />
|
||||||
<input type="hidden" name="key" value={authKey.key} />
|
<input type="hidden" name="key" value={authKey.key} />
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Expiring this authentication key will immediately prevent it from
|
Expiring this authentication key will immediately prevent it from
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { FileKey2 } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useLoaderData } from 'react-router';
|
import { useLoaderData } from 'react-router';
|
||||||
import { Link as RemixLink } from 'react-router';
|
import { Link as RemixLink } from 'react-router';
|
||||||
|
import Code from '~/components/Code';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
|
import Notice from '~/components/Notice';
|
||||||
import Select from '~/components/Select';
|
import Select from '~/components/Select';
|
||||||
import TableList from '~/components/TableList';
|
import TableList from '~/components/TableList';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
|
import { Capabilities } from '~/server/web/roles';
|
||||||
import type { PreAuthKey, User } from '~/types';
|
import type { PreAuthKey, User } from '~/types';
|
||||||
import { send } from '~/utils/res';
|
import log from '~/utils/log';
|
||||||
|
import { authKeysAction } from './actions';
|
||||||
import AuthKeyRow from './auth-key-row';
|
import AuthKeyRow from './auth-key-row';
|
||||||
import AddAuthKey from './dialogs/add-auth-key';
|
import AddAuthKey from './dialogs/add-auth-key';
|
||||||
|
|
||||||
@ -16,146 +21,155 @@ export async function loader({
|
|||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const users = await context.client.get<{ users: User[] }>(
|
const { users } = await context.client.get<{ users: User[] }>(
|
||||||
'v1/user',
|
'v1/user',
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const preAuthKeys = await Promise.all(
|
const preAuthKeys = await Promise.all(
|
||||||
users.users
|
users
|
||||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||||
.map((user) => {
|
.map(async (user) => {
|
||||||
const qp = new URLSearchParams();
|
const qp = new URLSearchParams();
|
||||||
qp.set('user', user.name);
|
qp.set('user', user.name);
|
||||||
|
|
||||||
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
|
try {
|
||||||
`v1/preauthkey?${qp.toString()}`,
|
const { preAuthKeys } = await context.client.get<{
|
||||||
session.get('api_key')!,
|
preAuthKeys: PreAuthKey[];
|
||||||
);
|
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
|
||||||
|
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: [] as PreAuthKey[],
|
||||||
|
};
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const keys = preAuthKeys
|
||||||
|
.filter(({ success }) => success)
|
||||||
|
.map(({ user, preAuthKeys }) => ({
|
||||||
|
user,
|
||||||
|
preAuthKeys,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const missing = preAuthKeys
|
||||||
|
.filter(({ success }) => !success)
|
||||||
|
.map(({ user, error }) => ({
|
||||||
|
user,
|
||||||
|
error,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
|
keys,
|
||||||
users: users.users,
|
missing,
|
||||||
server: context.config.headscale.public_url ?? context.config.headscale.url,
|
users,
|
||||||
|
access: await context.sessions.check(
|
||||||
|
request,
|
||||||
|
Capabilities.generate_authkeys,
|
||||||
|
),
|
||||||
|
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({
|
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||||
request,
|
return authKeysAction(request);
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
const data = await request.formData();
|
|
||||||
|
|
||||||
// Expiring a pre-auth key
|
|
||||||
if (request.method === 'DELETE') {
|
|
||||||
const key = data.get('key');
|
|
||||||
const user = data.get('user');
|
|
||||||
|
|
||||||
if (!key || !user) {
|
|
||||||
return send(
|
|
||||||
{ message: 'Missing parameters' },
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
|
||||||
'v1/preauthkey/expire',
|
|
||||||
session.get('api_key')!,
|
|
||||||
{
|
|
||||||
user: user,
|
|
||||||
key: key,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { message: 'Pre-auth key expired' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creating a new pre-auth key
|
|
||||||
if (request.method === 'POST') {
|
|
||||||
const user = data.get('user');
|
|
||||||
const expiry = data.get('expiry');
|
|
||||||
const reusable = data.get('reusable');
|
|
||||||
const ephemeral = data.get('ephemeral');
|
|
||||||
|
|
||||||
if (!user || !expiry || !reusable || !ephemeral) {
|
|
||||||
return send(
|
|
||||||
{ message: 'Missing parameters' },
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
|
||||||
'v1/preauthkey',
|
|
||||||
session.get('api_key')!,
|
|
||||||
{
|
|
||||||
user: user,
|
|
||||||
ephemeral: ephemeral === 'on',
|
|
||||||
reusable: reusable === 'on',
|
|
||||||
expiration: date.toISOString(),
|
|
||||||
aclTags: [], // TODO
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { message: 'Pre-auth key created', key };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { keys, users, server } = useLoaderData<typeof loader>();
|
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
|
||||||
const [user, setUser] = useState('__headplane_all');
|
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||||
const [status, setStatus] = useState('active');
|
const [status, setStatus] = useState<Status>('active');
|
||||||
|
const isDisabled =
|
||||||
|
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||||
|
|
||||||
const filteredKeys = keys.filter((key) => {
|
const filteredKeys = useMemo(() => {
|
||||||
if (user !== '__headplane_all' && key.user !== user) {
|
const now = new Date();
|
||||||
return false;
|
return keys
|
||||||
}
|
.filter(({ user }) => {
|
||||||
|
if (selectedUser === '__headplane_all') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (status !== 'all') {
|
return user.id === selectedUser;
|
||||||
const now = new Date();
|
})
|
||||||
const expiry = new Date(key.expiration);
|
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||||
|
.filter((key) => {
|
||||||
|
if (status === 'all') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'active') {
|
if (status === 'ephemeral') {
|
||||||
return !(expiry < now) && (!key.used || key.reusable);
|
return key.ephemeral;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'expired') {
|
if (status === 'reusable') {
|
||||||
return key.used || expiry < now;
|
return key.reusable;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'reusable') {
|
const expiry = new Date(key.expiration);
|
||||||
return key.reusable;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'ephemeral') {
|
return expiry < now;
|
||||||
return key.ephemeral;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
if (status === 'active') {
|
||||||
});
|
// Active keys are either not expired or reusable
|
||||||
|
if (expiry < now) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key.used) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.reusable;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [keys, selectedUser, status]);
|
||||||
|
|
||||||
// TODO: Fix the selects
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col md:w-2/3">
|
||||||
<p className="mb-8 text-md">
|
<p className="mb-8 text-md">
|
||||||
<RemixLink to="/settings" className="font-medium">
|
<RemixLink to="/settings" className="font-medium">
|
||||||
Settings
|
Settings
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span> Pre-Auth Keys
|
<span className="mx-2">/</span> Pre-Auth Keys
|
||||||
</p>
|
</p>
|
||||||
|
{!access ? (
|
||||||
|
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
) : missing.length > 0 ? (
|
||||||
|
<Notice title="Missing authentication keys" variant="error">
|
||||||
|
An error occurred while fetching the authentication keys for the
|
||||||
|
following users:{' '}
|
||||||
|
{missing.map(({ user }, index) => (
|
||||||
|
<>
|
||||||
|
<Code key={user.name}>{user.name}</Code>
|
||||||
|
{index < missing.length - 1 ? ', ' : '. '}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
Their keys may not be listed correctly. Please check the server logs
|
||||||
|
for more information.
|
||||||
|
</Notice>
|
||||||
|
) : undefined}
|
||||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Headscale fully supports pre-authentication keys in order to easily add
|
Headscale fully supports pre-authentication keys in order to easily add
|
||||||
@ -171,25 +185,31 @@ export default function Page() {
|
|||||||
<AddAuthKey users={users} />
|
<AddAuthKey users={users} />
|
||||||
<div className="flex items-center gap-4 mt-4">
|
<div className="flex items-center gap-4 mt-4">
|
||||||
<Select
|
<Select
|
||||||
label="Filter by User"
|
label="User"
|
||||||
placeholder="Select a user"
|
placeholder="Select a user"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
defaultSelectedKey="__headplane_all"
|
defaultSelectedKey="__headplane_all"
|
||||||
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
|
isDisabled={isDisabled}
|
||||||
|
onSelectionChange={(value) =>
|
||||||
|
setSelectedUser(value?.toString() ?? '')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||||
...users.map((user) => (
|
...keys.map(({ user }) => (
|
||||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||||
)),
|
)),
|
||||||
]}
|
]}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
label="Filter by status"
|
label="Status"
|
||||||
placeholder="Select a status"
|
placeholder="Select a status"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
defaultSelectedKey="active"
|
defaultSelectedKey="active"
|
||||||
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
|
isDisabled={isDisabled}
|
||||||
|
onSelectionChange={(value) =>
|
||||||
|
setStatus((value?.toString() ?? 'active') as Status)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select.Item key="all">All</Select.Item>
|
<Select.Item key="all">All</Select.Item>
|
||||||
<Select.Item key="active">Active</Select.Item>
|
<Select.Item key="active">Active</Select.Item>
|
||||||
@ -199,16 +219,35 @@ export default function Page() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<TableList className="mt-4">
|
<TableList className="mt-4">
|
||||||
{filteredKeys.length === 0 ? (
|
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||||
<TableList.Item>
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
<p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
|
<FileKey2 />
|
||||||
|
<p className="font-semibold">
|
||||||
|
No pre-auth keys have been created yet.
|
||||||
|
</p>
|
||||||
|
</TableList.Item>
|
||||||
|
) : filteredKeys.length === 0 ? (
|
||||||
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
|
<FileKey2 />
|
||||||
|
<p className="font-semibold">
|
||||||
|
No pre-auth keys match the selected filters.
|
||||||
|
</p>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : (
|
) : (
|
||||||
filteredKeys.map((key) => (
|
filteredKeys.map((key) => {
|
||||||
<TableList.Item key={key.id}>
|
// TODO: Why is Headscale using email as the user ID here?
|
||||||
<AuthKeyRow authKey={key} server={server} />
|
// https://github.com/juanfont/headscale/issues/2520
|
||||||
</TableList.Item>
|
const user = users.find((user) => user.email === key.user);
|
||||||
))
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableList.Item key={key.id}>
|
||||||
|
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||||
|
</TableList.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableList>
|
</TableList>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user