mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 14:43:24 +00:00
feat: add user token management
This commit is contained in:
159
inertia/components/common/api_tokens/api_tokens.tsx
Normal file
159
inertia/components/common/api_tokens/api_tokens.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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 { useApiTokens } from '~/hooks/use_api_tokens';
|
||||
import { CreateTokenModal } from './create_token_modal';
|
||||
|
||||
export function ApiTokens() {
|
||||
const { t } = useTranslation();
|
||||
const { tokens, createToken, revokeToken } = useApiTokens();
|
||||
|
||||
const handleCreateTokenModal = () => {
|
||||
modals.open({
|
||||
title: t('api-tokens.create-new'),
|
||||
children: (
|
||||
<CreateTokenModal
|
||||
onCreate={(name) => createToken(name)}
|
||||
onClose={() => modals.closeAll()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevokeToken = async (tokenId: number, tokenName: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: t('api-tokens.revoke'),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t('api-tokens.confirm-revoke')} <strong>{tokenName}</strong>?
|
||||
</Text>
|
||||
),
|
||||
labels: {
|
||||
confirm: t('api-tokens.revoke'),
|
||||
cancel: t('common.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 isExpired = (expiresAt: string | null) => {
|
||||
if (!expiresAt) return false;
|
||||
return DateTime.fromISO(expiresAt) < DateTime.now();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={500}>{t('api-tokens.title')}</Text>
|
||||
<Button
|
||||
leftSection={<TbPlus size={16} />}
|
||||
onClick={handleCreateTokenModal}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{t('api-tokens.create')}
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
inertia/components/common/api_tokens/create_token_modal.tsx
Normal file
53
inertia/components/common/api_tokens/create_token_modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Button, Group, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CreateTokenModalProps {
|
||||
onCreate: (name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tokenName, setTokenName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!tokenName.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onCreate(tokenName);
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('api-tokens.create-description')}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={t('api-tokens.name')}
|
||||
placeholder={t('api-tokens.name-placeholder')}
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!tokenName.trim() || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('api-tokens.create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,9 @@ export function UserDropdown() {
|
||||
alt={auth.user?.fullname}
|
||||
radius="xl"
|
||||
size={20}
|
||||
imageProps={{
|
||||
referrerPolicy: 'no-referrer',
|
||||
}}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
{auth.user?.fullname}
|
||||
|
||||
33
inertia/hooks/use_api_tokens.ts
Normal file
33
inertia/hooks/use_api_tokens.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
|
||||
interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function useApiTokens() {
|
||||
const {
|
||||
props: { tokens },
|
||||
} = usePage<{
|
||||
tokens: ApiToken[];
|
||||
}>();
|
||||
|
||||
const createToken = async (name: string, expiresAt?: Date) => {
|
||||
return router.post('/user/api-tokens', { name, expiresAt });
|
||||
};
|
||||
|
||||
const revokeToken = async (tokenId: number) => {
|
||||
return router.delete(`/user/api-tokens/${tokenId}`);
|
||||
};
|
||||
|
||||
return {
|
||||
tokens,
|
||||
createToken,
|
||||
revokeToken,
|
||||
};
|
||||
}
|
||||
@@ -94,5 +94,26 @@
|
||||
"list": "List",
|
||||
"grid": "Grid"
|
||||
},
|
||||
"coming-soon": "Under development"
|
||||
"coming-soon": "Under development",
|
||||
"api-tokens": {
|
||||
"title": "API Tokens",
|
||||
"create": "Create token",
|
||||
"create-new": "Create new token",
|
||||
"create-description": "This token will be used for browser extension authentication.",
|
||||
"name": "Token name",
|
||||
"name-placeholder": "Ex: Chrome Extension",
|
||||
"token-created": "Token created successfully",
|
||||
"token-created-description": "Copy this token and keep it safe. It will not be displayed again after closing this window.",
|
||||
"revoke": "Revoke",
|
||||
"confirm-revoke": "Are you sure you want to revoke this token?",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"no-tokens": "No tokens created",
|
||||
"expired": "Expired",
|
||||
"revoked": "Revoked",
|
||||
"created": "Created",
|
||||
"last-used": "Last used",
|
||||
"expires": "Expires",
|
||||
"never": "Never"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,5 +94,26 @@
|
||||
"list": "Liste",
|
||||
"grid": "Grille"
|
||||
},
|
||||
"coming-soon": "En cours de développement"
|
||||
"coming-soon": "En cours de développement",
|
||||
"api-tokens": {
|
||||
"title": "Tokens API",
|
||||
"create": "Créer un token",
|
||||
"create-new": "Créer un nouveau token",
|
||||
"create-description": "Ce token sera utilisé pour l'authentification de votre extension navigateur.",
|
||||
"name": "Nom du token",
|
||||
"name-placeholder": "Ex: Extension Chrome",
|
||||
"token-created": "Token créé avec succès",
|
||||
"token-created-description": "Copiez ce token et conservez-le en lieu sûr. Il ne sera plus affiché après fermeture de cette fenêtre.",
|
||||
"revoke": "Révoquer",
|
||||
"confirm-revoke": "Êtes-vous sûr de vouloir révoquer ce token ?",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"no-tokens": "Aucun token créé",
|
||||
"expired": "Expiré",
|
||||
"revoked": "Révoqué",
|
||||
"created": "Créé",
|
||||
"last-used": "Dernière utilisation",
|
||||
"expires": "Expire",
|
||||
"never": "Jamais"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiTokens } from '~/components/common/api_tokens/api_tokens';
|
||||
import {
|
||||
FloatingTab,
|
||||
FloatingTabs,
|
||||
@@ -14,6 +15,11 @@ function UserSettingsShow() {
|
||||
value: 'preferences',
|
||||
content: <UserPreferences />,
|
||||
},
|
||||
{
|
||||
label: t('api-tokens.title'),
|
||||
value: 'api-tokens',
|
||||
content: <ApiTokens />,
|
||||
},
|
||||
];
|
||||
return <FloatingTabs tabs={tabs} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user