feat: add user token management

This commit is contained in:
Sonny
2025-08-21 16:39:02 +02:00
parent 376e9e32c3
commit d00b6b9edd
17 changed files with 517 additions and 2 deletions

View File

@@ -0,0 +1,32 @@
import { ApiTokenService } from '#user/services/api_token_service';
import { createApiTokenValidator } from '#user/validators/token/create_api_token';
import { deleteApiTokenValidator } from '#user/validators/token/delete_api_token';
import { inject } from '@adonisjs/core';
import { HttpContext } from '@adonisjs/core/http';
import { DateTime } from 'luxon';
@inject()
export default class ApiTokenController {
constructor(private apiTokenService: ApiTokenService) {}
async store({ request, response, auth }: HttpContext) {
const { name, expiresAt } = await request.validateUsing(
createApiTokenValidator
);
await this.apiTokenService.createToken({
user: auth.user!,
name,
expiresAt: expiresAt ? DateTime.fromJSDate(expiresAt) : undefined,
});
return response.redirect().withQs().back();
}
async destroy({ request, response, auth }: HttpContext) {
const { params } = await request.validateUsing(deleteApiTokenValidator);
const tokenId = params.tokenId;
await this.apiTokenService.revokeToken(tokenId, auth.user!.id);
return response.redirect().withQs().back();
}
}

View File

@@ -0,0 +1,52 @@
import AppBaseModel from '#core/models/app_base_model';
import User from '#user/models/user';
import { beforeSave, belongsTo, column } from '@adonisjs/lucid/orm';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
export default class ApiToken extends AppBaseModel {
@column()
declare userId: number;
@column()
declare name: string;
@column()
declare token: string;
@column.dateTime()
declare lastUsedAt: DateTime | null;
@column.dateTime()
declare expiresAt: DateTime | null;
@column()
declare isActive: boolean;
@belongsTo(() => User, {
foreignKey: 'userId',
})
declare user: BelongsTo<typeof User>;
isExpired(): boolean {
if (!this.expiresAt) return false;
return DateTime.now() > this.expiresAt;
}
isValid(): boolean {
return this.isActive && !this.isExpired();
}
async markAsUsed(): Promise<void> {
this.lastUsedAt = DateTime.now();
await this.save();
}
@beforeSave()
static async generateToken(token: ApiToken) {
if (!token.token) {
token.token = randomBytes(32).toString('hex');
}
}
}

View File

@@ -3,6 +3,7 @@ import AppBaseModel from '#core/models/app_base_model';
import Link from '#links/models/link'; import Link from '#links/models/link';
import { type DisplayPreferences } from '#shared/types/index'; import { type DisplayPreferences } from '#shared/types/index';
import { ensureDisplayPreferences } from '#user/lib/index'; import { ensureDisplayPreferences } from '#user/lib/index';
import ApiToken from '#user/models/api_token';
import type { GoogleToken } from '@adonisjs/ally/types'; import type { GoogleToken } from '@adonisjs/ally/types';
import { column, computed, hasMany } from '@adonisjs/lucid/orm'; import { column, computed, hasMany } from '@adonisjs/lucid/orm';
import type { HasMany } from '@adonisjs/lucid/types/relations'; import type { HasMany } from '@adonisjs/lucid/types/relations';
@@ -43,6 +44,11 @@ export default class User extends AppBaseModel {
}) })
declare links: HasMany<typeof Link>; declare links: HasMany<typeof Link>;
@hasMany(() => ApiToken, {
foreignKey: 'userId',
})
declare apiTokens: HasMany<typeof ApiToken>;
@computed() @computed()
get fullname() { get fullname() {
return this.nickName || this.name; return this.nickName || this.name;

View File

@@ -0,0 +1,15 @@
import { middleware } from '#start/kernel';
import router from '@adonisjs/core/services/router';
const ApiTokenController = () =>
import('#user/controllers/api_token_controller');
router
.group(() => {
router.post('/', [ApiTokenController, 'store']).as('user.api-tokens.store');
router
.delete('/:tokenId', [ApiTokenController, 'destroy'])
.as('user.api-tokens.destroy');
})
.prefix('/user/api-tokens')
.middleware([middleware.auth()]);

View File

@@ -1,2 +1,3 @@
import './api_token_routes.js';
import './user_display_preferences_route.js'; import './user_display_preferences_route.js';
import './user_theme_route.js'; import './user_theme_route.js';

View File

@@ -0,0 +1,54 @@
import ApiToken from '#user/models/api_token';
import User from '#user/models/user';
import { DateTime } from 'luxon';
type CreateApiTokenPayload = {
user: User;
name: string;
expiresAt?: DateTime;
};
export class ApiTokenService {
async createToken({
user,
name,
expiresAt,
}: CreateApiTokenPayload): Promise<ApiToken> {
return await ApiToken.create({
userId: user.id,
name,
expiresAt,
isActive: true,
});
}
async getUserTokens(userId: number): Promise<ApiToken[]> {
return await ApiToken.query()
.where('userId', userId)
.orderBy('created_at', 'desc');
}
async revokeToken(tokenId: number, userId: number): Promise<void> {
const token = await ApiToken.query()
.where('id', tokenId)
.where('userId', userId)
.firstOrFail();
token.isActive = false;
await token.save();
}
async validateToken(tokenString: string): Promise<ApiToken | null> {
const token = await ApiToken.query()
.where('token', tokenString)
.where('isActive', true)
.first();
if (!token || !token.isValid()) {
return null;
}
await token.markAsUsed();
return token;
}
}

View File

@@ -0,0 +1,8 @@
import vine from '@vinejs/vine';
export const createApiTokenValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255),
expiresAt: vine.date().optional(),
})
);

View File

@@ -0,0 +1,9 @@
import vine from '@vinejs/vine';
export const deleteApiTokenValidator = vine.compile(
vine.object({
params: vine.object({
tokenId: vine.number().positive(),
}),
})
);

View File

@@ -1,10 +1,17 @@
import { ApiTokenService } from '#user/services/api_token_service';
import { inject } from '@adonisjs/core';
import { HttpContext } from '@adonisjs/core/http'; import { HttpContext } from '@adonisjs/core/http';
@inject()
export default class ShowUserSettingsController { export default class ShowUserSettingsController {
constructor(private apiTokenService: ApiTokenService) {}
public async render({ auth, inertia }: HttpContext) { public async render({ auth, inertia }: HttpContext) {
const user = await auth.authenticate(); const user = await auth.authenticate();
const tokens = await this.apiTokenService.getUserTokens(user.id);
return inertia.render('user_settings/show', { return inertia.render('user_settings/show', {
user, user,
tokens,
}); });
} }
} }

View File

@@ -0,0 +1,35 @@
import { defaultTableFields } from '#database/default_table_fields';
import { BaseSchema } from '@adonisjs/lucid/schema';
export default class CreateApiTokensTable extends BaseSchema {
static tableName = 'api_tokens';
async up() {
const exists = await this.schema.hasTable(CreateApiTokensTable.tableName);
if (exists) {
return console.warn(
`Table ${CreateApiTokensTable.tableName} already exists.`
);
}
this.schema.createTable(CreateApiTokensTable.tableName, (table) => {
table
.integer('user_id')
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.string('name', 255).notNullable();
table.string('token', 255).notNullable().unique();
table.timestamp('last_used_at').nullable();
table.timestamp('expires_at').nullable();
table.boolean('is_active').defaultTo(true).notNullable();
defaultTableFields(table);
});
}
async down() {
this.schema.dropTable(CreateApiTokensTable.tableName);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -32,6 +32,9 @@ export function UserDropdown() {
alt={auth.user?.fullname} alt={auth.user?.fullname}
radius="xl" radius="xl"
size={20} size={20}
imageProps={{
referrerPolicy: 'no-referrer',
}}
/> />
<Text fw={500} size="sm" lh={1} mr={3}> <Text fw={500} size="sm" lh={1} mr={3}>
{auth.user?.fullname} {auth.user?.fullname}

View 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,
};
}

View File

@@ -94,5 +94,26 @@
"list": "List", "list": "List",
"grid": "Grid" "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"
}
} }

View File

@@ -94,5 +94,26 @@
"list": "Liste", "list": "Liste",
"grid": "Grille" "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"
}
} }

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ApiTokens } from '~/components/common/api_tokens/api_tokens';
import { import {
FloatingTab, FloatingTab,
FloatingTabs, FloatingTabs,
@@ -14,6 +15,11 @@ function UserSettingsShow() {
value: 'preferences', value: 'preferences',
content: <UserPreferences />, content: <UserPreferences />,
}, },
{
label: t('api-tokens.title'),
value: 'api-tokens',
content: <ApiTokens />,
},
]; ];
return <FloatingTabs tabs={tabs} />; return <FloatingTabs tabs={tabs} />;
} }