Skip to content

Commit fdaa725

Browse files
authored
✨ feat: Implement API Key management functionality (#8535)
* ✨ feat: Implement API Key management functionality - Added new components for API Key management including creation, deletion, and display. - Introduced a new database schema for storing API Keys. - Implemented server and client services for API Key operations. - Integrated API Key management into the profile section with appropriate routing and feature flags. - Enhanced localization support for API Key related UI elements. This commit lays the groundwork for managing API Keys within the application, allowing users to create, view, and manage their keys effectively. * fix: server config unit test * ✨ feat(database): Create api_keys table with conditional existence check - Added a conditional check to create the "api_keys" table only if it does not already exist. - Ensured the foreign key constraint for "user_id" references the "users" table remains intact. This change enhances the migration process by preventing errors during table creation if the table already exists. * feat: Implement API Key management interface - Introduced a new Client component for managing API keys, including creation, updating, and deletion functionalities. - Replaced the previous page component with the new Client component in the API key management page. - Removed obsolete client and server service files related to API key management, streamlining the service layer. This update enhances the user experience by providing a dedicated interface for API key operations.
1 parent 7d85151 commit fdaa725

File tree

23 files changed

+7195
-2
lines changed

23 files changed

+7195
-2
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
'use client';
2+
3+
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
4+
import { Button } from '@lobehub/ui';
5+
import { useMutation } from '@tanstack/react-query';
6+
import { Popconfirm, Switch } from 'antd';
7+
import { createStyles } from 'antd-style';
8+
import { Trash } from 'lucide-react';
9+
import { FC, useRef, useState } from 'react';
10+
import { useTranslation } from 'react-i18next';
11+
12+
import { lambdaClient } from '@/libs/trpc/client';
13+
import { ApiKeyItem, CreateApiKeyParams, UpdateApiKeyParams } from '@/types/apiKey';
14+
15+
import { ApiKeyDisplay, ApiKeyModal, EditableCell } from './features';
16+
17+
const useStyles = createStyles(({ css, token }) => ({
18+
container: css`
19+
.ant-pro-card-body {
20+
padding-inline: 0;
21+
22+
.ant-pro-table-list-toolbar-container {
23+
padding-block-start: 0;
24+
}
25+
}
26+
`,
27+
header: css`
28+
display: flex;
29+
justify-content: flex-end;
30+
margin-block-end: ${token.margin}px;
31+
`,
32+
table: css`
33+
border-radius: ${token.borderRadius}px;
34+
background: ${token.colorBgContainer};
35+
`,
36+
}));
37+
38+
const Client: FC = () => {
39+
const { styles } = useStyles();
40+
const { t } = useTranslation('auth');
41+
const [modalOpen, setModalOpen] = useState(false);
42+
43+
const actionRef = useRef<ActionType>(null);
44+
45+
const createMutation = useMutation({
46+
mutationFn: (params: CreateApiKeyParams) => lambdaClient.apiKey.createApiKey.mutate(params),
47+
onSuccess: () => {
48+
actionRef.current?.reload();
49+
setModalOpen(false);
50+
},
51+
});
52+
53+
const updateMutation = useMutation({
54+
mutationFn: ({ id, params }: { id: number; params: UpdateApiKeyParams }) =>
55+
lambdaClient.apiKey.updateApiKey.mutate({ id, value: params }),
56+
onSuccess: () => {
57+
actionRef.current?.reload();
58+
},
59+
});
60+
61+
const deleteMutation = useMutation({
62+
mutationFn: (id: number) => lambdaClient.apiKey.deleteApiKey.mutate({ id }),
63+
onSuccess: () => {
64+
actionRef.current?.reload();
65+
},
66+
});
67+
68+
const handleCreate = () => {
69+
setModalOpen(true);
70+
};
71+
72+
const handleModalOk = (values: CreateApiKeyParams) => {
73+
createMutation.mutate(values);
74+
};
75+
76+
const columns: ProColumns<ApiKeyItem>[] = [
77+
{
78+
dataIndex: 'name',
79+
key: 'name',
80+
render: (_, apiKey) => (
81+
<EditableCell
82+
onSubmit={(name) => {
83+
if (!name || name === apiKey.name) {
84+
return;
85+
}
86+
87+
updateMutation.mutate({ id: apiKey.id!, params: { name: name as string } });
88+
}}
89+
placeholder={t('apikey.display.enterPlaceholder')}
90+
type="text"
91+
value={apiKey.name}
92+
/>
93+
),
94+
title: t('apikey.list.columns.name'),
95+
},
96+
{
97+
dataIndex: 'key',
98+
ellipsis: true,
99+
key: 'key',
100+
render: (_, apiKey) => <ApiKeyDisplay apiKey={apiKey.key} />,
101+
title: t('apikey.list.columns.key'),
102+
width: 230,
103+
},
104+
{
105+
dataIndex: 'enabled',
106+
key: 'enabled',
107+
render: (_, apiKey: ApiKeyItem) => (
108+
<Switch
109+
checked={!!apiKey.enabled}
110+
onChange={(checked) => {
111+
updateMutation.mutate({ id: apiKey.id!, params: { enabled: checked } });
112+
}}
113+
/>
114+
),
115+
title: t('apikey.list.columns.status'),
116+
width: 100,
117+
},
118+
{
119+
dataIndex: 'expiresAt',
120+
key: 'expiresAt',
121+
render: (_, apiKey) => (
122+
<EditableCell
123+
onSubmit={(expiresAt) => {
124+
if (expiresAt === apiKey.expiresAt) {
125+
return;
126+
}
127+
128+
updateMutation.mutate({
129+
id: apiKey.id!,
130+
params: { expiresAt: expiresAt ? new Date(expiresAt as string) : null },
131+
});
132+
}}
133+
placeholder={t('apikey.display.neverExpires')}
134+
type="date"
135+
value={apiKey.expiresAt?.toLocaleString() || t('apikey.display.neverExpires')}
136+
/>
137+
),
138+
title: t('apikey.list.columns.expiresAt'),
139+
width: 170,
140+
},
141+
{
142+
dataIndex: 'lastUsedAt',
143+
key: 'lastUsedAt',
144+
renderText: (_, apiKey: ApiKeyItem) =>
145+
apiKey.lastUsedAt?.toLocaleString() || t('apikey.display.neverUsed'),
146+
title: t('apikey.list.columns.lastUsedAt'),
147+
},
148+
{
149+
key: 'action',
150+
render: (_: any, apiKey: ApiKeyItem) => (
151+
<Popconfirm
152+
cancelText={t('apikey.list.actions.deleteConfirm.actions.cancel')}
153+
description={t('apikey.list.actions.deleteConfirm.content')}
154+
okText={t('apikey.list.actions.deleteConfirm.actions.ok')}
155+
onConfirm={() => deleteMutation.mutate(apiKey.id!)}
156+
title={t('apikey.list.actions.deleteConfirm.title')}
157+
>
158+
<Button
159+
icon={Trash}
160+
size="small"
161+
style={{ verticalAlign: 'middle' }}
162+
title={t('apikey.list.actions.delete')}
163+
type="text"
164+
/>
165+
</Popconfirm>
166+
),
167+
title: t('apikey.list.columns.actions'),
168+
width: 100,
169+
},
170+
];
171+
172+
return (
173+
<div className={styles.container}>
174+
<ProTable
175+
actionRef={actionRef}
176+
className={styles.table}
177+
columns={columns}
178+
headerTitle={t('apikey.list.title')}
179+
options={false}
180+
pagination={false}
181+
request={async () => {
182+
const apiKeys = await lambdaClient.apiKey.getApiKeys.query();
183+
184+
return {
185+
data: apiKeys,
186+
success: true,
187+
};
188+
}}
189+
rowKey="id"
190+
search={false}
191+
toolbar={{
192+
actions: [
193+
<Button key="create" onClick={handleCreate} type="primary">
194+
{t('apikey.list.actions.create')}
195+
</Button>,
196+
],
197+
}}
198+
/>
199+
<ApiKeyModal
200+
onCancel={() => setModalOpen(false)}
201+
onOk={handleModalOk}
202+
open={modalOpen}
203+
submitLoading={createMutation.isPending}
204+
/>
205+
</div>
206+
);
207+
};
208+
209+
export default Client;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { DatePicker } from '@lobehub/ui';
2+
import { DatePickerProps, Flex } from 'antd';
3+
import dayjs, { Dayjs } from 'dayjs';
4+
import { FC } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
interface ApiKeyDatePickerProps extends Omit<DatePickerProps, 'onChange'> {
8+
onChange?: (date: Dayjs | null) => void;
9+
}
10+
11+
const ApiKeyDatePicker: FC<ApiKeyDatePickerProps> = ({ value, onChange, ...props }) => {
12+
const { t } = useTranslation('auth');
13+
14+
const handleOnChange = (date: Dayjs | null) => {
15+
// 如果选择了日期,设置为当天的 23:59:59
16+
const submitData = date ? date.hour(23).minute(59).second(59).millisecond(999) : null;
17+
18+
onChange?.(submitData);
19+
};
20+
21+
return (
22+
<DatePicker
23+
key={value?.valueOf() || 'EMPTY'}
24+
value={value}
25+
{...props}
26+
minDate={dayjs()}
27+
onChange={handleOnChange}
28+
placeholder={t('apikey.form.fields.expiresAt.placeholder')}
29+
renderExtraFooter={() => (
30+
<Flex justify="center">
31+
<a onClick={() => handleOnChange(null)}>{t('apikey.display.neverExpires')}</a>
32+
</Flex>
33+
)}
34+
showNow={false}
35+
/>
36+
);
37+
};
38+
39+
export default ApiKeyDatePicker;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
2+
import { Button } from '@lobehub/ui';
3+
import { App, Flex } from 'antd';
4+
import { FC, useState } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
interface ApiKeyDisplayProps {
8+
apiKey?: string;
9+
}
10+
11+
const ApiKeyDisplay: FC<ApiKeyDisplayProps> = ({ apiKey }) => {
12+
const { t } = useTranslation('auth');
13+
const [isVisible, setIsVisible] = useState(false);
14+
const { message } = App.useApp();
15+
16+
const toggleVisibility = () => {
17+
setIsVisible(!isVisible);
18+
};
19+
20+
const handleCopy = async () => {
21+
if (!apiKey) return;
22+
23+
try {
24+
await navigator.clipboard.writeText(apiKey);
25+
message.success(t('apikey.display.copySuccess'));
26+
} catch {
27+
message.error(t('apikey.display.copyError'));
28+
}
29+
};
30+
31+
const displayValue = apiKey && (isVisible ? apiKey : `lb-${'*'.repeat(apiKey.length - 2)}`);
32+
33+
if (!apiKey) {
34+
return t('apikey.display.autoGenerated');
35+
}
36+
37+
return (
38+
<Flex align="center" gap={8}>
39+
<span style={{ fontSize: '14px' }}>{displayValue}</span>
40+
<Flex>
41+
<Button
42+
icon={isVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
43+
onClick={toggleVisibility}
44+
size="small"
45+
title={isVisible ? t('apikey.display.hide') : t('apikey.display.show')}
46+
type="text"
47+
/>
48+
<Button
49+
icon={<CopyOutlined />}
50+
onClick={handleCopy}
51+
size="small"
52+
title={t('apikey.display.copy')}
53+
type="text"
54+
/>
55+
</Flex>
56+
</Flex>
57+
);
58+
};
59+
60+
export default ApiKeyDisplay;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { FormModal, Input } from '@lobehub/ui';
2+
import { Dayjs } from 'dayjs';
3+
import { FC } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
6+
import { CreateApiKeyParams } from '@/types/apiKey';
7+
8+
import ApiKeyDatePicker from '../ApiKeyDatePicker';
9+
10+
interface ApiKeyModalProps {
11+
onCancel: () => void;
12+
onOk: (values: CreateApiKeyParams) => void;
13+
open: boolean;
14+
submitLoading?: boolean;
15+
}
16+
17+
type FormValues = Omit<CreateApiKeyParams, 'expiresAt'> & {
18+
expiresAt: Dayjs | null;
19+
};
20+
21+
const ApiKeyModal: FC<ApiKeyModalProps> = ({ open, onCancel, onOk, submitLoading }) => {
22+
const { t } = useTranslation('auth');
23+
24+
return (
25+
<FormModal
26+
destroyOnHidden
27+
height={'90%'}
28+
itemMinWidth={'max(30%,240px)'}
29+
items={[
30+
{
31+
children: <Input placeholder={t('apikey.form.fields.name.placeholder')} />,
32+
label: t('apikey.form.fields.name.label'),
33+
name: 'name',
34+
rules: [{ required: true }],
35+
},
36+
{
37+
children: <ApiKeyDatePicker style={{ width: '100%' }} />,
38+
label: t('apikey.form.fields.expiresAt.label'),
39+
name: 'expiresAt',
40+
},
41+
]}
42+
itemsType={'flat'}
43+
onCancel={onCancel}
44+
onFinish={(values: FormValues) => {
45+
onOk({
46+
...values,
47+
expiresAt: values.expiresAt ? values.expiresAt.toDate() : null,
48+
} satisfies CreateApiKeyParams);
49+
}}
50+
open={open}
51+
submitLoading={submitLoading}
52+
submitText={t('apikey.form.submit')}
53+
title={t('apikey.form.title')}
54+
/>
55+
);
56+
};
57+
58+
export default ApiKeyModal;

0 commit comments

Comments
 (0)