From 10f2b800258079199c2f17324dc93a93de0e15c5 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 25 Aug 2026 11:07:31 +0300 Subject: [PATCH] feat: last used column for api tokens (#3230) --- .../t.$teamUrl+/settings.tokens.tsx | 17 ++++++++++++ .../public-api/get-api-token-by-token.ts | 26 +++++++++++++++++++ .../server-only/public-api/get-api-tokens.ts | 1 + .../migration.sql | 2 ++ packages/prisma/schema.prisma | 21 ++++++++------- .../api-token-router/get-api-tokens.types.ts | 1 + 6 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 packages/prisma/migrations/20260818081941_add_api_token_last_used_property/migration.sql diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx index 239137d0b..548933f01 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx @@ -68,6 +68,20 @@ export default function ApiTokensPage() { return i18n.date(row.original.expires); }, }, + { + header: t`Last Used`, + cell: ({ row }) => { + if (!row.original.lastUsedAt) { + return ( + + Never + + ); + } + + return {i18n.date(row.original.lastUsedAt)}; + }, + }, { header: t`Actions`, cell: ({ row }) => ( @@ -146,6 +160,9 @@ export default function ApiTokensPage() { + + + diff --git a/packages/lib/server-only/public-api/get-api-token-by-token.ts b/packages/lib/server-only/public-api/get-api-token-by-token.ts index d25b9b902..abfab5ec3 100644 --- a/packages/lib/server-only/public-api/get-api-token-by-token.ts +++ b/packages/lib/server-only/public-api/get-api-token-by-token.ts @@ -1,9 +1,12 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { logger } from '../../utils/logger'; import { hashString } from '../auth/hash'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +const LAST_USED_AT_UPDATE_INTERVAL = 60_000; // 1 minute + type GetApiTokenByTokenOptions = { token: string; @@ -96,6 +99,29 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get }); } + // Only update the lastUsedAt after X amount of time has passed to reduce + // the number of writes to the database + if (!apiToken.lastUsedAt || apiToken.lastUsedAt.getTime() + LAST_USED_AT_UPDATE_INTERVAL < Date.now()) { + void prisma.apiToken + .updateMany({ + where: { + id: apiToken.id, + // Optimistic guard: skip if another request beat us + lastUsedAt: apiToken.lastUsedAt, + }, + data: { + lastUsedAt: new Date(), + }, + }) + .catch((err) => { + logger.warn({ + msg: 'Failed to update API token lastUsedAt', + apiTokenId: apiToken.id, + err, + }); + }); + } + return { ...apiToken, user, diff --git a/packages/lib/server-only/public-api/get-api-tokens.ts b/packages/lib/server-only/public-api/get-api-tokens.ts index 06e014a10..a97ba809d 100644 --- a/packages/lib/server-only/public-api/get-api-tokens.ts +++ b/packages/lib/server-only/public-api/get-api-tokens.ts @@ -22,6 +22,7 @@ export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => { name: true, createdAt: true, expires: true, + lastUsedAt: true, }, orderBy: { createdAt: 'desc', diff --git a/packages/prisma/migrations/20260818081941_add_api_token_last_used_property/migration.sql b/packages/prisma/migrations/20260818081941_add_api_token_last_used_property/migration.sql new file mode 100644 index 000000000..9b9f88927 --- /dev/null +++ b/packages/prisma/migrations/20260818081941_add_api_token_last_used_property/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ApiToken" ADD COLUMN "lastUsedAt" TIMESTAMP(3); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 06be2eed9..13768e34f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -220,16 +220,17 @@ enum ApiTokenAlgorithm { } model ApiToken { - id Int @id @default(autoincrement()) - name String - token String @unique - algorithm ApiTokenAlgorithm @default(SHA512) - expires DateTime? - createdAt DateTime @default(now()) - userId Int? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - teamId Int - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + name String + token String @unique + algorithm ApiTokenAlgorithm @default(SHA512) + expires DateTime? + createdAt DateTime @default(now()) + lastUsedAt DateTime? + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } enum SubscriptionStatus { diff --git a/packages/trpc/server/api-token-router/get-api-tokens.types.ts b/packages/trpc/server/api-token-router/get-api-tokens.types.ts index 6a690066d..c3f06d916 100644 --- a/packages/trpc/server/api-token-router/get-api-tokens.types.ts +++ b/packages/trpc/server/api-token-router/get-api-tokens.types.ts @@ -9,6 +9,7 @@ export const ZGetApiTokensResponseSchema = z.array( name: true, createdAt: true, expires: true, + lastUsedAt: true, }), );