Skip to content

Commit 6a4bdd3

Browse files
committed
feat: add api key fe and usage counter
1 parent f7b1d33 commit 6a4bdd3

File tree

10 files changed

+262
-3
lines changed

10 files changed

+262
-3
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { cn } from '@/utils/style';
2+
import React, { PropsWithChildren } from 'react';
3+
import copy from 'copy-to-clipboard';
4+
import { useEvent } from '@/hooks/useEvent';
5+
import { toast } from 'sonner';
6+
import { useTranslation } from '@i18next-toolkit/react';
7+
8+
interface CopyableTextProps extends PropsWithChildren {
9+
className?: string;
10+
text: string;
11+
}
12+
export const CopyableText: React.FC<CopyableTextProps> = React.memo((props) => {
13+
const { t } = useTranslation();
14+
const handleClick = useEvent(() => {
15+
copy(props.text);
16+
toast.success(t('Copied'));
17+
});
18+
19+
return (
20+
<span
21+
className={cn(
22+
'cursor-pointer select-none rounded bg-white bg-opacity-10 px-2',
23+
'hover:bg-white hover:bg-opacity-20',
24+
props.className
25+
)}
26+
onClick={handleClick}
27+
>
28+
{props.children ?? props.text}
29+
</span>
30+
);
31+
});
32+
CopyableText.displayName = 'CopyableText';

src/client/routeTree.gen.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { Route as SettingsUsageImport } from './routes/settings/usage'
3535
import { Route as SettingsProfileImport } from './routes/settings/profile'
3636
import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
3737
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
38+
import { Route as SettingsApiKeyImport } from './routes/settings/apiKey'
3839
import { Route as PageAddImport } from './routes/page/add'
3940
import { Route as PageSlugImport } from './routes/page/$slug'
4041
import { Route as MonitorAddImport } from './routes/monitor/add'
@@ -171,6 +172,11 @@ const SettingsAuditLogRoute = SettingsAuditLogImport.update({
171172
getParentRoute: () => SettingsRoute,
172173
} as any)
173174

175+
const SettingsApiKeyRoute = SettingsApiKeyImport.update({
176+
path: '/apiKey',
177+
getParentRoute: () => SettingsRoute,
178+
} as any)
179+
174180
const PageAddRoute = PageAddImport.update({
175181
path: '/add',
176182
getParentRoute: () => PageRoute,
@@ -312,6 +318,10 @@ declare module '@tanstack/react-router' {
312318
preLoaderRoute: typeof PageAddImport
313319
parentRoute: typeof PageImport
314320
}
321+
'/settings/apiKey': {
322+
preLoaderRoute: typeof SettingsApiKeyImport
323+
parentRoute: typeof SettingsImport
324+
}
315325
'/settings/auditLog': {
316326
preLoaderRoute: typeof SettingsAuditLogImport
317327
parentRoute: typeof SettingsImport
@@ -411,6 +421,7 @@ export const routeTree = rootRoute.addChildren([
411421
RegisterRoute,
412422
ServerRoute,
413423
SettingsRoute.addChildren([
424+
SettingsApiKeyRoute,
414425
SettingsAuditLogRoute,
415426
SettingsNotificationsRoute,
416427
SettingsProfileRoute,

src/client/routes/settings.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ function PageComponent() {
3939
title: t('Workspace'),
4040
href: '/settings/workspace',
4141
},
42+
{
43+
id: 'apiKey',
44+
title: t('Api Key'),
45+
href: '/settings/apiKey',
46+
},
4247
{
4348
id: 'auditLog',
4449
title: t('Audit Log'),
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { routeAuthBeforeLoad } from '@/utils/route';
2+
import { createFileRoute } from '@tanstack/react-router';
3+
import { useTranslation } from '@i18next-toolkit/react';
4+
import { CommonWrapper } from '@/components/CommonWrapper';
5+
import { ScrollArea } from '@/components/ui/scroll-area';
6+
import { CommonHeader } from '@/components/CommonHeader';
7+
import { Card, CardContent, CardHeader } from '@/components/ui/card';
8+
import { AppRouterOutput, defaultErrorHandler, trpc } from '@/api/trpc';
9+
import { createColumnHelper, DataTable } from '@/components/DataTable';
10+
import { useMemo } from 'react';
11+
import { Button } from '@/components/ui/button';
12+
import { useEvent } from '@/hooks/useEvent';
13+
import dayjs from 'dayjs';
14+
import { LuPlus, LuTrash } from 'react-icons/lu';
15+
import copy from 'copy-to-clipboard';
16+
import { toast } from 'sonner';
17+
import { CopyableText } from '@/components/CopyableText';
18+
import { AlertConfirm } from '@/components/AlertConfirm';
19+
import { formatNumber } from '@/utils/common';
20+
21+
export const Route = createFileRoute('/settings/apiKey')({
22+
beforeLoad: routeAuthBeforeLoad,
23+
component: PageComponent,
24+
});
25+
26+
type ApiKeyInfo = AppRouterOutput['user']['allApiKeys'][number];
27+
const columnHelper = createColumnHelper<ApiKeyInfo>();
28+
29+
function PageComponent() {
30+
const { t } = useTranslation();
31+
const { data: apiKeys = [], refetch: refetchApiKeys } =
32+
trpc.user.allApiKeys.useQuery();
33+
const generateApiKeyMutation = trpc.user.generateApiKey.useMutation({
34+
onError: defaultErrorHandler,
35+
});
36+
const deleteApiKeyMutation = trpc.user.deleteApiKey.useMutation({
37+
onError: defaultErrorHandler,
38+
});
39+
40+
const columns = useMemo(() => {
41+
return [
42+
columnHelper.accessor('apiKey', {
43+
header: t('Key'),
44+
size: 300,
45+
cell: (props) => {
46+
return (
47+
<CopyableText text={props.getValue()}>
48+
{props.getValue().slice(0, 20)}...
49+
</CopyableText>
50+
);
51+
},
52+
}),
53+
columnHelper.accessor('usage', {
54+
header: t('Usage'),
55+
size: 80,
56+
cell: (props) => {
57+
return (
58+
<div className="text-right">
59+
{formatNumber(Number(props.getValue()))}
60+
</div>
61+
);
62+
},
63+
}),
64+
columnHelper.accessor('createdAt', {
65+
header: t('Created At'),
66+
size: 130,
67+
cell: (props) => {
68+
const date = props.getValue();
69+
return (
70+
<span>
71+
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
72+
</span>
73+
);
74+
},
75+
}),
76+
columnHelper.accessor('updatedAt', {
77+
header: t('Last Use At'),
78+
size: 130,
79+
cell: (props) => {
80+
const date = props.getValue();
81+
return (
82+
<span>
83+
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
84+
</span>
85+
);
86+
},
87+
}),
88+
columnHelper.accessor('expiredAt', {
89+
header: t('Expired At'),
90+
size: 130,
91+
cell: (props) => {
92+
const date = props.getValue();
93+
return (
94+
<span>
95+
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
96+
</span>
97+
);
98+
},
99+
}),
100+
columnHelper.display({
101+
id: 'action',
102+
header: t('Action'),
103+
size: 130,
104+
cell: (props) => {
105+
return (
106+
<div>
107+
<AlertConfirm
108+
onConfirm={async () => {
109+
await deleteApiKeyMutation.mutateAsync({
110+
apiKey: props.row.original.apiKey,
111+
});
112+
refetchApiKeys();
113+
}}
114+
>
115+
<Button variant="outline" size="icon" Icon={LuTrash} />
116+
</AlertConfirm>
117+
</div>
118+
);
119+
},
120+
}),
121+
];
122+
}, [t]);
123+
124+
const handleGenerateApiKey = useEvent(async () => {
125+
const apiKey = await generateApiKeyMutation.mutateAsync();
126+
127+
copy(apiKey);
128+
toast.success(t('New api key has been copied into your clipboard!'));
129+
refetchApiKeys();
130+
});
131+
132+
return (
133+
<CommonWrapper header={<CommonHeader title={t('Api Keys')} />}>
134+
<ScrollArea className="h-full overflow-hidden p-4">
135+
<div className="flex flex-col gap-4">
136+
<Card>
137+
<CardHeader className="text-lg font-bold">
138+
<div className="flex items-center justify-between gap-2">
139+
<div>{t('Api Keys')}</div>
140+
141+
<Button
142+
Icon={LuPlus}
143+
size="icon"
144+
variant="outline"
145+
onClick={handleGenerateApiKey}
146+
/>
147+
</div>
148+
</CardHeader>
149+
<CardContent>
150+
<DataTable columns={columns} data={apiKeys} />
151+
</CardContent>
152+
</Card>
153+
</div>
154+
</ScrollArea>
155+
</CommonWrapper>
156+
);
157+
}

src/server/model/user.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,5 +381,16 @@ export async function verifyUserApiKey(apiKey: string) {
381381
throw new Error('Api Key not found');
382382
}
383383

384+
prisma.userApiKey.update({
385+
where: {
386+
apiKey,
387+
},
388+
data: {
389+
usage: {
390+
increment: 1,
391+
},
392+
},
393+
});
394+
384395
return result.user;
385396
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "UserApiKey" ADD COLUMN "usage" INTEGER NOT NULL DEFAULT 0;

src/server/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ model User {
4040
model UserApiKey {
4141
apiKey String @id @unique @db.VarChar(128)
4242
userId String
43+
usage Int @default(0)
4344
createdAt DateTime @default(now()) @db.Timestamptz(6)
4445
updatedAt DateTime @updatedAt @db.Timestamptz(6)
4546
expiredAt DateTime?

src/server/prisma/zod/userapikey.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CompleteUser, RelatedUserModelSchema } from "./index.js"
55
export const UserApiKeyModelSchema = z.object({
66
apiKey: z.string(),
77
userId: z.string(),
8+
usage: z.number().int(),
89
createdAt: z.date(),
910
updatedAt: z.date(),
1011
expiredAt: z.date().nullish(),

src/server/trpc/routers/user.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
changeUserPassword,
77
createAdminUser,
88
createUser,
9+
generateUserApiKey,
910
getUserCount,
1011
getUserInfo,
1112
} from '../../model/user.js';
@@ -14,6 +15,8 @@ import { TRPCError } from '@trpc/server';
1415
import { env } from '../../utils/env.js';
1516
import { userInfoSchema } from '../../model/_schema/index.js';
1617
import { OPENAPI_TAG } from '../../utils/const.js';
18+
import { prisma } from '../../model/_client.js';
19+
import { UserApiKeyModelSchema } from '../../prisma/zod/userapikey.js';
1720

1821
export const userRouter = router({
1922
login: publicProcedure
@@ -141,4 +144,38 @@ export const userRouter = router({
141144
.query(async ({ ctx }) => {
142145
return getUserInfo(ctx.user.id);
143146
}),
147+
allApiKeys: protectProedure
148+
.input(z.void())
149+
.output(UserApiKeyModelSchema.array())
150+
.query(async ({ ctx }) => {
151+
return prisma.userApiKey.findMany({
152+
where: {
153+
userId: ctx.user.id,
154+
},
155+
orderBy: {
156+
createdAt: 'desc',
157+
},
158+
});
159+
}),
160+
generateApiKey: protectProedure
161+
.input(z.void())
162+
.output(z.string())
163+
.mutation(async ({ ctx }) => {
164+
return generateUserApiKey(ctx.user.id);
165+
}),
166+
deleteApiKey: protectProedure
167+
.input(
168+
z.object({
169+
apiKey: z.string(),
170+
})
171+
)
172+
.output(z.void())
173+
.mutation(async ({ input, ctx }) => {
174+
await prisma.userApiKey.delete({
175+
where: {
176+
userId: ctx.user.id,
177+
apiKey: input.apiKey,
178+
},
179+
});
180+
}),
144181
});

src/server/trpc/trpc.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ const isUser = middleware(async (opts) => {
6464

6565
return opts.next({
6666
ctx: {
67-
id: user.id,
68-
username: user.username,
69-
role: user.role,
67+
user: {
68+
id: user.id,
69+
username: user.username,
70+
role: user.role,
71+
},
7072
},
7173
});
7274
} else {

0 commit comments

Comments
 (0)