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 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,
|
||||||
|
@ -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'),
|
||||||
|
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');
|
throw new Error('Api Key not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prisma.userApiKey.update({
|
||||||
|
where: {
|
||||||
|
apiKey,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usage: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return result.user;
|
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 {
|
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?
|
||||||
|
@ -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(),
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -64,9 +64,11 @@ const isUser = middleware(async (opts) => {
|
|||||||
|
|
||||||
return opts.next({
|
return opts.next({
|
||||||
ctx: {
|
ctx: {
|
||||||
id: user.id,
|
user: {
|
||||||
username: user.username,
|
id: user.id,
|
||||||
role: user.role,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
Loading…
Reference in New Issue
Block a user