feat: add api key fe and usage counter

This commit is contained in:
moonrailgun 2024-11-03 19:39:59 +08:00
parent f7b1d33c5d
commit 6a4bdd324c
10 changed files with 262 additions and 3 deletions

View File

@ -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<CopyableTextProps> = React.memo((props) => {
const { t } = useTranslation();
const handleClick = useEvent(() => {
copy(props.text);
toast.success(t('Copied'));
});
return (
<span
className={cn(
'cursor-pointer select-none rounded bg-white bg-opacity-10 px-2',
'hover:bg-white hover:bg-opacity-20',
props.className
)}
onClick={handleClick}
>
{props.children ?? props.text}
</span>
);
});
CopyableText.displayName = 'CopyableText';

View File

@ -35,6 +35,7 @@ import { Route as SettingsUsageImport } from './routes/settings/usage'
import { Route as SettingsProfileImport } from './routes/settings/profile' import { Route as SettingsProfileImport } from './routes/settings/profile'
import { Route as SettingsNotificationsImport } from './routes/settings/notifications' import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog' 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 PageAddImport } from './routes/page/add'
import { Route as PageSlugImport } from './routes/page/$slug' import { Route as PageSlugImport } from './routes/page/$slug'
import { Route as MonitorAddImport } from './routes/monitor/add' import { Route as MonitorAddImport } from './routes/monitor/add'
@ -171,6 +172,11 @@ const SettingsAuditLogRoute = SettingsAuditLogImport.update({
getParentRoute: () => SettingsRoute, getParentRoute: () => SettingsRoute,
} as any) } as any)
const SettingsApiKeyRoute = SettingsApiKeyImport.update({
path: '/apiKey',
getParentRoute: () => SettingsRoute,
} as any)
const PageAddRoute = PageAddImport.update({ const PageAddRoute = PageAddImport.update({
path: '/add', path: '/add',
getParentRoute: () => PageRoute, getParentRoute: () => PageRoute,
@ -312,6 +318,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PageAddImport preLoaderRoute: typeof PageAddImport
parentRoute: typeof PageImport parentRoute: typeof PageImport
} }
'/settings/apiKey': {
preLoaderRoute: typeof SettingsApiKeyImport
parentRoute: typeof SettingsImport
}
'/settings/auditLog': { '/settings/auditLog': {
preLoaderRoute: typeof SettingsAuditLogImport preLoaderRoute: typeof SettingsAuditLogImport
parentRoute: typeof SettingsImport parentRoute: typeof SettingsImport
@ -411,6 +421,7 @@ export const routeTree = rootRoute.addChildren([
RegisterRoute, RegisterRoute,
ServerRoute, ServerRoute,
SettingsRoute.addChildren([ SettingsRoute.addChildren([
SettingsApiKeyRoute,
SettingsAuditLogRoute, SettingsAuditLogRoute,
SettingsNotificationsRoute, SettingsNotificationsRoute,
SettingsProfileRoute, SettingsProfileRoute,

View File

@ -39,6 +39,11 @@ function PageComponent() {
title: t('Workspace'), title: t('Workspace'),
href: '/settings/workspace', href: '/settings/workspace',
}, },
{
id: 'apiKey',
title: t('Api Key'),
href: '/settings/apiKey',
},
{ {
id: 'auditLog', id: 'auditLog',
title: t('Audit Log'), title: t('Audit Log'),

View File

@ -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<ApiKeyInfo>();
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 (
<CopyableText text={props.getValue()}>
{props.getValue().slice(0, 20)}...
</CopyableText>
);
},
}),
columnHelper.accessor('usage', {
header: t('Usage'),
size: 80,
cell: (props) => {
return (
<div className="text-right">
{formatNumber(Number(props.getValue()))}
</div>
);
},
}),
columnHelper.accessor('createdAt', {
header: t('Created At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.accessor('updatedAt', {
header: t('Last Use At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.accessor('expiredAt', {
header: t('Expired At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.display({
id: 'action',
header: t('Action'),
size: 130,
cell: (props) => {
return (
<div>
<AlertConfirm
onConfirm={async () => {
await deleteApiKeyMutation.mutateAsync({
apiKey: props.row.original.apiKey,
});
refetchApiKeys();
}}
>
<Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
</div>
);
},
}),
];
}, [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 (
<CommonWrapper header={<CommonHeader title={t('Api Keys')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="text-lg font-bold">
<div className="flex items-center justify-between gap-2">
<div>{t('Api Keys')}</div>
<Button
Icon={LuPlus}
size="icon"
variant="outline"
onClick={handleGenerateApiKey}
/>
</div>
</CardHeader>
<CardContent>
<DataTable columns={columns} data={apiKeys} />
</CardContent>
</Card>
</div>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -381,5 +381,16 @@ export async function verifyUserApiKey(apiKey: string) {
throw new Error('Api Key not found'); throw new Error('Api Key not found');
} }
prisma.userApiKey.update({
where: {
apiKey,
},
data: {
usage: {
increment: 1,
},
},
});
return result.user; return result.user;
} }

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserApiKey" ADD COLUMN "usage" INTEGER NOT NULL DEFAULT 0;

View File

@ -40,6 +40,7 @@ model User {
model UserApiKey { model UserApiKey {
apiKey String @id @unique @db.VarChar(128) apiKey String @id @unique @db.VarChar(128)
userId String userId String
usage Int @default(0)
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)
expiredAt DateTime? expiredAt DateTime?

View File

@ -5,6 +5,7 @@ import { CompleteUser, RelatedUserModelSchema } from "./index.js"
export const UserApiKeyModelSchema = z.object({ export const UserApiKeyModelSchema = z.object({
apiKey: z.string(), apiKey: z.string(),
userId: z.string(), userId: z.string(),
usage: z.number().int(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
expiredAt: z.date().nullish(), expiredAt: z.date().nullish(),

View File

@ -6,6 +6,7 @@ import {
changeUserPassword, changeUserPassword,
createAdminUser, createAdminUser,
createUser, createUser,
generateUserApiKey,
getUserCount, getUserCount,
getUserInfo, getUserInfo,
} from '../../model/user.js'; } from '../../model/user.js';
@ -14,6 +15,8 @@ import { TRPCError } from '@trpc/server';
import { env } from '../../utils/env.js'; import { env } from '../../utils/env.js';
import { userInfoSchema } from '../../model/_schema/index.js'; import { userInfoSchema } from '../../model/_schema/index.js';
import { OPENAPI_TAG } from '../../utils/const.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({ export const userRouter = router({
login: publicProcedure login: publicProcedure
@ -141,4 +144,38 @@ export const userRouter = router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return getUserInfo(ctx.user.id); 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,
},
});
}),
}); });

View File

@ -64,10 +64,12 @@ const isUser = middleware(async (opts) => {
return opts.next({ return opts.next({
ctx: { ctx: {
user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
}, },
},
}); });
} else { } else {
// auth with jwt // auth with jwt