diff --git a/src/client/components/CopyableText.tsx b/src/client/components/CopyableText.tsx new file mode 100644 index 0000000..f187b64 --- /dev/null +++ b/src/client/components/CopyableText.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/utils/style'; +import React, { PropsWithChildren } from 'react'; +import copy from 'copy-to-clipboard'; +import { useEvent } from '@/hooks/useEvent'; +import { toast } from 'sonner'; +import { useTranslation } from '@i18next-toolkit/react'; + +interface CopyableTextProps extends PropsWithChildren { + className?: string; + text: string; +} +export const CopyableText: React.FC = React.memo((props) => { + const { t } = useTranslation(); + const handleClick = useEvent(() => { + copy(props.text); + toast.success(t('Copied')); + }); + + return ( + + {props.children ?? props.text} + + ); +}); +CopyableText.displayName = 'CopyableText'; diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 5ddfaf4..3057d8e 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -35,6 +35,7 @@ import { Route as SettingsUsageImport } from './routes/settings/usage' import { Route as SettingsProfileImport } from './routes/settings/profile' import { Route as SettingsNotificationsImport } from './routes/settings/notifications' import { Route as SettingsAuditLogImport } from './routes/settings/auditLog' +import { Route as SettingsApiKeyImport } from './routes/settings/apiKey' import { Route as PageAddImport } from './routes/page/add' import { Route as PageSlugImport } from './routes/page/$slug' import { Route as MonitorAddImport } from './routes/monitor/add' @@ -171,6 +172,11 @@ const SettingsAuditLogRoute = SettingsAuditLogImport.update({ getParentRoute: () => SettingsRoute, } as any) +const SettingsApiKeyRoute = SettingsApiKeyImport.update({ + path: '/apiKey', + getParentRoute: () => SettingsRoute, +} as any) + const PageAddRoute = PageAddImport.update({ path: '/add', getParentRoute: () => PageRoute, @@ -312,6 +318,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PageAddImport parentRoute: typeof PageImport } + '/settings/apiKey': { + preLoaderRoute: typeof SettingsApiKeyImport + parentRoute: typeof SettingsImport + } '/settings/auditLog': { preLoaderRoute: typeof SettingsAuditLogImport parentRoute: typeof SettingsImport @@ -411,6 +421,7 @@ export const routeTree = rootRoute.addChildren([ RegisterRoute, ServerRoute, SettingsRoute.addChildren([ + SettingsApiKeyRoute, SettingsAuditLogRoute, SettingsNotificationsRoute, SettingsProfileRoute, diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx index bb065b2..db7a767 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -39,6 +39,11 @@ function PageComponent() { title: t('Workspace'), href: '/settings/workspace', }, + { + id: 'apiKey', + title: t('Api Key'), + href: '/settings/apiKey', + }, { id: 'auditLog', title: t('Audit Log'), diff --git a/src/client/routes/settings/apiKey.tsx b/src/client/routes/settings/apiKey.tsx new file mode 100644 index 0000000..e8d9fff --- /dev/null +++ b/src/client/routes/settings/apiKey.tsx @@ -0,0 +1,157 @@ +import { routeAuthBeforeLoad } from '@/utils/route'; +import { createFileRoute } from '@tanstack/react-router'; +import { useTranslation } from '@i18next-toolkit/react'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CommonHeader } from '@/components/CommonHeader'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { AppRouterOutput, defaultErrorHandler, trpc } from '@/api/trpc'; +import { createColumnHelper, DataTable } from '@/components/DataTable'; +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { useEvent } from '@/hooks/useEvent'; +import dayjs from 'dayjs'; +import { LuPlus, LuTrash } from 'react-icons/lu'; +import copy from 'copy-to-clipboard'; +import { toast } from 'sonner'; +import { CopyableText } from '@/components/CopyableText'; +import { AlertConfirm } from '@/components/AlertConfirm'; +import { formatNumber } from '@/utils/common'; + +export const Route = createFileRoute('/settings/apiKey')({ + beforeLoad: routeAuthBeforeLoad, + component: PageComponent, +}); + +type ApiKeyInfo = AppRouterOutput['user']['allApiKeys'][number]; +const columnHelper = createColumnHelper(); + +function PageComponent() { + const { t } = useTranslation(); + const { data: apiKeys = [], refetch: refetchApiKeys } = + trpc.user.allApiKeys.useQuery(); + const generateApiKeyMutation = trpc.user.generateApiKey.useMutation({ + onError: defaultErrorHandler, + }); + const deleteApiKeyMutation = trpc.user.deleteApiKey.useMutation({ + onError: defaultErrorHandler, + }); + + const columns = useMemo(() => { + return [ + columnHelper.accessor('apiKey', { + header: t('Key'), + size: 300, + cell: (props) => { + return ( + + {props.getValue().slice(0, 20)}... + + ); + }, + }), + columnHelper.accessor('usage', { + header: t('Usage'), + size: 80, + cell: (props) => { + return ( +
+ {formatNumber(Number(props.getValue()))} +
+ ); + }, + }), + columnHelper.accessor('createdAt', { + header: t('Created At'), + size: 130, + cell: (props) => { + const date = props.getValue(); + return ( + + {date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'} + + ); + }, + }), + columnHelper.accessor('updatedAt', { + header: t('Last Use At'), + size: 130, + cell: (props) => { + const date = props.getValue(); + return ( + + {date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'} + + ); + }, + }), + columnHelper.accessor('expiredAt', { + header: t('Expired At'), + size: 130, + cell: (props) => { + const date = props.getValue(); + return ( + + {date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'} + + ); + }, + }), + columnHelper.display({ + id: 'action', + header: t('Action'), + size: 130, + cell: (props) => { + return ( +
+ { + await deleteApiKeyMutation.mutateAsync({ + apiKey: props.row.original.apiKey, + }); + refetchApiKeys(); + }} + > +
+ ); + }, + }), + ]; + }, [t]); + + const handleGenerateApiKey = useEvent(async () => { + const apiKey = await generateApiKeyMutation.mutateAsync(); + + copy(apiKey); + toast.success(t('New api key has been copied into your clipboard!')); + refetchApiKeys(); + }); + + return ( + }> + +
+ + +
+
{t('Api Keys')}
+ +
+
+ + + +
+
+
+
+ ); +} diff --git a/src/server/model/user.ts b/src/server/model/user.ts index 587fea4..9d14e1b 100644 --- a/src/server/model/user.ts +++ b/src/server/model/user.ts @@ -381,5 +381,16 @@ export async function verifyUserApiKey(apiKey: string) { throw new Error('Api Key not found'); } + prisma.userApiKey.update({ + where: { + apiKey, + }, + data: { + usage: { + increment: 1, + }, + }, + }); + return result.user; } diff --git a/src/server/prisma/migrations/20241103103959_add_api_key_usage/migration.sql b/src/server/prisma/migrations/20241103103959_add_api_key_usage/migration.sql new file mode 100644 index 0000000..3b15a13 --- /dev/null +++ b/src/server/prisma/migrations/20241103103959_add_api_key_usage/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserApiKey" ADD COLUMN "usage" INTEGER NOT NULL DEFAULT 0; diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index a144742..0905d22 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -40,6 +40,7 @@ model User { model UserApiKey { apiKey String @id @unique @db.VarChar(128) userId String + usage Int @default(0) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) expiredAt DateTime? diff --git a/src/server/prisma/zod/userapikey.ts b/src/server/prisma/zod/userapikey.ts index fa84cc3..619fb62 100644 --- a/src/server/prisma/zod/userapikey.ts +++ b/src/server/prisma/zod/userapikey.ts @@ -5,6 +5,7 @@ import { CompleteUser, RelatedUserModelSchema } from "./index.js" export const UserApiKeyModelSchema = z.object({ apiKey: z.string(), userId: z.string(), + usage: z.number().int(), createdAt: z.date(), updatedAt: z.date(), expiredAt: z.date().nullish(), diff --git a/src/server/trpc/routers/user.ts b/src/server/trpc/routers/user.ts index 4c7489c..b7d8f33 100644 --- a/src/server/trpc/routers/user.ts +++ b/src/server/trpc/routers/user.ts @@ -6,6 +6,7 @@ import { changeUserPassword, createAdminUser, createUser, + generateUserApiKey, getUserCount, getUserInfo, } from '../../model/user.js'; @@ -14,6 +15,8 @@ import { TRPCError } from '@trpc/server'; import { env } from '../../utils/env.js'; import { userInfoSchema } from '../../model/_schema/index.js'; import { OPENAPI_TAG } from '../../utils/const.js'; +import { prisma } from '../../model/_client.js'; +import { UserApiKeyModelSchema } from '../../prisma/zod/userapikey.js'; export const userRouter = router({ login: publicProcedure @@ -141,4 +144,38 @@ export const userRouter = router({ .query(async ({ ctx }) => { return getUserInfo(ctx.user.id); }), + allApiKeys: protectProedure + .input(z.void()) + .output(UserApiKeyModelSchema.array()) + .query(async ({ ctx }) => { + return prisma.userApiKey.findMany({ + where: { + userId: ctx.user.id, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }), + generateApiKey: protectProedure + .input(z.void()) + .output(z.string()) + .mutation(async ({ ctx }) => { + return generateUserApiKey(ctx.user.id); + }), + deleteApiKey: protectProedure + .input( + z.object({ + apiKey: z.string(), + }) + ) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + await prisma.userApiKey.delete({ + where: { + userId: ctx.user.id, + apiKey: input.apiKey, + }, + }); + }), }); diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts index 9159b7c..e92fc96 100644 --- a/src/server/trpc/trpc.ts +++ b/src/server/trpc/trpc.ts @@ -64,9 +64,11 @@ const isUser = middleware(async (opts) => { return opts.next({ ctx: { - id: user.id, - username: user.username, - role: user.role, + user: { + id: user.id, + username: user.username, + role: user.role, + }, }, }); } else {