From 9aa71dad3025e64b5dc4a0d1b1767fd2f4c67085 Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 22 Aug 2025 18:35:50 +0200 Subject: [PATCH] refactor: use adonis's access tokens instead of creating custom (and unsecured) logic --- app/user/controllers/api_token_controller.ts | 27 ++- app/user/models/api_token.ts | 52 ------ app/user/models/user.ts | 9 +- app/user/services/api_token_service.ts | 53 ++---- app/user/validators/token/delete_api_token.ts | 2 +- .../show_user_settings_controller.ts | 9 +- config/inertia.ts | 1 + .../1755640100587_create_api_tokens_table.ts | 35 ---- ...8189587_create_auth_access_tokens_table.ts | 31 ++++ .../common/api_tokens/api_tokens.tsx | 171 +++++++----------- .../common/api_tokens/create_token_modal.tsx | 2 +- .../simple_table/simple_table.module.css | 20 ++ .../common/simple_table/simple_table.tsx | 56 ++++++ inertia/components/navbar/mobile.module.css | 29 --- inertia/components/navbar/navbar.tsx | 119 ------------ inertia/hooks/use_api_tokens.ts | 11 +- inertia/i18n/locales/en/common.json | 3 +- inertia/i18n/locales/fr/common.json | 3 +- inertia/types/app.ts | 10 + 19 files changed, 241 insertions(+), 402 deletions(-) delete mode 100644 app/user/models/api_token.ts delete mode 100644 database/migrations/1755640100587_create_api_tokens_table.ts create mode 100644 database/migrations/1755788189587_create_auth_access_tokens_table.ts create mode 100644 inertia/components/common/simple_table/simple_table.module.css create mode 100644 inertia/components/common/simple_table/simple_table.tsx delete mode 100644 inertia/components/navbar/mobile.module.css delete mode 100644 inertia/components/navbar/navbar.tsx diff --git a/app/user/controllers/api_token_controller.ts b/app/user/controllers/api_token_controller.ts index e35defc..a935398 100644 --- a/app/user/controllers/api_token_controller.ts +++ b/app/user/controllers/api_token_controller.ts @@ -3,21 +3,23 @@ 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) { + async store({ request, response, auth, session }: HttpContext) { const { name, expiresAt } = await request.validateUsing( createApiTokenValidator ); - - await this.apiTokenService.createToken({ - user: auth.user!, + const token = await this.apiTokenService.createToken(auth.user!, { name, - expiresAt: expiresAt ? DateTime.fromJSDate(expiresAt) : undefined, + expiresAt, + }); + session.flash('token', { + ...token.toJSON(), + token: token.value?.release(), + identifier: token.identifier, }); return response.redirect().withQs().back(); } @@ -26,7 +28,18 @@ export default class ApiTokenController { const { params } = await request.validateUsing(deleteApiTokenValidator); const tokenId = params.tokenId; - await this.apiTokenService.revokeToken(tokenId, auth.user!.id); + const token = await this.apiTokenService.getTokenByValue( + auth.user!, + tokenId + ); + if (!token) { + return response.notFound(); + } + + await this.apiTokenService.revokeToken( + auth.user!, + Number(token.identifier) + ); return response.redirect().withQs().back(); } } diff --git a/app/user/models/api_token.ts b/app/user/models/api_token.ts deleted file mode 100644 index 0e2da95..0000000 --- a/app/user/models/api_token.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 26b253a..2d29347 100644 --- a/app/user/models/user.ts +++ b/app/user/models/user.ts @@ -3,8 +3,8 @@ 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 { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'; import { column, computed, hasMany } from '@adonisjs/lucid/orm'; import type { HasMany } from '@adonisjs/lucid/types/relations'; import { DateTime } from 'luxon'; @@ -44,11 +44,6 @@ export default class User extends AppBaseModel { }) declare links: HasMany; - @hasMany(() => ApiToken, { - foreignKey: 'userId', - }) - declare apiTokens: HasMany; - @computed() get fullname() { return this.nickName || this.name; @@ -70,4 +65,6 @@ export default class User extends AppBaseModel { prepare: (value) => JSON.stringify(value), }) declare displayPreferences: DisplayPreferences; + + static accessTokens = DbAccessTokensProvider.forModel(User); } diff --git a/app/user/services/api_token_service.ts b/app/user/services/api_token_service.ts index 31fa2e4..9b9fb30 100644 --- a/app/user/services/api_token_service.ts +++ b/app/user/services/api_token_service.ts @@ -1,54 +1,33 @@ -import ApiToken from '#user/models/api_token'; import User from '#user/models/user'; -import { DateTime } from 'luxon'; +import { AccessToken } from '@adonisjs/auth/access_tokens'; -type CreateApiTokenPayload = { - user: User; +type CreateTokenParams = { name: string; - expiresAt?: DateTime; + expiresAt?: Date; }; export class ApiTokenService { - async createToken({ - user, - name, - expiresAt, - }: CreateApiTokenPayload): Promise { - return await ApiToken.create({ - userId: user.id, + createToken(user: User, { name, expiresAt }: CreateTokenParams) { + const expiresIn = expiresAt ? expiresAt.getTime() - Date.now() : undefined; + return User.accessTokens.create(user, undefined, { name, - expiresAt, - isActive: true, + expiresIn, }); } - async getUserTokens(userId: number): Promise { - return await ApiToken.query() - .where('userId', userId) - .orderBy('created_at', 'desc'); + getTokens(user: User) { + return User.accessTokens.all(user); } - 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(); + revokeToken(user: User, identifier: number) { + return User.accessTokens.delete(user, identifier); } - async validateToken(tokenString: string): Promise { - const token = await ApiToken.query() - .where('token', tokenString) - .where('isActive', true) - .first(); + validateToken(token: AccessToken) { + return User.accessTokens.verify(token.value!); + } - if (!token || !token.isValid()) { - return null; - } - - await token.markAsUsed(); - return token; + getTokenByValue(user: User, value: string) { + return User.accessTokens.find(user, value); } } diff --git a/app/user/validators/token/delete_api_token.ts b/app/user/validators/token/delete_api_token.ts index 60234e5..41ac762 100644 --- a/app/user/validators/token/delete_api_token.ts +++ b/app/user/validators/token/delete_api_token.ts @@ -3,7 +3,7 @@ import vine from '@vinejs/vine'; export const deleteApiTokenValidator = vine.compile( vine.object({ params: vine.object({ - tokenId: vine.number().positive(), + tokenId: vine.string().trim().minLength(1).maxLength(255), }), }) ); diff --git a/app/user_settings/controllers/show_user_settings_controller.ts b/app/user_settings/controllers/show_user_settings_controller.ts index e7a8423..c8d4f6e 100644 --- a/app/user_settings/controllers/show_user_settings_controller.ts +++ b/app/user_settings/controllers/show_user_settings_controller.ts @@ -8,10 +8,15 @@ export default class ShowUserSettingsController { public async render({ auth, inertia }: HttpContext) { const user = await auth.authenticate(); - const tokens = await this.apiTokenService.getUserTokens(user.id); + const tokens = await this.apiTokenService.getTokens(user); return inertia.render('user_settings/show', { user, - tokens, + tokens: tokens.map((token) => { + return { + ...token.toJSON(), + identifier: token.identifier, + }; + }), }); } } diff --git a/config/inertia.ts b/config/inertia.ts index 5634984..7f61afb 100644 --- a/config/inertia.ts +++ b/config/inertia.ts @@ -15,6 +15,7 @@ export default defineConfig({ */ sharedData: { errors: (ctx) => ctx.session?.flashMessages.get('errors'), + token: (ctx) => ctx.session?.flashMessages.get('token'), user: (ctx) => ({ theme: ctx.session?.get(KEY_USER_THEME, DEFAULT_USER_THEME), }), diff --git a/database/migrations/1755640100587_create_api_tokens_table.ts b/database/migrations/1755640100587_create_api_tokens_table.ts deleted file mode 100644 index 0dcabdd..0000000 --- a/database/migrations/1755640100587_create_api_tokens_table.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/database/migrations/1755788189587_create_auth_access_tokens_table.ts b/database/migrations/1755788189587_create_auth_access_tokens_table.ts new file mode 100644 index 0000000..846b149 --- /dev/null +++ b/database/migrations/1755788189587_create_auth_access_tokens_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema'; + +export default class CreateAuthAccessTokensTable extends BaseSchema { + protected tableName = 'auth_access_tokens'; + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id'); + table + .integer('tokenable_id') + .notNullable() + .unsigned() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + table.string('type').notNullable(); + table.string('name').nullable(); + table.string('hash').notNullable(); + table.text('abilities').notNullable(); + table.timestamp('created_at'); + table.timestamp('updated_at'); + table.timestamp('last_used_at').nullable(); + table.timestamp('expires_at').nullable(); + }); + } + + async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/inertia/components/common/api_tokens/api_tokens.tsx b/inertia/components/common/api_tokens/api_tokens.tsx index b67d53e..283fd0e 100644 --- a/inertia/components/common/api_tokens/api_tokens.tsx +++ b/inertia/components/common/api_tokens/api_tokens.tsx @@ -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: ( - - {t('api-tokens.confirm-revoke')} {tokenName}? - + title: ( + <> + {t('api-tokens.revoke')} "{token.name}" + ), + children: {t('api-tokens.confirm-revoke')}, 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 && ( + <> + + {t('api-tokens.new-token')}{' '} + {newlyCreatedToken.token && ( + + {({ copied, copy }) => ( + + )} + + )} + + + ); - 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: [ + handleRevokeToken(token.identifier)} + > + + , + ], + }); + + const rows = tokens.map(generateRow); return ( @@ -73,87 +116,13 @@ export function ApiTokens() { - - {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)} - > - - - - - )} - - - )) - )} - + {tokens.length === 0 && ( + + {t('api-tokens.no-tokens')} + + )} + + {tokens.length > 0 && } ); } diff --git a/inertia/components/common/api_tokens/create_token_modal.tsx b/inertia/components/common/api_tokens/create_token_modal.tsx index 6626fd4..e2baa1a 100644 --- a/inertia/components/common/api_tokens/create_token_modal.tsx +++ b/inertia/components/common/api_tokens/create_token_modal.tsx @@ -38,7 +38,7 @@ export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) { /> - ) : ( - - )} - - - - - - - - - - - {t('home')} - - - Github - - - Extension - - - - - - {!isAuthenticated ? ( - - ) : ( - - )} - - - - - ); -} diff --git a/inertia/hooks/use_api_tokens.ts b/inertia/hooks/use_api_tokens.ts index 0ec8b4f..461daed 100644 --- a/inertia/hooks/use_api_tokens.ts +++ b/inertia/hooks/use_api_tokens.ts @@ -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 { diff --git a/inertia/i18n/locales/en/common.json b/inertia/i18n/locales/en/common.json index 48f9209..642a291 100644 --- a/inertia/i18n/locales/en/common.json +++ b/inertia/i18n/locales/en/common.json @@ -114,6 +114,7 @@ "created": "Created", "last-used": "Last used", "expires": "Expires", - "never": "Never" + "never": "Never", + "new-token": "New token created" } } diff --git a/inertia/i18n/locales/fr/common.json b/inertia/i18n/locales/fr/common.json index 9b80cf4..af8ba79 100644 --- a/inertia/i18n/locales/fr/common.json +++ b/inertia/i18n/locales/fr/common.json @@ -114,6 +114,7 @@ "created": "Créé", "last-used": "Dernière utilisation", "expires": "Expire", - "never": "Jamais" + "never": "Jamais", + "new-token": "Nouveau token créé" } } diff --git a/inertia/types/app.ts b/inertia/types/app.ts index 764bba9..470e85c 100644 --- a/inertia/types/app.ts +++ b/inertia/types/app.ts @@ -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[]; +};