feat: rework the entire pre-auth keys page

This commit is contained in:
Aarnav Tale 2025-04-06 15:41:03 -04:00
parent 785d5a663f
commit 3dca401837
9 changed files with 315 additions and 144 deletions

View File

@ -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.

View File

@ -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" />

View File

@ -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>

View File

@ -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">

View File

@ -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');
}

View File

@ -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');

View File

@ -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">

View File

@ -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

View File

@ -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>