feat: add api key fe and usage counter
This commit is contained in:
parent
f7b1d33c5d
commit
6a4bdd324c
32
src/client/components/CopyableText.tsx
Normal file
32
src/client/components/CopyableText.tsx
Normal 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';
|
@ -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,
|
||||
|
@ -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'),
|
||||
|
157
src/client/routes/settings/apiKey.tsx
Normal file
157
src/client/routes/settings/apiKey.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserApiKey" ADD COLUMN "usage" INTEGER NOT NULL DEFAULT 0;
|
@ -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?
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user