From d00b6b9edda7865736af658e3766031eba22ae36 Mon Sep 17 00:00:00 2001 From: Sonny Date: Thu, 21 Aug 2025 16:39:02 +0200 Subject: [PATCH] feat: add user token management --- app/user/controllers/api_token_controller.ts | 32 ++++ app/user/models/api_token.ts | 52 ++++++ app/user/models/user.ts | 6 + app/user/routes/api_token_routes.ts | 15 ++ app/user/routes/routes.ts | 1 + app/user/services/api_token_service.ts | 54 ++++++ app/user/validators/token/create_api_token.ts | 8 + app/user/validators/token/delete_api_token.ts | 9 + .../show_user_settings_controller.ts | 7 + .../1755640100587_create_api_tokens_table.ts | 35 ++++ .../common/api_tokens/api_tokens.tsx | 159 ++++++++++++++++++ .../common/api_tokens/create_token_modal.tsx | 53 ++++++ .../common/floating_navbar/user_dropdown.tsx | 3 + inertia/hooks/use_api_tokens.ts | 33 ++++ inertia/i18n/locales/en/common.json | 23 ++- inertia/i18n/locales/fr/common.json | 23 ++- inertia/pages/user_settings/show.tsx | 6 + 17 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 app/user/controllers/api_token_controller.ts create mode 100644 app/user/models/api_token.ts create mode 100644 app/user/routes/api_token_routes.ts create mode 100644 app/user/services/api_token_service.ts create mode 100644 app/user/validators/token/create_api_token.ts create mode 100644 app/user/validators/token/delete_api_token.ts create mode 100644 database/migrations/1755640100587_create_api_tokens_table.ts create mode 100644 inertia/components/common/api_tokens/api_tokens.tsx create mode 100644 inertia/components/common/api_tokens/create_token_modal.tsx create mode 100644 inertia/hooks/use_api_tokens.ts diff --git a/app/user/controllers/api_token_controller.ts b/app/user/controllers/api_token_controller.ts new file mode 100644 index 0000000..e35defc --- /dev/null +++ b/app/user/controllers/api_token_controller.ts @@ -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(); + } +} diff --git a/app/user/models/api_token.ts b/app/user/models/api_token.ts new file mode 100644 index 0000000..0e2da95 --- /dev/null +++ b/app/user/models/api_token.ts @@ -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; + + isExpired(): boolean { + if (!this.expiresAt) return false; + return DateTime.now() > this.expiresAt; + } + + isValid(): boolean { + return this.isActive && !this.isExpired(); + } + + async markAsUsed(): Promise { + this.lastUsedAt = DateTime.now(); + await this.save(); + } + + @beforeSave() + static async generateToken(token: ApiToken) { + if (!token.token) { + token.token = randomBytes(32).toString('hex'); + } + } +} diff --git a/app/user/models/user.ts b/app/user/models/user.ts index 03c5d8e..26b253a 100644 --- a/app/user/models/user.ts +++ b/app/user/models/user.ts @@ -3,6 +3,7 @@ import AppBaseModel from '#core/models/app_base_model'; import Link from '#links/models/link'; import { type DisplayPreferences } from '#shared/types/index'; import { ensureDisplayPreferences } from '#user/lib/index'; +import ApiToken from '#user/models/api_token'; import type { GoogleToken } from '@adonisjs/ally/types'; import { column, computed, hasMany } from '@adonisjs/lucid/orm'; import type { HasMany } from '@adonisjs/lucid/types/relations'; @@ -43,6 +44,11 @@ export default class User extends AppBaseModel { }) declare links: HasMany; + @hasMany(() => ApiToken, { + foreignKey: 'userId', + }) + declare apiTokens: HasMany; + @computed() get fullname() { return this.nickName || this.name; diff --git a/app/user/routes/api_token_routes.ts b/app/user/routes/api_token_routes.ts new file mode 100644 index 0000000..9c53f12 --- /dev/null +++ b/app/user/routes/api_token_routes.ts @@ -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()]); diff --git a/app/user/routes/routes.ts b/app/user/routes/routes.ts index 2a3e4df..077b293 100644 --- a/app/user/routes/routes.ts +++ b/app/user/routes/routes.ts @@ -1,2 +1,3 @@ +import './api_token_routes.js'; import './user_display_preferences_route.js'; import './user_theme_route.js'; diff --git a/app/user/services/api_token_service.ts b/app/user/services/api_token_service.ts new file mode 100644 index 0000000..31fa2e4 --- /dev/null +++ b/app/user/services/api_token_service.ts @@ -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 { + return await ApiToken.create({ + userId: user.id, + name, + expiresAt, + isActive: true, + }); + } + + async getUserTokens(userId: number): Promise { + return await ApiToken.query() + .where('userId', userId) + .orderBy('created_at', 'desc'); + } + + async revokeToken(tokenId: number, userId: number): Promise { + const token = await ApiToken.query() + .where('id', tokenId) + .where('userId', userId) + .firstOrFail(); + + token.isActive = false; + await token.save(); + } + + async validateToken(tokenString: string): Promise { + const token = await ApiToken.query() + .where('token', tokenString) + .where('isActive', true) + .first(); + + if (!token || !token.isValid()) { + return null; + } + + await token.markAsUsed(); + return token; + } +} diff --git a/app/user/validators/token/create_api_token.ts b/app/user/validators/token/create_api_token.ts new file mode 100644 index 0000000..d9ac690 --- /dev/null +++ b/app/user/validators/token/create_api_token.ts @@ -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(), + }) +); diff --git a/app/user/validators/token/delete_api_token.ts b/app/user/validators/token/delete_api_token.ts new file mode 100644 index 0000000..60234e5 --- /dev/null +++ b/app/user/validators/token/delete_api_token.ts @@ -0,0 +1,9 @@ +import vine from '@vinejs/vine'; + +export const deleteApiTokenValidator = vine.compile( + vine.object({ + params: vine.object({ + tokenId: vine.number().positive(), + }), + }) +); diff --git a/app/user_settings/controllers/show_user_settings_controller.ts b/app/user_settings/controllers/show_user_settings_controller.ts index 7fd5841..e7a8423 100644 --- a/app/user_settings/controllers/show_user_settings_controller.ts +++ b/app/user_settings/controllers/show_user_settings_controller.ts @@ -1,10 +1,17 @@ +import { ApiTokenService } from '#user/services/api_token_service'; +import { inject } from '@adonisjs/core'; import { HttpContext } from '@adonisjs/core/http'; +@inject() export default class ShowUserSettingsController { + constructor(private apiTokenService: ApiTokenService) {} + public async render({ auth, inertia }: HttpContext) { const user = await auth.authenticate(); + const tokens = await this.apiTokenService.getUserTokens(user.id); return inertia.render('user_settings/show', { user, + tokens, }); } } diff --git a/database/migrations/1755640100587_create_api_tokens_table.ts b/database/migrations/1755640100587_create_api_tokens_table.ts new file mode 100644 index 0000000..0dcabdd --- /dev/null +++ b/database/migrations/1755640100587_create_api_tokens_table.ts @@ -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); + } +} diff --git a/inertia/components/common/api_tokens/api_tokens.tsx b/inertia/components/common/api_tokens/api_tokens.tsx new file mode 100644 index 0000000..b67d53e --- /dev/null +++ b/inertia/components/common/api_tokens/api_tokens.tsx @@ -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: ( + createToken(name)} + onClose={() => modals.closeAll()} + /> + ), + }); + }; + + const handleRevokeToken = async (tokenId: number, tokenName: string) => { + modals.openConfirmModal({ + title: t('api-tokens.revoke'), + children: ( + + {t('api-tokens.confirm-revoke')} {tokenName}? + + ), + 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 ( + + + {t('api-tokens.title')} + + + + + {tokens.length === 0 ? ( + + {t('api-tokens.no-tokens')} + + ) : ( + tokens.map((token) => ( + + + + + {token.name} + {isExpired(token.expiresAt) && ( + + {t('api-tokens.expired')} + + )} + {!token.isActive && ( + + {t('api-tokens.revoked')} + + )} + + + {t('api-tokens.created')}: {formatDate(token.createdAt)} + + + {t('api-tokens.last-used')}: {formatDate(token.lastUsedAt)} + + {token.expiresAt && ( + + {t('api-tokens.expires')}: {formatDate(token.expiresAt)} + + )} + + {token.isActive && ( + + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + handleRevokeToken(token.id, token.name)} + > + + + + + )} + + + )) + )} + + + ); +} diff --git a/inertia/components/common/api_tokens/create_token_modal.tsx b/inertia/components/common/api_tokens/create_token_modal.tsx new file mode 100644 index 0000000..6626fd4 --- /dev/null +++ b/inertia/components/common/api_tokens/create_token_modal.tsx @@ -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; + 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 ( + + + {t('api-tokens.create-description')} + + setTokenName(e.target.value)} + required + /> + + + + + + ); +} diff --git a/inertia/components/common/floating_navbar/user_dropdown.tsx b/inertia/components/common/floating_navbar/user_dropdown.tsx index c70037f..a3093e9 100644 --- a/inertia/components/common/floating_navbar/user_dropdown.tsx +++ b/inertia/components/common/floating_navbar/user_dropdown.tsx @@ -32,6 +32,9 @@ export function UserDropdown() { alt={auth.user?.fullname} radius="xl" size={20} + imageProps={{ + referrerPolicy: 'no-referrer', + }} /> {auth.user?.fullname} diff --git a/inertia/hooks/use_api_tokens.ts b/inertia/hooks/use_api_tokens.ts new file mode 100644 index 0000000..0ec8b4f --- /dev/null +++ b/inertia/hooks/use_api_tokens.ts @@ -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, + }; +} diff --git a/inertia/i18n/locales/en/common.json b/inertia/i18n/locales/en/common.json index e1bb1f3..48f9209 100644 --- a/inertia/i18n/locales/en/common.json +++ b/inertia/i18n/locales/en/common.json @@ -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" + } } diff --git a/inertia/i18n/locales/fr/common.json b/inertia/i18n/locales/fr/common.json index 66e1b3e..9b80cf4 100644 --- a/inertia/i18n/locales/fr/common.json +++ b/inertia/i18n/locales/fr/common.json @@ -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" + } } diff --git a/inertia/pages/user_settings/show.tsx b/inertia/pages/user_settings/show.tsx index 394f278..a0661f8 100644 --- a/inertia/pages/user_settings/show.tsx +++ b/inertia/pages/user_settings/show.tsx @@ -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: , }, + { + label: t('api-tokens.title'), + value: 'api-tokens', + content: , + }, ]; return ; }