Skip to content

Commit 05001f0

Browse files
committed
add api token
1 parent 2ea3600 commit 05001f0

7 files changed

Lines changed: 332 additions & 4 deletions

File tree

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"@rsbuild/core": "^1.7.2",
1515
"@rsbuild/plugin-react": "^1.4.3",
1616
"@rsbuild/plugin-svgr": "^1.2.4",
17-
"@tanstack/react-query": "^5.90.17",
17+
"@tanstack/react-query": "^5.90.18",
1818
"antd": "^6.2.0",
1919
"dayjs": "^1.11.19",
2020
"git-url-parse": "^16.1.0",

src/components/sider.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AppstoreOutlined,
33
FileTextOutlined,
4+
KeyOutlined,
45
LineChartOutlined,
56
PlusOutlined,
67
SettingOutlined,
@@ -236,6 +237,11 @@ const SiderMenu = ({ selectedKeys, onNavigate }: SiderMenuProps) => {
236237
icon: <UserOutlined />,
237238
label: <Link to={rootRouterPath.user}>账户设置</Link>,
238239
},
240+
{
241+
key: 'api-tokens',
242+
icon: <KeyOutlined />,
243+
label: <Link to={rootRouterPath.apiTokens}>API Token</Link>,
244+
},
239245
{
240246
key: 'audit-logs',
241247
icon: <FileTextOutlined />,

src/pages/api-tokens.tsx

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { KeyOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
2+
import {
3+
Button,
4+
Card,
5+
Checkbox,
6+
Form,
7+
Input,
8+
message,
9+
Modal,
10+
Popconfirm,
11+
Select,
12+
Space,
13+
Table,
14+
Tag,
15+
Typography,
16+
} from 'antd';
17+
import type { ColumnsType } from 'antd/es/table';
18+
import dayjs from 'dayjs';
19+
import { useState } from 'react';
20+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
21+
import { api } from '@/services/api';
22+
23+
const { Paragraph } = Typography;
24+
25+
function ApiTokensPage() {
26+
const queryClient = useQueryClient();
27+
const [createModalVisible, setCreateModalVisible] = useState(false);
28+
const [newToken, setNewToken] = useState<string | null>(null);
29+
const [form] = Form.useForm();
30+
31+
const { data, isLoading } = useQuery({
32+
queryKey: ['apiTokens'],
33+
queryFn: api.listApiTokens,
34+
});
35+
36+
const createMutation = useMutation({
37+
mutationFn: api.createApiToken,
38+
onSuccess: (result) => {
39+
if (result?.token) {
40+
setNewToken(result.token);
41+
setCreateModalVisible(false);
42+
message.success('API Token 创建成功');
43+
queryClient.invalidateQueries({ queryKey: ['apiTokens'] });
44+
form.resetFields();
45+
}
46+
},
47+
onError: (error: Error) => {
48+
message.error(error.message || '创建失败');
49+
},
50+
});
51+
52+
const revokeMutation = useMutation({
53+
mutationFn: api.revokeApiToken,
54+
onSuccess: () => {
55+
message.success('Token 已撤销');
56+
queryClient.invalidateQueries({ queryKey: ['apiTokens'] });
57+
},
58+
onError: (error: Error) => {
59+
message.error(error.message || '撤销失败');
60+
},
61+
});
62+
63+
const handleCreate = async (values: {
64+
name: string;
65+
permissions: string[];
66+
expiresIn?: number;
67+
}) => {
68+
const permissions = {
69+
read: values.permissions?.includes('read'),
70+
write: values.permissions?.includes('write'),
71+
delete: values.permissions?.includes('delete'),
72+
};
73+
const expiresAt = values.expiresIn
74+
? dayjs().add(values.expiresIn, 'day').toISOString()
75+
: undefined;
76+
await createMutation.mutateAsync({
77+
name: values.name,
78+
permissions,
79+
expiresAt,
80+
});
81+
};
82+
83+
const columns: ColumnsType<ApiToken> = [
84+
{
85+
title: '名称',
86+
dataIndex: 'name',
87+
key: 'name',
88+
render: (name: string, record: ApiToken) => (
89+
<Space>
90+
<KeyOutlined />
91+
{name}
92+
{record.isRevoked && <Tag color="red">已撤销</Tag>}
93+
{record.isExpired && !record.isRevoked && (
94+
<Tag color="orange">已过期</Tag>
95+
)}
96+
</Space>
97+
),
98+
},
99+
{
100+
title: 'Token',
101+
dataIndex: 'tokenSuffix',
102+
key: 'tokenSuffix',
103+
render: (tokenSuffix: string) => (
104+
<span className="font-mono text-gray-500">****{tokenSuffix}</span>
105+
),
106+
},
107+
{
108+
title: '权限',
109+
dataIndex: 'permissions',
110+
key: 'permissions',
111+
render: (permissions: ApiToken['permissions']) => (
112+
<Space>
113+
{permissions?.read && <Tag color="blue">读取</Tag>}
114+
{permissions?.write && <Tag color="green">写入</Tag>}
115+
{permissions?.delete && <Tag color="red">删除</Tag>}
116+
</Space>
117+
),
118+
},
119+
{
120+
title: '过期时间',
121+
dataIndex: 'expiresAt',
122+
key: 'expiresAt',
123+
render: (expiresAt: string | null) =>
124+
expiresAt ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') : '永不过期',
125+
},
126+
{
127+
title: '最后使用',
128+
dataIndex: 'lastUsedAt',
129+
key: 'lastUsedAt',
130+
render: (lastUsedAt: string | null) =>
131+
lastUsedAt ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') : '从未使用',
132+
},
133+
{
134+
title: '创建时间',
135+
dataIndex: 'createdAt',
136+
key: 'createdAt',
137+
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm'),
138+
},
139+
{
140+
title: '操作',
141+
key: 'action',
142+
render: (_: unknown, record: ApiToken) => (
143+
<Popconfirm
144+
title="确认撤销"
145+
description="撤销后此 Token 将无法再使用,确定要撤销吗?"
146+
onConfirm={() => revokeMutation.mutate(record.id)}
147+
okText="确定"
148+
cancelText="取消"
149+
disabled={record.isRevoked}
150+
>
151+
<Button
152+
type="text"
153+
danger
154+
icon={<DeleteOutlined />}
155+
disabled={record.isRevoked}
156+
>
157+
撤销
158+
</Button>
159+
</Popconfirm>
160+
),
161+
},
162+
];
163+
164+
return (
165+
<div className="body">
166+
<Card
167+
title="API Token 管理"
168+
extra={
169+
<Button
170+
type="primary"
171+
icon={<KeyOutlined />}
172+
onClick={() => setCreateModalVisible(true)}
173+
>
174+
创建 Token
175+
</Button>
176+
}
177+
>
178+
<Paragraph type="secondary" className="mb-4">
179+
API Token 可用于 CI/CD 流程或自动化脚本中调用 Pushy API。每个用户最多可创建
180+
10 个 Token。
181+
</Paragraph>
182+
<Table
183+
columns={columns}
184+
dataSource={data?.data}
185+
loading={isLoading}
186+
rowKey="id"
187+
pagination={false}
188+
/>
189+
</Card>
190+
191+
<Modal
192+
title="创建 API Token"
193+
open={createModalVisible}
194+
onCancel={() => {
195+
setCreateModalVisible(false);
196+
form.resetFields();
197+
}}
198+
footer={null}
199+
destroyOnClose
200+
>
201+
<Form form={form} layout="vertical" onFinish={handleCreate}>
202+
<Form.Item
203+
label="Token 名称"
204+
name="name"
205+
rules={[{ required: true, message: '请输入 Token 名称' }]}
206+
>
207+
<Input placeholder="例如:CI/CD Pipeline" maxLength={100} />
208+
</Form.Item>
209+
<Form.Item
210+
label="权限"
211+
name="permissions"
212+
rules={[{ required: true, message: '请至少选择一个权限' }]}
213+
>
214+
<Checkbox.Group>
215+
<Space direction="vertical">
216+
<Checkbox value="read">
217+
<b>读取 (read)</b> - 查看应用、版本、原生包信息
218+
</Checkbox>
219+
<Checkbox value="write">
220+
<b>写入 (write)</b> - 创建和更新应用、发布版本、上传原生包
221+
</Checkbox>
222+
<Checkbox value="delete">
223+
<b>删除 (delete)</b> - 删除应用、版本、原生包
224+
</Checkbox>
225+
</Space>
226+
</Checkbox.Group>
227+
</Form.Item>
228+
<Form.Item label="过期时间" name="expiresIn" initialValue={180}>
229+
<Select
230+
options={[
231+
{ value: 0, label: '永不过期' },
232+
{ value: 30, label: '30 天' },
233+
{ value: 90, label: '90 天' },
234+
{ value: 180, label: '180 天' },
235+
{ value: 360, label: '360 天' },
236+
]}
237+
/>
238+
</Form.Item>
239+
<Form.Item className="mb-0">
240+
<Button
241+
type="primary"
242+
htmlType="submit"
243+
loading={createMutation.isPending}
244+
block
245+
>
246+
创建
247+
</Button>
248+
</Form.Item>
249+
</Form>
250+
</Modal>
251+
252+
<Modal
253+
title="Token 创建成功"
254+
open={!!newToken}
255+
onOk={() => setNewToken(null)}
256+
onCancel={() => setNewToken(null)}
257+
cancelButtonProps={{ style: { display: 'none' } }}
258+
okText="我已保存"
259+
>
260+
<div className="my-4">
261+
<Paragraph type="warning" className="mb-2">
262+
⚠️ 请立即复制并安全保存此 Token,关闭后将无法再次查看!
263+
</Paragraph>
264+
<Input.TextArea
265+
value={newToken || ''}
266+
readOnly
267+
autoSize={{ minRows: 2 }}
268+
className="font-mono"
269+
/>
270+
<Button
271+
icon={<CopyOutlined />}
272+
className="mt-2"
273+
onClick={() => {
274+
if (newToken) {
275+
navigator.clipboard.writeText(newToken);
276+
message.success('已复制到剪贴板');
277+
}
278+
}}
279+
>
280+
复制 Token
281+
</Button>
282+
</div>
283+
</Modal>
284+
</div>
285+
);
286+
}
287+
288+
export const Component = ApiTokensPage;

src/router.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const rootRouterPath = {
2121
adminUsers: '/admin-users',
2222
adminApps: '/admin-apps',
2323
adminMetrics: '/admin-metrics',
24+
apiTokens: '/api-tokens',
2425
};
2526

2627
export const needAuthLoader = ({ request }: { request: Request }) => {
@@ -118,6 +119,11 @@ export const router = createHashRouter([
118119
loader: needAuthLoader,
119120
lazy: () => import('./pages/admin-metrics'),
120121
},
122+
{
123+
path: 'api-tokens',
124+
loader: needAuthLoader,
125+
lazy: () => import('./pages/api-tokens'),
126+
},
121127
],
122128
},
123129
]);

src/services/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,13 @@ export const api = {
209209
'get',
210210
`/metrics/app?appKey=${encodeURIComponent(params.appKey)}&start=${encodeURIComponent(params.start)}&end=${encodeURIComponent(params.end)}`,
211211
),
212+
// API Token
213+
createApiToken: (params: {
214+
name: string;
215+
permissions: { read?: boolean; write?: boolean; delete?: boolean };
216+
expiresAt?: string;
217+
}) => request<ApiToken>('post', '/api-token/create', params),
218+
listApiTokens: () => request<{ data: ApiToken[] }>('get', '/api-token/list'),
219+
revokeApiToken: (tokenId: number) =>
220+
request<{ message: string }>('delete', `/api-token/${tokenId}`),
212221
};

src/types.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,22 @@ interface AuditLog {
171171
};
172172
createdAt: string;
173173
}
174+
175+
interface ApiToken {
176+
id: number;
177+
name: string;
178+
token?: string; // Only available when creating
179+
tokenSuffix: string;
180+
permissions: {
181+
read?: boolean;
182+
write?: boolean;
183+
delete?: boolean;
184+
};
185+
expiresAt: string | null;
186+
revokedAt: string | null;
187+
lastUsedAt: string | null;
188+
createdAt: string;
189+
updatedAt: string;
190+
isExpired: boolean;
191+
isRevoked: boolean;
192+
}

0 commit comments

Comments
 (0)