refactor: use adonis's access tokens instead of creating custom (and unsecured) logic

This commit is contained in:
Sonny
2025-08-22 18:35:50 +02:00
parent d00b6b9edd
commit 9aa71dad30
19 changed files with 241 additions and 402 deletions

View File

@@ -1,25 +1,33 @@
import { usePage } from '@inertiajs/react';
import {
ActionIcon,
Badge,
Button,
Card,
CopyButton,
Group,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { modals } from '@mantine/modals';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { TbCheck, TbCopy, TbPlus, TbTrash } from 'react-icons/tb';
import { TbPlus, TbTrash } from 'react-icons/tb';
import { SimpleTable } from '~/components/common/simple_table/simple_table';
import { useApiTokens } from '~/hooks/use_api_tokens';
import { ApiToken } from '~/types/app';
import { CreateTokenModal } from './create_token_modal';
const useGetCreatedToken = () => {
const newlyCreatedToken = usePage<{
token?: ApiToken;
}>().props.token;
return newlyCreatedToken;
};
export function ApiTokens() {
const { t } = useTranslation();
const { tokens, createToken, revokeToken } = useApiTokens();
const newlyCreatedToken = useGetCreatedToken();
const handleCreateTokenModal = () => {
modals.open({
title: t('api-tokens.create-new'),
@@ -32,32 +40,67 @@ export function ApiTokens() {
});
};
const handleRevokeToken = async (tokenId: number, tokenName: string) => {
const handleRevokeToken = async (tokenId: number) => {
const token = tokens.find((t) => t.identifier === tokenId);
if (!token) return;
modals.openConfirmModal({
title: t('api-tokens.revoke'),
children: (
<Text size="sm">
{t('api-tokens.confirm-revoke')} <strong>{tokenName}</strong>?
</Text>
title: (
<>
{t('api-tokens.revoke')} "<strong>{token.name}</strong>"
</>
),
children: <Text size="sm">{t('api-tokens.confirm-revoke')}</Text>,
labels: {
confirm: t('api-tokens.revoke'),
cancel: t('common.cancel'),
cancel: t('cancel'),
},
confirmProps: { color: 'red' },
onConfirm: () => revokeToken(tokenId),
});
};
const formatDate = (dateString: string | null) => {
if (!dateString) return t('api-tokens.never');
return DateTime.fromISO(dateString).toRelative();
};
const generateTokenRow = (token: ApiToken) =>
newlyCreatedToken?.identifier === token.identifier && (
<>
<Text c="green" size="sm">
{t('api-tokens.new-token')}{' '}
{newlyCreatedToken.token && (
<CopyButton value={newlyCreatedToken.token}>
{({ copied, copy }) => (
<Button
color={copied ? 'teal' : 'blue'}
onClick={copy}
size="xs"
variant="light"
>
{copied ? t('copied') : t('copy')}
</Button>
)}
</CopyButton>
)}
</Text>
</>
);
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return DateTime.fromISO(expiresAt) < DateTime.now();
};
const generateRow = (token: ApiToken) => ({
key: token.identifier.toString(),
name: token.name,
token: generateTokenRow(token) || undefined,
expiresAt: token.expiresAt,
lastUsedAt: token.lastUsedAt,
actions: [
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleRevokeToken(token.identifier)}
>
<TbTrash size={16} />
</ActionIcon>,
],
});
const rows = tokens.map(generateRow);
return (
<Card withBorder>
@@ -73,87 +116,13 @@ export function ApiTokens() {
</Button>
</Group>
<Stack gap="sm">
{tokens.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('api-tokens.no-tokens')}
</Text>
) : (
tokens.map((token) => (
<Card
key={token.id}
withBorder
p="sm"
opacity={token.isActive ? 1 : 0.5}
>
<Group justify="space-between" align="flex-start">
<Stack gap="xs" style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={500}>{token.name}</Text>
{isExpired(token.expiresAt) && (
<Badge color="red" variant="light" size="xs">
{t('api-tokens.expired')}
</Badge>
)}
{!token.isActive && (
<Badge color="gray" variant="light" size="xs">
{t('api-tokens.revoked')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{t('api-tokens.created')}: {formatDate(token.createdAt)}
</Text>
<Text size="xs" c="dimmed">
{t('api-tokens.last-used')}: {formatDate(token.lastUsedAt)}
</Text>
{token.expiresAt && (
<Text size="xs" c="dimmed">
{t('api-tokens.expires')}: {formatDate(token.expiresAt)}
</Text>
)}
</Stack>
{token.isActive && (
<Group gap="xs">
<CopyButton value={token.token} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={
copied
? t('api-tokens.copied')
: t('api-tokens.copy')
}
>
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
variant="subtle"
>
{copied ? (
<TbCheck size={16} />
) : (
<TbCopy size={16} />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
<Tooltip label={t('api-tokens.revoke')}>
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleRevokeToken(token.id, token.name)}
>
<TbTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Card>
))
)}
</Stack>
{tokens.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
{t('api-tokens.no-tokens')}
</Text>
)}
{tokens.length > 0 && <SimpleTable data={rows} />}
</Card>
);
}

View File

@@ -38,7 +38,7 @@ export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) {
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
{t('cancel')}
</Button>
<Button
onClick={handleCreate}

View File

@@ -0,0 +1,20 @@
.header {
position: sticky;
top: 0;
background-color: var(--mantine-color-body);
transition: box-shadow 150ms ease;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-3));
}
}
.scrolled {
box-shadow: var(--mantine-shadow-sm);
}

View File

@@ -0,0 +1,56 @@
import { ScrollArea, Table, Text } from '@mantine/core';
import cx from 'clsx';
import { useState } from 'react';
import classes from './simple_table.module.css';
export type SimpleTableData = {
key: string;
[key: string]: string | React.ReactNode | undefined;
actions?: React.ReactNode[];
};
interface SimpleTableProps {
data: SimpleTableData[];
}
export function SimpleTable({ data }: SimpleTableProps) {
const [scrolled, setScrolled] = useState(false);
const columns = data.length > 0 ? Object.keys(data[0]) : [];
const rows = data.map((row) => {
return (
<Table.Tr key={row.key}>
{columns.map((column) => (
<Table.Td key={column}>
{row[column] ?? (
<Text c="dimmed" size="sm">
N/A
</Text>
)}
</Table.Td>
))}
</Table.Tr>
);
});
return (
<ScrollArea
h={300}
onScrollPositionChange={({ y }) => setScrolled(y !== 0)}
>
<Table miw={700}>
<Table.Thead
className={cx(classes.header, { [classes.scrolled]: scrolled })}
>
<Table.Tr>
{columns.map((column) => (
<Table.Th key={column}>{column}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -1,29 +0,0 @@
.header {
height: rem(60px);
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.link {
display: flex;
align-items: center;
height: 100%;
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
text-decoration: none;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-weight: 500;
font-size: var(--mantine-font-size-sm);
@media (max-width: $mantine-breakpoint-sm) {
height: rem(42px);
width: 100%;
}
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}

View File

@@ -1,119 +0,0 @@
import PATHS from '#core/constants/paths';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import {
Box,
Burger,
Button,
Divider,
Drawer,
Group,
Image,
ScrollArea,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { LocaleSwitcher } from '~/components/common/locale_switcher';
import { ThemeSwitcher } from '~/components/common/theme_switcher';
import useUser from '~/hooks/use_auth';
import classes from './mobile.module.css';
export default function Navbar() {
const { t } = useTranslation('common');
const { isAuthenticated } = useUser();
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure(false);
return (
<Box pb={40}>
<header className={classes.header}>
<Group justify="space-between" h="100%">
<Link href="/">
<Image src="/logo.png" h={35} alt="MyLinks's logo" />
</Link>
<Group h="100%" gap={0} visibleFrom="sm">
<Link href="/" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
</Group>
<Group gap="xs">
<ThemeSwitcher />
<LocaleSwitcher />
{!isAuthenticated ? (
<Button
component="a"
href={route('auth').path}
visibleFrom="sm"
w={110}
>
{t('login')}
</Button>
) : (
<Button
component={Link}
href={route('dashboard').path}
visibleFrom="sm"
w={110}
>
Dashboard
</Button>
)}
<Burger
opened={drawerOpened}
onClick={toggleDrawer}
hiddenFrom="sm"
/>
</Group>
</Group>
</header>
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
size="100%"
padding="md"
title="Navigation"
hiddenFrom="sm"
zIndex={1000000}
>
<ScrollArea h={`calc(100vh - ${rem(80)})`} mx="-md">
<Divider my="sm" />
<Link href="#" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
<Divider my="sm" />
<Group justify="center" grow pb="xl" px="md">
{!isAuthenticated ? (
<Button component="a" href={route('auth').path} w={110}>
{t('login')}
</Button>
) : (
<Button component={Link} href={route('dashboard').path} w={110}>
Dashboard
</Button>
)}
</Group>
</ScrollArea>
</Drawer>
</Box>
);
}

View File

@@ -1,14 +1,5 @@
import { router, usePage } from '@inertiajs/react';
interface ApiToken {
id: number;
name: string;
token: string;
lastUsedAt: string | null;
expiresAt: string | null;
isActive: boolean;
createdAt: string;
}
import { ApiToken } from '~/types/app';
export function useApiTokens() {
const {

View File

@@ -114,6 +114,7 @@
"created": "Created",
"last-used": "Last used",
"expires": "Expires",
"never": "Never"
"never": "Never",
"new-token": "New token created"
}
}

View File

@@ -114,6 +114,7 @@
"created": "Créé",
"last-used": "Dernière utilisation",
"expires": "Expire",
"never": "Jamais"
"never": "Jamais",
"new-token": "Nouveau token créé"
}
}

View File

@@ -63,3 +63,13 @@ export enum Visibility {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}
export type ApiToken = {
identifier: number;
token: string | undefined;
name: string | null;
type: 'bearer';
lastUsedAt: string | null;
expiresAt: string | null;
abilities: string[];
};