@ploykit/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -3
- package/dist/components/ConfirmDialog.d.ts +13 -0
- package/dist/components/ConfirmDialog.js +29 -0
- package/dist/components/CronInput.d.ts +15 -0
- package/dist/components/CronInput.js +112 -0
- package/dist/components/DataTable.d.ts +22 -0
- package/dist/components/DataTable.js +13 -0
- package/dist/components/ErrorBoundary.d.ts +16 -0
- package/dist/components/ErrorBoundary.js +24 -0
- package/dist/components/FormField.d.ts +10 -0
- package/dist/components/FormField.js +22 -0
- package/dist/components/ImpersonationBanner.d.ts +3 -0
- package/dist/components/ImpersonationBanner.js +10 -0
- package/dist/components/NotificationBell.d.ts +3 -0
- package/dist/components/NotificationBell.js +47 -0
- package/dist/components/OnboardingChecklist.d.ts +12 -0
- package/dist/components/OnboardingChecklist.js +43 -0
- package/dist/components/Page.d.ts +17 -0
- package/dist/components/Page.js +16 -0
- package/dist/components/SecretModal.d.ts +16 -0
- package/dist/components/SecretModal.js +45 -0
- package/dist/components/SettingsCard.d.ts +10 -0
- package/dist/components/SettingsCard.js +6 -0
- package/dist/components/SiteBanner.d.ts +6 -0
- package/dist/components/SiteBanner.js +11 -0
- package/dist/components/SiteBannerLayer.d.ts +4 -0
- package/dist/components/SiteBannerLayer.js +9 -0
- package/dist/components/UserMenu.d.ts +13 -0
- package/dist/components/UserMenu.js +30 -0
- package/dist/components/WorkspaceSwitcher.d.ts +1 -0
- package/dist/components/WorkspaceSwitcher.js +53 -0
- package/dist/components/toast.d.ts +7 -0
- package/dist/components/toast.js +11 -0
- package/dist/components/ui/Badge.d.ts +13 -0
- package/dist/components/ui/Badge.js +21 -0
- package/dist/components/ui/Button.d.ts +10 -0
- package/dist/components/ui/Button.js +33 -0
- package/dist/components/ui/Input.d.ts +3 -0
- package/dist/components/ui/Input.js +10 -0
- package/dist/components/ui/alert-dialog.d.ts +18 -0
- package/dist/components/ui/alert-dialog.js +41 -0
- package/dist/components/ui/avatar.d.ts +11 -0
- package/dist/components/ui/avatar.js +22 -0
- package/dist/components/ui/breadcrumb.d.ts +10 -0
- package/dist/components/ui/breadcrumb.js +36 -0
- package/dist/components/ui/card.d.ts +13 -0
- package/dist/components/ui/card.js +24 -0
- package/dist/components/ui/checkbox.d.ts +3 -0
- package/dist/components/ui/checkbox.js +8 -0
- package/dist/components/ui/dialog.d.ts +15 -0
- package/dist/components/ui/dialog.js +26 -0
- package/dist/components/ui/empty.d.ts +13 -0
- package/dist/components/ui/empty.js +36 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +6 -0
- package/dist/components/ui/menu.d.ts +29 -0
- package/dist/components/ui/menu.js +50 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +22 -0
- package/dist/components/ui/progress.d.ts +7 -0
- package/dist/components/ui/progress.js +19 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +33 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +7 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +36 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +265 -0
- package/dist/components/ui/skeleton.d.ts +3 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/switch.d.ts +5 -0
- package/dist/components/ui/switch.js +7 -0
- package/dist/components/ui/tabs.d.ts +10 -0
- package/dist/components/ui/tabs.js +28 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/hooks/useApi.d.ts +10 -0
- package/dist/hooks/useApi.js +11 -0
- package/dist/hooks/useAudit.d.ts +39 -0
- package/dist/hooks/useAudit.js +55 -0
- package/dist/hooks/useAuth.d.ts +1 -0
- package/dist/hooks/useAuth.js +4 -0
- package/dist/hooks/useBilling.d.ts +44 -0
- package/dist/hooks/useBilling.js +64 -0
- package/dist/hooks/useLogin.d.ts +10 -0
- package/dist/hooks/useLogin.js +39 -0
- package/dist/hooks/useMembers.d.ts +125 -0
- package/dist/hooks/useMembers.js +127 -0
- package/dist/hooks/useNotificationPrefs.d.ts +20 -0
- package/dist/hooks/useNotificationPrefs.js +42 -0
- package/dist/hooks/usePasswordReset.d.ts +5 -0
- package/dist/hooks/usePasswordReset.js +12 -0
- package/dist/hooks/useSEO.d.ts +6 -0
- package/dist/hooks/useSEO.js +14 -0
- package/dist/hooks/useSessions.d.ts +20 -0
- package/dist/hooks/useSessions.js +38 -0
- package/dist/hooks/useSiteConfig.d.ts +12 -0
- package/dist/hooks/useSiteConfig.js +11 -0
- package/dist/hooks/useTokens.d.ts +36 -0
- package/dist/hooks/useTokens.js +35 -0
- package/dist/hooks/useUsage.d.ts +20 -0
- package/dist/hooks/useUsage.js +21 -0
- package/dist/hooks/useWorkspace.d.ts +26 -0
- package/dist/hooks/useWorkspace.js +32 -0
- package/dist/hooks/useZodForm.d.ts +6 -0
- package/dist/hooks/useZodForm.js +6 -0
- package/dist/hooks/ws.d.ts +4 -0
- package/dist/hooks/ws.js +21 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +94 -0
- package/dist/layouts/AppShell.d.ts +20 -0
- package/dist/layouts/AppShell.js +21 -0
- package/dist/layouts/AuthShell.d.ts +7 -0
- package/dist/layouts/AuthShell.js +4 -0
- package/dist/layouts/SettingsShell.d.ts +19 -0
- package/dist/layouts/SettingsShell.js +21 -0
- package/dist/lib/api-error.d.ts +1 -0
- package/dist/lib/api-error.js +74 -0
- package/dist/lib/perm.d.ts +2 -0
- package/dist/lib/perm.js +7 -0
- package/dist/lib/utils.d.ts +12 -0
- package/dist/lib/utils.js +84 -0
- package/dist/lib/ws-query-bridge.d.ts +7 -0
- package/dist/lib/ws-query-bridge.js +35 -0
- package/dist/pages/LandingPage.d.ts +22 -0
- package/dist/pages/LandingPage.js +5 -0
- package/dist/pages/LoginPage.d.ts +15 -0
- package/dist/pages/LoginPage.js +87 -0
- package/dist/pages/account/NotificationPreferencesPage.d.ts +13 -0
- package/dist/pages/account/NotificationPreferencesPage.js +41 -0
- package/dist/pages/account/ProfileSettingsPage.d.ts +4 -0
- package/dist/pages/account/ProfileSettingsPage.js +96 -0
- package/dist/pages/account/SecuritySettingsPage.d.ts +1 -0
- package/dist/pages/account/SecuritySettingsPage.js +134 -0
- package/dist/pages/account/TokensPage.d.ts +1 -0
- package/dist/pages/account/TokensPage.js +117 -0
- package/dist/pages/admin/AdminSettingsPage.d.ts +1 -0
- package/dist/pages/admin/AdminSettingsPage.js +210 -0
- package/dist/pages/admin/AdminShell.d.ts +7 -0
- package/dist/pages/admin/AdminShell.js +79 -0
- package/dist/pages/admin/AuditPage.d.ts +1 -0
- package/dist/pages/admin/AuditPage.js +164 -0
- package/dist/pages/admin/OrdersPage.d.ts +1 -0
- package/dist/pages/admin/OrdersPage.js +236 -0
- package/dist/pages/admin/OverviewPage.d.ts +1 -0
- package/dist/pages/admin/OverviewPage.js +200 -0
- package/dist/pages/admin/SSOPage.d.ts +1 -0
- package/dist/pages/admin/SSOPage.js +53 -0
- package/dist/pages/admin/UserDetailPage.d.ts +1 -0
- package/dist/pages/admin/UserDetailPage.js +266 -0
- package/dist/pages/admin/UsersPage.d.ts +4 -0
- package/dist/pages/admin/UsersPage.js +201 -0
- package/dist/pages/admin/WebhookDeliveriesPage.d.ts +1 -0
- package/dist/pages/admin/WebhookDeliveriesPage.js +128 -0
- package/dist/pages/admin/WorkspaceDetailPage.d.ts +1 -0
- package/dist/pages/admin/WorkspaceDetailPage.js +202 -0
- package/dist/pages/admin/WorkspacesPage.d.ts +1 -0
- package/dist/pages/admin/WorkspacesPage.js +133 -0
- package/dist/pages/admin/shared.d.ts +8 -0
- package/dist/pages/admin/shared.js +23 -0
- package/dist/pages/auth/ForgotPasswordPage.d.ts +6 -0
- package/dist/pages/auth/ForgotPasswordPage.js +32 -0
- package/dist/pages/auth/InviteAcceptPage.d.ts +5 -0
- package/dist/pages/auth/InviteAcceptPage.js +40 -0
- package/dist/pages/auth/RegisterPage.d.ts +15 -0
- package/dist/pages/auth/RegisterPage.js +80 -0
- package/dist/pages/auth/ResetPasswordPage.d.ts +4 -0
- package/dist/pages/auth/ResetPasswordPage.js +71 -0
- package/dist/pages/auth/VerifyEmailPage.d.ts +1 -0
- package/dist/pages/auth/VerifyEmailPage.js +45 -0
- package/dist/pages/workspace/BillingPage.d.ts +4 -0
- package/dist/pages/workspace/BillingPage.js +183 -0
- package/dist/pages/workspace/BillingSuccessPage.d.ts +4 -0
- package/dist/pages/workspace/BillingSuccessPage.js +72 -0
- package/dist/pages/workspace/MembersPage.d.ts +3 -0
- package/dist/pages/workspace/MembersPage.js +262 -0
- package/dist/pages/workspace/SchedulesPage.d.ts +4 -0
- package/dist/pages/workspace/SchedulesPage.js +177 -0
- package/dist/pages/workspace/UsagePage.d.ts +15 -0
- package/dist/pages/workspace/UsagePage.js +74 -0
- package/dist/pages/workspace/WebhooksPage.d.ts +3 -0
- package/dist/pages/workspace/WebhooksPage.js +242 -0
- package/dist/pages/workspace/WorkspaceAuditPage.d.ts +3 -0
- package/dist/pages/workspace/WorkspaceAuditPage.js +109 -0
- package/dist/pages/workspace/WorkspaceGeneralPage.d.ts +4 -0
- package/dist/pages/workspace/WorkspaceGeneralPage.js +101 -0
- package/dist/provider/PloykitProvider.d.ts +50 -0
- package/dist/provider/PloykitProvider.js +240 -0
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +11 -0
- package/package.json +63 -6
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Link, useNavigate, useParams } from 'react-router';
|
|
4
|
+
import { ArrowLeft, Download } from 'lucide-react';
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { apiFetch } from '@ploykit/client';
|
|
7
|
+
import { useConfirm } from '../../components/ConfirmDialog';
|
|
8
|
+
import { DataTable } from '../../components/DataTable';
|
|
9
|
+
import { PageEmpty, PageError, PageHeader } from '../../components/Page';
|
|
10
|
+
import { ImpactConfirmation } from '../../components/SecretModal';
|
|
11
|
+
import { SettingsCard } from '../../components/SettingsCard';
|
|
12
|
+
import { toast } from '../../components/toast';
|
|
13
|
+
import { Badge, StatusBadge } from '../../components/ui/Badge';
|
|
14
|
+
import { Button } from '../../components/ui/Button';
|
|
15
|
+
import { Input } from '../../components/ui/Input';
|
|
16
|
+
import { useApi, ApiError } from '../../hooks/useApi';
|
|
17
|
+
import { useAuth } from '../../hooks/useAuth';
|
|
18
|
+
import { apiErrorMessage } from '../../lib/api-error';
|
|
19
|
+
import { formatDate, formatDateTime, shortID } from '../../lib/utils';
|
|
20
|
+
import { adminError } from './shared';
|
|
21
|
+
function resendErrorMessage(err) {
|
|
22
|
+
if (err instanceof ApiError) {
|
|
23
|
+
if (err.status === 409)
|
|
24
|
+
return '该用户邮箱已验证,无需重发';
|
|
25
|
+
if (err.status === 429)
|
|
26
|
+
return '发送冷却中,请约 1 分钟后再试';
|
|
27
|
+
}
|
|
28
|
+
return apiErrorMessage(err, '验证邮件发送失败');
|
|
29
|
+
}
|
|
30
|
+
export function UserDetailPage() {
|
|
31
|
+
const { id = '' } = useParams();
|
|
32
|
+
const api = useApi();
|
|
33
|
+
const qc = useQueryClient();
|
|
34
|
+
const confirm = useConfirm();
|
|
35
|
+
const navigate = useNavigate();
|
|
36
|
+
const me = useAuth().user;
|
|
37
|
+
const [confirmEmail, setConfirmEmail] = useState('');
|
|
38
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
39
|
+
const [kickOpen, setKickOpen] = useState(false);
|
|
40
|
+
const [exporting, setExporting] = useState(false);
|
|
41
|
+
const detailQ = useQuery({
|
|
42
|
+
queryKey: ['admin', 'user', id],
|
|
43
|
+
queryFn: () => api.get(`/api/admin/users/${id}`),
|
|
44
|
+
enabled: !!id,
|
|
45
|
+
});
|
|
46
|
+
const invalidate = () => {
|
|
47
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'user', id] });
|
|
48
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
|
49
|
+
};
|
|
50
|
+
const kickM = useMutation({
|
|
51
|
+
mutationFn: () => api.post(`/api/admin/users/${id}/kick`),
|
|
52
|
+
onSuccess: () => {
|
|
53
|
+
toast.success('已吊销该用户全部在途会话');
|
|
54
|
+
invalidate();
|
|
55
|
+
},
|
|
56
|
+
onError: (e) => toast.error(apiErrorMessage(e, '踢下线失败')),
|
|
57
|
+
});
|
|
58
|
+
const revokeSessionM = useMutation({
|
|
59
|
+
mutationFn: (sid) => api.post(`/api/admin/users/${id}/sessions/${sid}/revoke`),
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
toast.success('会话已吊销');
|
|
62
|
+
invalidate();
|
|
63
|
+
},
|
|
64
|
+
onError: (e) => toast.error(apiErrorMessage(e, '会话吊销失败')),
|
|
65
|
+
});
|
|
66
|
+
const resetPwdM = useMutation({
|
|
67
|
+
mutationFn: () => api.post(`/api/admin/users/${id}/password-reset`),
|
|
68
|
+
onSuccess: () => toast.success('重置邮件已发送'),
|
|
69
|
+
onError: (e) => toast.error(apiErrorMessage(e, '重置邮件发送失败')),
|
|
70
|
+
});
|
|
71
|
+
const resendM = useMutation({
|
|
72
|
+
mutationFn: () => api.post(`/api/admin/users/${id}/resend-verification`),
|
|
73
|
+
onSuccess: () => toast.success('验证邮件已发送'),
|
|
74
|
+
onError: (e) => toast.error(resendErrorMessage(e)),
|
|
75
|
+
});
|
|
76
|
+
const markVerifiedM = useMutation({
|
|
77
|
+
mutationFn: () => api.post(`/api/admin/users/${id}/mark-verified`),
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
toast.success('已标记邮箱已验证');
|
|
80
|
+
invalidate();
|
|
81
|
+
},
|
|
82
|
+
onError: (e) => toast.error(apiErrorMessage(e, '标记失败')),
|
|
83
|
+
});
|
|
84
|
+
const revokePatM = useMutation({
|
|
85
|
+
mutationFn: (patID) => api.post(`/api/admin/users/${id}/pats/${patID}/revoke`),
|
|
86
|
+
onSuccess: () => {
|
|
87
|
+
toast.success('令牌已吊销');
|
|
88
|
+
invalidate();
|
|
89
|
+
},
|
|
90
|
+
onError: (e) => toast.error(apiErrorMessage(e, '令牌吊销失败')),
|
|
91
|
+
});
|
|
92
|
+
const deleteM = useMutation({
|
|
93
|
+
mutationFn: (email) => api.delete(`/api/admin/users/${id}?confirm=${encodeURIComponent(email)}`),
|
|
94
|
+
onSuccess: () => {
|
|
95
|
+
toast.success('账号已删除(软删)');
|
|
96
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
|
97
|
+
navigate('/admin/users');
|
|
98
|
+
},
|
|
99
|
+
onError: (e) => toast.error(apiErrorMessage(e, '删除失败')),
|
|
100
|
+
});
|
|
101
|
+
const onExportData = async () => {
|
|
102
|
+
setExporting(true);
|
|
103
|
+
try {
|
|
104
|
+
const blob = await apiFetch(`/api/admin/users/${id}/export`, {
|
|
105
|
+
responseType: 'blob',
|
|
106
|
+
});
|
|
107
|
+
const url = URL.createObjectURL(blob);
|
|
108
|
+
const a = document.createElement('a');
|
|
109
|
+
a.href = url;
|
|
110
|
+
a.download = `userdata-${id}.json`;
|
|
111
|
+
a.click();
|
|
112
|
+
URL.revokeObjectURL(url);
|
|
113
|
+
toast.success('用户数据导出已开始下载');
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
toast.error(apiErrorMessage(e, '导出失败'));
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
setExporting(false);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const onRevokeSession = async (s) => {
|
|
123
|
+
const ok = await confirm({
|
|
124
|
+
title: '吊销会话',
|
|
125
|
+
description: `将吊销该设备会话(${s.device || shortID(s.id)}),该设备需要重新登录。`,
|
|
126
|
+
confirmText: '吊销',
|
|
127
|
+
danger: true,
|
|
128
|
+
});
|
|
129
|
+
if (ok)
|
|
130
|
+
revokeSessionM.mutate(s.id);
|
|
131
|
+
};
|
|
132
|
+
const onRevokePat = async (p) => {
|
|
133
|
+
const ok = await confirm({
|
|
134
|
+
title: '吊销访问令牌',
|
|
135
|
+
description: `将吊销「${p.name}」,使用该令牌的集成立即失效。`,
|
|
136
|
+
confirmText: '吊销',
|
|
137
|
+
danger: true,
|
|
138
|
+
});
|
|
139
|
+
if (ok)
|
|
140
|
+
revokePatM.mutate(p.id);
|
|
141
|
+
};
|
|
142
|
+
if (detailQ.isError) {
|
|
143
|
+
return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u7528\u6237\u8BE6\u60C5" }), _jsx(PageError, { message: adminError(detailQ.error, '用户详情加载失败'), onRetry: () => void detailQ.refetch() })] }));
|
|
144
|
+
}
|
|
145
|
+
const d = detailQ.data;
|
|
146
|
+
const u = d?.user;
|
|
147
|
+
const isSelf = !!u && me?.id === u.id;
|
|
148
|
+
const deleteBlockedReason = isSelf
|
|
149
|
+
? '不能删除当前登录的管理员账号自己'
|
|
150
|
+
: u?.is_platform_admin
|
|
151
|
+
? '不能删除另一位平台管理员(admin-on-admin 禁止)'
|
|
152
|
+
: '';
|
|
153
|
+
const emailReadyToDelete = !!u && confirmEmail.trim().toLowerCase() === u.email.toLowerCase();
|
|
154
|
+
const sessionColumns = [
|
|
155
|
+
{
|
|
156
|
+
key: 'device',
|
|
157
|
+
header: '设备',
|
|
158
|
+
render: (s) => (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate", children: s.device || '未知设备' }), _jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", title: s.ip_hash, children: s.ip_hash ? shortID(s.ip_hash) : '—' })] })),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
key: 'last_seen_at',
|
|
162
|
+
header: '最近活跃',
|
|
163
|
+
className: 'w-44',
|
|
164
|
+
render: (s) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(s.last_seen_at) })),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: 'expires_at',
|
|
168
|
+
header: '过期时间',
|
|
169
|
+
className: 'w-44',
|
|
170
|
+
render: (s) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(s.expires_at) })),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
key: 'actions',
|
|
174
|
+
header: '操作',
|
|
175
|
+
className: 'w-20',
|
|
176
|
+
render: (s) => (_jsx(Button, { variant: "outline", size: "sm", disabled: revokeSessionM.isPending, onClick: () => void onRevokeSession(s), children: "\u540A\u9500" })),
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
const patColumns = [
|
|
180
|
+
{
|
|
181
|
+
key: 'name',
|
|
182
|
+
header: '令牌',
|
|
183
|
+
render: (p) => (_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "truncate font-medium", children: [p.name, p.revoked_at && (_jsx(Badge, { variant: "outline", className: "ml-2 text-muted-foreground", children: "\u5DF2\u540A\u9500" }))] }), _jsxs("p", { className: "truncate font-mono text-xs text-muted-foreground", children: [p.prefix, "\u2026"] })] })),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
key: 'created_at',
|
|
187
|
+
header: '创建时间',
|
|
188
|
+
className: 'w-28',
|
|
189
|
+
render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(p.created_at) })),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'last_used_at',
|
|
193
|
+
header: '最近使用',
|
|
194
|
+
className: 'w-44',
|
|
195
|
+
render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: p.last_used_at ? formatDateTime(p.last_used_at) : '从未使用' })),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: 'expires_at',
|
|
199
|
+
header: '过期时间',
|
|
200
|
+
className: 'w-28',
|
|
201
|
+
render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: p.expires_at ? formatDate(p.expires_at) : '永不过期' })),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: 'actions',
|
|
205
|
+
header: '操作',
|
|
206
|
+
className: 'w-20',
|
|
207
|
+
render: (p) => p.revoked_at ? (_jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" })) : (_jsx(Button, { variant: "outline", size: "sm", disabled: revokePatM.isPending, onClick: () => void onRevokePat(p), children: "\u540A\u9500" })),
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const wsColumns = [
|
|
211
|
+
{
|
|
212
|
+
key: 'name',
|
|
213
|
+
header: '工作区',
|
|
214
|
+
render: (w) => (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium", children: w.name }), _jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", children: w.slug })] })),
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
key: 'role',
|
|
218
|
+
header: '角色',
|
|
219
|
+
className: 'w-24',
|
|
220
|
+
render: (w) => _jsx(StatusBadge, { status: w.role }),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: 'created_at',
|
|
224
|
+
header: '加入时间',
|
|
225
|
+
className: 'w-28',
|
|
226
|
+
render: (w) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(w.created_at) })),
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsx(PageHeader, { title: u ? u.email : '用户详情', description: u
|
|
230
|
+
? `${u.display_name || '未设置昵称'} · 用户 ID ${shortID(u.id)}`
|
|
231
|
+
: '用户资料与会话 / 令牌 / 工作区聚合视图', actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", size: "sm", disabled: exporting || !u, onClick: () => void onExportData(), title: "GDPR \u7528\u6237\u6570\u636E\u5BFC\u51FA\uFF08\u8D44\u6599 / \u6210\u5458\u5173\u7CFB / \u5728\u9014\u4F1A\u8BDD\u5143\u6570\u636E / PAT \u5143\u6570\u636E / \u6700\u8FD1\u5BA1\u8BA1\u4E8B\u4EF6\uFF09", children: [_jsx(Download, {}), " ", exporting ? '导出中…' : '导出数据'] }), _jsxs(Link, { to: "/admin/users", className: "text-sm text-muted-foreground transition-colors hover:text-foreground", children: [_jsx(ArrowLeft, { size: 15, className: "mr-1 inline", "aria-hidden": "true" }), "\u8FD4\u56DE\u7528\u6237\u5217\u8868"] })] }) }), detailQ.isPending && (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u52A0\u8F7D\u7528\u6237\u8BE6\u60C5\u4E2D\u2026" })), u && (_jsxs(_Fragment, { children: [_jsxs(SettingsCard, { title: "\u8D26\u53F7\u8D44\u6599\u4E0E\u5B89\u5168\u64CD\u4F5C", description: "\u72B6\u6001\u3001\u9A8C\u8BC1\u4E0E\u767B\u5F55\u4F1A\u8BDD\u7684\u5168\u5C40\u64CD\u4F5C\uFF08\u5168\u90E8\u8BB0\u5165\u5E73\u53F0\u5BA1\u8BA1\uFF09", cta: _jsx(Button, { variant: "destructive", onClick: () => setKickOpen(true), disabled: kickM.isPending, children: kickM.isPending ? '踢下线中…' : '踢下线' }), children: [_jsxs("div", { className: "grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3", children: [_jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u72B6\u6001\uFF1A" }), _jsx(StatusBadge, { status: u.status })] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u90AE\u7BB1\u9A8C\u8BC1\uFF1A" }), u.email_verified ? (_jsx(Badge, { variant: "outline", children: "\u5DF2\u9A8C\u8BC1" })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u9A8C\u8BC1" }))] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5E73\u53F0\u7BA1\u7406\u5458\uFF1A" }), u.is_platform_admin ? (_jsx(Badge, { variant: "secondary", children: "\u662F" })) : (_jsx("span", { children: "\u5426" }))] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6CE8\u518C\u65F6\u95F4\uFF1A" }), formatDateTime(u.created_at)] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6700\u8FD1\u767B\u5F55\uFF1A" }), u.last_login_at ? formatDateTime(u.last_login_at) : '从未登录'] })] }), _jsxs("div", { className: "mt-4 flex flex-wrap gap-2 border-t pt-4", children: [_jsx(Button, { variant: "outline", size: "sm", disabled: resetPwdM.isPending, onClick: async () => {
|
|
232
|
+
const ok = await confirm({
|
|
233
|
+
title: '发送密码重置邮件',
|
|
234
|
+
description: `将向 ${u.email} 发送密码重置邮件(走用户自助重置链路,不直接改密码)。`,
|
|
235
|
+
confirmText: '发送邮件',
|
|
236
|
+
});
|
|
237
|
+
if (ok)
|
|
238
|
+
resetPwdM.mutate();
|
|
239
|
+
}, children: resetPwdM.isPending ? '发送中…' : '重置密码' }), _jsx(Button, { variant: "outline", size: "sm", disabled: resendM.isPending || u.email_verified, onClick: async () => {
|
|
240
|
+
const ok = await confirm({
|
|
241
|
+
title: '重发验证邮件',
|
|
242
|
+
description: `将向 ${u.email} 补发邮箱验证邮件(1 分钟冷却)。`,
|
|
243
|
+
confirmText: '发送邮件',
|
|
244
|
+
});
|
|
245
|
+
if (ok)
|
|
246
|
+
resendM.mutate();
|
|
247
|
+
}, children: resendM.isPending ? '发送中…' : '重发验证' }), _jsx(Button, { variant: "outline", size: "sm", disabled: markVerifiedM.isPending || u.email_verified, onClick: async () => {
|
|
248
|
+
const ok = await confirm({
|
|
249
|
+
title: '标记邮箱已验证',
|
|
250
|
+
description: '邮件链路不可达时的兜底:直接把该邮箱标记为已验证(幂等)。',
|
|
251
|
+
confirmText: '标记已验证',
|
|
252
|
+
});
|
|
253
|
+
if (ok)
|
|
254
|
+
markVerifiedM.mutate();
|
|
255
|
+
}, children: markVerifiedM.isPending ? '标记中…' : '标记已验证' })] })] }), _jsx(SettingsCard, { title: `在途会话(${d?.sessions.length ?? 0})`, description: "\u8BE5\u7528\u6237\u5F53\u524D\u6709\u6548\u7684\u767B\u5F55\u4F1A\u8BDD\uFF1B\u53EF\u5355\u8BBE\u5907\u540A\u9500", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: sessionColumns, rows: d?.sessions ?? [], rowKey: (s) => s.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u6CA1\u6709\u5728\u9014\u4F1A\u8BDD", hint: "\u8BE5\u7528\u6237\u5F53\u524D\u6CA1\u6709\u4EFB\u4F55\u6D3B\u8DC3\u767B\u5F55\uFF08\u6216\u5DF2\u88AB\u8E22\u4E0B\u7EBF\uFF09" }) }) }) }), _jsx(SettingsCard, { title: `访问令牌 PAT(${d?.pats.length ?? 0})`, description: "\u76EE\u6807\u7528\u6237\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C\u7684\u53EA\u8BFB\u5143\u6570\u636E\uFF08\u4E0D\u542B\u51ED\u636E\u6750\u6599\uFF09", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: patColumns, rows: d?.pats ?? [], rowKey: (p) => p.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u6682\u65E0\u8BBF\u95EE\u4EE4\u724C" }) }) }) }), _jsx(SettingsCard, { title: `所属工作区(${d?.workspaces.length ?? 0})`, description: "\u8BE5\u7528\u6237\u4F5C\u4E3A\u6210\u5458\u52A0\u5165\u7684\u5DE5\u4F5C\u533A\u4E0E\u89D2\u8272", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: wsColumns, rows: d?.workspaces ?? [], rowKey: (w) => w.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u4E0D\u5C5E\u4E8E\u4EFB\u4F55\u5DE5\u4F5C\u533A", hint: "\u5DE5\u4F5C\u533A\u5217\u8868\u5668\u672A\u63A5\u7EBF\u65F6\u4E5F\u663E\u793A\u4E3A\u7A7A" }) }) }) }), _jsx(SettingsCard, { danger: true, title: "\u5220\u9664\u8D26\u53F7", description: "\u8F6F\u5220\uFF1Astatus=deleted\u3001\u90AE\u7BB1\u6539\u5199\u3001PAT \u5168\u540A\u9500\uFF0C\u4E0D\u53EF\u6062\u590D", cta: _jsx(Button, { variant: "destructive", disabled: !!deleteBlockedReason ||
|
|
256
|
+
!emailReadyToDelete ||
|
|
257
|
+
deleteM.isPending, onClick: () => setDeleteOpen(true), children: deleteM.isPending ? '删除中…' : '删除账号' }), children: _jsxs("div", { className: "max-w-xl space-y-2", children: [deleteBlockedReason ? (_jsxs("p", { className: "text-sm text-muted-foreground", children: [deleteBlockedReason, "\uFF0C\u65E0\u6CD5\u5728\u6B64\u5220\u9664\u3002"] })) : (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u8F93\u5165\u76EE\u6807\u90AE\u7BB1 ", u.email, " \u4EE5\u786E\u8BA4\u5220\u9664\u3002"] })), _jsx(Input, { placeholder: u.email, value: confirmEmail, onChange: (e) => setConfirmEmail(e.target.value), "aria-label": "\u8F93\u5165\u76EE\u6807\u90AE\u7BB1\u4EE5\u786E\u8BA4\u5220\u9664", disabled: !!deleteBlockedReason })] }) }), _jsx(ImpactConfirmation, { open: deleteOpen, onOpenChange: setDeleteOpen, onConfirm: () => deleteM.mutate(u.email), title: `删除账号 ${u.email}`, confirmText: "\u6C38\u4E45\u5220\u9664", danger: true, impacts: [
|
|
258
|
+
'账号进入 deleted 状态,邮箱被改写释放,无法恢复',
|
|
259
|
+
'该用户全部在途会话与个人访问令牌(PAT)立即吊销',
|
|
260
|
+
'名下工作区与业务数据保留,操作记入平台审计日志',
|
|
261
|
+
] }), _jsx(ImpactConfirmation, { open: kickOpen, onOpenChange: setKickOpen, onConfirm: () => kickM.mutate(), title: `踢下线 ${u.email}`, confirmText: "\u8E22\u4E0B\u7EBF", danger: true, impacts: [
|
|
262
|
+
'该用户全部在途会话立即吊销,所有设备需重新登录',
|
|
263
|
+
'账号状态不变(不是封禁),重新登录即可恢复',
|
|
264
|
+
'操作将记入平台审计日志(admin.user_kick)',
|
|
265
|
+
] })] }))] }));
|
|
266
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useNavigate, useSearchParams } from 'react-router';
|
|
4
|
+
import { MoreHorizontal, LogIn, ShieldCheck, ShieldOff, UserCog, UserRoundSearch, } from 'lucide-react';
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { useConfirm } from '../../components/ConfirmDialog';
|
|
7
|
+
import { DataTable } from '../../components/DataTable';
|
|
8
|
+
import { PageEmpty, PageError, PageHeader } from '../../components/Page';
|
|
9
|
+
import { ImpactConfirmation } from '../../components/SecretModal';
|
|
10
|
+
import { toast } from '../../components/toast';
|
|
11
|
+
import { Badge, StatusBadge } from '../../components/ui/Badge';
|
|
12
|
+
import { Button } from '../../components/ui/Button';
|
|
13
|
+
import { Input } from '../../components/ui/Input';
|
|
14
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '../../components/ui/menu';
|
|
15
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
|
|
16
|
+
import { useApi } from '../../hooks/useApi';
|
|
17
|
+
import { useAuth } from '../../hooks/useAuth';
|
|
18
|
+
import { formatDate, formatDateTime } from '../../lib/utils';
|
|
19
|
+
import { ADMIN_PAGE_SIZE, adminError, adminListQuery, } from './shared';
|
|
20
|
+
const STATUS_OPTIONS = [
|
|
21
|
+
{ value: 'active', label: '生效中' },
|
|
22
|
+
{ value: 'disabled', label: '已封禁' },
|
|
23
|
+
];
|
|
24
|
+
export function UsersPage({ impersonatePath = '/app' } = {}) {
|
|
25
|
+
const api = useApi();
|
|
26
|
+
const qc = useQueryClient();
|
|
27
|
+
const confirm = useConfirm();
|
|
28
|
+
const navigate = useNavigate();
|
|
29
|
+
const me = useAuth().user;
|
|
30
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
31
|
+
const q = searchParams.get('q') ?? '';
|
|
32
|
+
const statusParam = searchParams.get('status') ?? '';
|
|
33
|
+
const status = STATUS_OPTIONS.some((s) => s.value === statusParam)
|
|
34
|
+
? statusParam
|
|
35
|
+
: '';
|
|
36
|
+
const page = Math.max(1, Number.parseInt(searchParams.get('page') ?? '1', 10) || 1);
|
|
37
|
+
const applyFilter = (nextQ, nextStatus, nextPage) => {
|
|
38
|
+
const p = new URLSearchParams(searchParams);
|
|
39
|
+
if (nextQ)
|
|
40
|
+
p.set('q', nextQ);
|
|
41
|
+
else
|
|
42
|
+
p.delete('q');
|
|
43
|
+
if (nextStatus)
|
|
44
|
+
p.set('status', nextStatus);
|
|
45
|
+
else
|
|
46
|
+
p.delete('status');
|
|
47
|
+
if (nextPage > 1)
|
|
48
|
+
p.set('page', String(nextPage));
|
|
49
|
+
else
|
|
50
|
+
p.delete('page');
|
|
51
|
+
setSearchParams(p, { replace: true });
|
|
52
|
+
};
|
|
53
|
+
const [searchInput, setSearchInput] = useState(q);
|
|
54
|
+
const [action, setAction] = useState(null);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const t = setTimeout(() => {
|
|
57
|
+
const next = searchInput.trim();
|
|
58
|
+
if (next !== q)
|
|
59
|
+
applyFilter(next, status, 1);
|
|
60
|
+
}, 300);
|
|
61
|
+
return () => clearTimeout(t);
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [searchInput]);
|
|
64
|
+
const usersQ = useQuery({
|
|
65
|
+
queryKey: ['admin', 'users', page, q, status],
|
|
66
|
+
queryFn: () => api.get(`/api/admin/users${adminListQuery(page, ADMIN_PAGE_SIZE, { q, status })}`),
|
|
67
|
+
});
|
|
68
|
+
const invalidate = () => {
|
|
69
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
|
70
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'user'] });
|
|
71
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
|
72
|
+
};
|
|
73
|
+
const statusM = useMutation({
|
|
74
|
+
mutationFn: (v) => api.patch(`/api/admin/users/${v.user.id}/status`, { status: v.status }),
|
|
75
|
+
onSuccess: (_d, v) => {
|
|
76
|
+
toast.success(`${v.user.email} 已${v.status === 'disabled' ? '封禁' : '解封'}`);
|
|
77
|
+
setAction(null);
|
|
78
|
+
void invalidate();
|
|
79
|
+
},
|
|
80
|
+
onError: (e) => toast.error(adminError(e, '操作失败')),
|
|
81
|
+
});
|
|
82
|
+
const adminFlagM = useMutation({
|
|
83
|
+
mutationFn: (v) => api.patch(`/api/admin/users/${v.user.id}/admin`, { is_admin: v.isAdmin }),
|
|
84
|
+
onSuccess: (_d, v) => {
|
|
85
|
+
toast.success(`${v.user.email} ${v.isAdmin ? '已授予' : '已撤销'}平台管理员`);
|
|
86
|
+
void invalidate();
|
|
87
|
+
},
|
|
88
|
+
onError: (e) => toast.error(adminError(e, '操作失败')),
|
|
89
|
+
});
|
|
90
|
+
const impersonateM = useMutation({
|
|
91
|
+
mutationFn: (user) => api.post(`/api/admin/users/${user.id}/impersonate`),
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
window.location.href = impersonatePath;
|
|
94
|
+
},
|
|
95
|
+
onError: (e) => {
|
|
96
|
+
toast.error(adminError(e, '模拟登录失败'));
|
|
97
|
+
setAction(null);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const onToggleAdmin = async (user) => {
|
|
101
|
+
const revoke = user.is_platform_admin;
|
|
102
|
+
const ok = await confirm({
|
|
103
|
+
title: revoke ? '撤销平台管理员' : '授予平台管理员',
|
|
104
|
+
description: revoke
|
|
105
|
+
? `${user.email} 将失去平台管理权限(管理控制台入口随之消失)。`
|
|
106
|
+
: `${user.email} 将获得平台管理权限,可进入管理控制台操作平台全部数据。`,
|
|
107
|
+
confirmText: revoke ? '撤销管理员' : '授予管理员',
|
|
108
|
+
danger: revoke,
|
|
109
|
+
});
|
|
110
|
+
if (ok)
|
|
111
|
+
adminFlagM.mutate({ user, isAdmin: !revoke });
|
|
112
|
+
};
|
|
113
|
+
const raw = usersQ.data?.items ?? [];
|
|
114
|
+
const total = usersQ.data?.total ?? 0;
|
|
115
|
+
const pastEnd = !usersQ.isPending && raw.length === 0 && page > 1;
|
|
116
|
+
const filtered = !!(q || status);
|
|
117
|
+
const columns = [
|
|
118
|
+
{
|
|
119
|
+
key: 'email',
|
|
120
|
+
header: '用户',
|
|
121
|
+
render: (u) => (_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "truncate font-medium", children: [u.email, u.is_platform_admin && (_jsx(Badge, { variant: "secondary", className: "ml-2", children: "\u5E73\u53F0\u7BA1\u7406\u5458" }))] }), _jsx("p", { className: "truncate text-xs text-muted-foreground", children: u.display_name || '—' })] })),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: 'status',
|
|
125
|
+
header: '状态',
|
|
126
|
+
className: 'w-24',
|
|
127
|
+
render: (u) => _jsx(StatusBadge, { status: u.status }),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: 'email_verified',
|
|
131
|
+
header: '邮箱验证',
|
|
132
|
+
className: 'w-24',
|
|
133
|
+
render: (u) => u.email_verified ? (_jsx(Badge, { variant: "outline", children: "\u5DF2\u9A8C\u8BC1" })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u9A8C\u8BC1" })),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: 'created_at',
|
|
137
|
+
header: '注册时间',
|
|
138
|
+
className: 'w-28',
|
|
139
|
+
render: (u) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(u.created_at) })),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: 'last_login_at',
|
|
143
|
+
header: '最近登录',
|
|
144
|
+
className: 'w-44',
|
|
145
|
+
render: (u) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: u.last_login_at ? formatDateTime(u.last_login_at) : '从未登录' })),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: 'actions',
|
|
149
|
+
header: '操作',
|
|
150
|
+
className: 'w-16',
|
|
151
|
+
render: (u) => {
|
|
152
|
+
if (me?.id === u.id) {
|
|
153
|
+
return (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`/admin/users/${u.id}`), children: [_jsx(UserRoundSearch, {}), " \u8BE6\u60C5"] }));
|
|
154
|
+
}
|
|
155
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { className: "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground max-md:size-10", "aria-label": `操作 ${u.email}`, children: _jsx(MoreHorizontal, { size: 16, "aria-hidden": "true" }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-48", children: [_jsx(DropdownMenuGroup, { children: _jsx(DropdownMenuLabel, { className: "truncate", children: u.email }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => navigate(`/admin/users/${u.id}`), children: [_jsx(UserRoundSearch, {}), " \u7528\u6237\u8BE6\u60C5"] }), _jsx(DropdownMenuSeparator, {}), u.status === 'active' ? (_jsxs(DropdownMenuItem, { variant: "destructive", onClick: () => setAction({ kind: 'disable', user: u }), children: [_jsx(ShieldOff, {}), " \u5C01\u7981\u8D26\u53F7"] })) : (_jsxs(DropdownMenuItem, { onClick: () => setAction({ kind: 'enable', user: u }), children: [_jsx(ShieldCheck, {}), " \u89E3\u5C01\u8D26\u53F7"] })), _jsxs(DropdownMenuItem, { onClick: () => void onToggleAdmin(u), children: [_jsx(UserCog, {}), ' ', u.is_platform_admin ? '撤销平台管理员' : '授予平台管理员'] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { disabled: u.is_platform_admin, onClick: () => setAction({ kind: 'impersonate', user: u }), children: [_jsx(LogIn, {}), " \u6A21\u62DF\u767B\u5F55"] })] })] }));
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u7528\u6237", description: "\u5E73\u53F0\u5168\u90E8\u6CE8\u518C\u7528\u6237\uFF1B\u652F\u6301\u90AE\u7BB1/\u6635\u79F0\u641C\u7D22\u4E0E\u72B6\u6001\u8FC7\u6EE4\uFF08\u670D\u52A1\u7AEF\u68C0\u7D22\uFF09" }), _jsxs("div", { className: "mb-4 flex flex-wrap items-center gap-2", children: [_jsx(Input, { className: "w-72", placeholder: "\u6309\u90AE\u7BB1\u524D\u7F00 / \u6635\u79F0\u641C\u7D22\u2026", "aria-label": "\u641C\u7D22\u7528\u6237", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: (e) => {
|
|
160
|
+
if (e.key === 'Enter')
|
|
161
|
+
applyFilter(searchInput.trim(), status, 1);
|
|
162
|
+
} }), _jsxs(Select, { value: status || 'all', onValueChange: (v) => {
|
|
163
|
+
const next = typeof v === 'string' && v !== 'all' ? v : '';
|
|
164
|
+
applyFilter(q, next, 1);
|
|
165
|
+
}, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u6309\u72B6\u6001\u8FC7\u6EE4", children: _jsx(SelectValue, { children: status
|
|
166
|
+
? (STATUS_OPTIONS.find((s) => s.value === status)?.label ??
|
|
167
|
+
status)
|
|
168
|
+
: '全部状态' }) }), _jsxs(SelectContent, { align: "start", children: [_jsx(SelectItem, { value: "all", children: "\u5168\u90E8\u72B6\u6001" }), STATUS_OPTIONS.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value)))] })] }), filtered && (_jsx(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => {
|
|
169
|
+
setSearchInput('');
|
|
170
|
+
applyFilter('', '', 1);
|
|
171
|
+
}, children: "\u6E05\u9664\u8FC7\u6EE4" }))] }), usersQ.isError ? (_jsx(PageError, { message: adminError(usersQ.error, '用户列表加载失败'), onRetry: () => void usersQ.refetch() })) : (_jsx(DataTable, { columns: columns, rows: raw, rowKey: (u) => u.id, loading: usersQ.isPending, pagination: {
|
|
172
|
+
page,
|
|
173
|
+
pageSize: ADMIN_PAGE_SIZE,
|
|
174
|
+
total,
|
|
175
|
+
onPageChange: (p) => applyFilter(q, status, p),
|
|
176
|
+
}, empty: pastEnd ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(PageEmpty, { label: "\u6CA1\u6709\u66F4\u591A\u7528\u6237\u4E86", hint: "\u5F53\u524D\u9875\u6570\u636E\u53EF\u80FD\u5DF2\u88AB\u5220\u9664" }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => applyFilter(q, status, 1), children: "\u8FD4\u56DE\u7B2C\u4E00\u9875" })] })) : filtered ? (_jsx(PageEmpty, { label: "\u6CA1\u6709\u5339\u914D\u7528\u6237", hint: "\u6362\u4E2A\u5173\u952E\u8BCD\u3001\u653E\u5BBD\u72B6\u6001\u6761\u4EF6\u6216\u6E05\u9664\u8FC7\u6EE4" })) : (_jsx(PageEmpty, { label: "\u6682\u65E0\u7528\u6237" })) })), _jsx(ImpactConfirmation, { open: action?.kind === 'disable' || action?.kind === 'enable', onOpenChange: (o) => !o && setAction(null), title: action?.kind === 'disable'
|
|
177
|
+
? `封禁 ${action.user.email}`
|
|
178
|
+
: action?.kind === 'enable'
|
|
179
|
+
? `解封 ${action.user.email}`
|
|
180
|
+
: '', danger: action?.kind === 'disable', confirmText: action?.kind === 'disable' ? '封禁' : '解封', impacts: action?.kind === 'disable'
|
|
181
|
+
? [
|
|
182
|
+
'该用户将无法通过邮箱密码与 OAuth 登录',
|
|
183
|
+
'其名下工作区与数据保留,不受影响',
|
|
184
|
+
'操作将记入平台审计日志',
|
|
185
|
+
]
|
|
186
|
+
: ['该用户恢复登录能力', '操作将记入平台审计日志'], onConfirm: () => {
|
|
187
|
+
if (action) {
|
|
188
|
+
statusM.mutate({
|
|
189
|
+
user: action.user,
|
|
190
|
+
status: action.kind === 'disable' ? 'disabled' : 'active',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} }), _jsx(ImpactConfirmation, { open: action?.kind === 'impersonate', onOpenChange: (o) => !o && setAction(null), title: action?.kind === 'impersonate' ? `模拟登录 ${action.user.email}` : '', confirmText: impersonateM.isPending ? '切换中…' : '以该用户进入产品', impacts: [
|
|
194
|
+
'浏览器将以该用户身份进入产品(/app)',
|
|
195
|
+
'当前管理员会话被替换,退出登录后需重新登录管理员账号',
|
|
196
|
+
'审计将记录 admin.impersonate,操作可追溯',
|
|
197
|
+
], onConfirm: () => {
|
|
198
|
+
if (action?.kind === 'impersonate')
|
|
199
|
+
impersonateM.mutate(action.user);
|
|
200
|
+
} })] }));
|
|
201
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function WebhookDeliveriesPage(): import("react").JSX.Element;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useSearchParams } from 'react-router';
|
|
3
|
+
import { RefreshCw } from 'lucide-react';
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
5
|
+
import { DataTable } from '../../components/DataTable';
|
|
6
|
+
import { PageEmpty, PageError, PageHeader } from '../../components/Page';
|
|
7
|
+
import { toast } from '../../components/toast';
|
|
8
|
+
import { Badge, StatusBadge } from '../../components/ui/Badge';
|
|
9
|
+
import { Button } from '../../components/ui/Button';
|
|
10
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
|
|
11
|
+
import { useApi } from '../../hooks/useApi';
|
|
12
|
+
import { formatDateTime, shortID, statusLabel } from '../../lib/utils';
|
|
13
|
+
import { ADMIN_PAGE_SIZE, adminError, adminListQuery, } from './shared';
|
|
14
|
+
const DELIVERY_STATUSES = ['pending', 'delivered', 'dead'];
|
|
15
|
+
const LIST_REFETCH_MS = 30_000;
|
|
16
|
+
const COUNT_STALE_MS = 60_000;
|
|
17
|
+
export function WebhookDeliveriesPage() {
|
|
18
|
+
const api = useApi();
|
|
19
|
+
const qc = useQueryClient();
|
|
20
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
21
|
+
const statusParam = searchParams.get('status') ?? '';
|
|
22
|
+
const status = DELIVERY_STATUSES.includes(statusParam) ? statusParam : '';
|
|
23
|
+
const page = Math.max(1, Number.parseInt(searchParams.get('page') ?? '1', 10) || 1);
|
|
24
|
+
const setFilter = (nextStatus, nextPage = 1) => {
|
|
25
|
+
const p = new URLSearchParams(searchParams);
|
|
26
|
+
if (nextStatus)
|
|
27
|
+
p.set('status', nextStatus);
|
|
28
|
+
else
|
|
29
|
+
p.delete('status');
|
|
30
|
+
if (nextPage > 1)
|
|
31
|
+
p.set('page', String(nextPage));
|
|
32
|
+
else
|
|
33
|
+
p.delete('page');
|
|
34
|
+
setSearchParams(p, { replace: true });
|
|
35
|
+
};
|
|
36
|
+
const listQ = useQuery({
|
|
37
|
+
queryKey: ['admin', 'webhook-deliveries', page, status],
|
|
38
|
+
queryFn: () => api.get(`/api/admin/webhook-deliveries${adminListQuery(page, ADMIN_PAGE_SIZE, { status })}`),
|
|
39
|
+
refetchInterval: LIST_REFETCH_MS,
|
|
40
|
+
});
|
|
41
|
+
const countQuery = (s) => ({
|
|
42
|
+
queryKey: ['admin', 'webhook-deliveries-count', s],
|
|
43
|
+
queryFn: () => api.get(`/api/admin/webhook-deliveries${adminListQuery(1, 1, { status: s })}`),
|
|
44
|
+
staleTime: COUNT_STALE_MS,
|
|
45
|
+
});
|
|
46
|
+
const pendingCountQ = useQuery(countQuery('pending'));
|
|
47
|
+
const deliveredCountQ = useQuery(countQuery('delivered'));
|
|
48
|
+
const deadCountQ = useQuery(countQuery('dead'));
|
|
49
|
+
const statusCounts = {
|
|
50
|
+
pending: pendingCountQ.data?.total,
|
|
51
|
+
delivered: deliveredCountQ.data?.total,
|
|
52
|
+
dead: deadCountQ.data?.total,
|
|
53
|
+
};
|
|
54
|
+
const redeliverM = useMutation({
|
|
55
|
+
mutationFn: (d) => api.post(`/api/admin/webhook-deliveries/${d.id}/redeliver`),
|
|
56
|
+
onSuccess: (d2, d) => {
|
|
57
|
+
toast.success(`已重新入队(新投递 ${shortID(d2?.id ?? '')},溯源 ${d.event_id}.r)`);
|
|
58
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'webhook-deliveries'] });
|
|
59
|
+
},
|
|
60
|
+
onError: (e) => toast.error(adminError(e, '重投失败')),
|
|
61
|
+
});
|
|
62
|
+
const items = listQ.data?.items ?? [];
|
|
63
|
+
const total = listQ.data?.total ?? 0;
|
|
64
|
+
const pastEnd = !listQ.isPending && items.length === 0 && page > 1;
|
|
65
|
+
const columns = [
|
|
66
|
+
{
|
|
67
|
+
key: 'workspace_id',
|
|
68
|
+
header: '工作区',
|
|
69
|
+
className: 'w-24',
|
|
70
|
+
render: (d) => d.workspace_id ? (_jsx("span", { className: "font-mono text-xs", title: d.workspace_id, children: shortID(d.workspace_id) })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u5B64\u513F" })),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'url',
|
|
74
|
+
header: '目标 URL',
|
|
75
|
+
render: (d) => d.url ? (_jsx("p", { className: "max-w-[22rem] truncate font-mono text-xs", title: d.url, children: d.url })) : (_jsx("span", { className: "text-xs text-muted-foreground", children: "\uFF08\u8BA2\u9605\u5DF2\u5220\u9664\uFF09" })),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'event_id',
|
|
79
|
+
header: '事件 ID',
|
|
80
|
+
className: 'w-32',
|
|
81
|
+
render: (d) => (_jsx("code", { className: "block max-w-[8rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs", title: d.event_id, children: d.event_id })),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'event_type',
|
|
85
|
+
header: '事件',
|
|
86
|
+
className: 'w-40',
|
|
87
|
+
render: (d) => (_jsx("code", { className: "block max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs", title: d.event_type, children: d.event_type })),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: 'status',
|
|
91
|
+
header: '状态',
|
|
92
|
+
className: 'w-24',
|
|
93
|
+
render: (d) => _jsx(StatusBadge, { status: d.status }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'attempts',
|
|
97
|
+
header: '尝试',
|
|
98
|
+
className: 'w-16 text-right',
|
|
99
|
+
render: (d) => _jsx("span", { className: "tabular-nums", children: d.attempts }),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: 'last_status',
|
|
103
|
+
header: '最近响应',
|
|
104
|
+
className: 'w-32',
|
|
105
|
+
render: (d) => d.last_status_code ? (_jsx("span", { className: "font-mono text-xs", title: d.last_error ?? undefined, children: d.last_status_code })) : d.last_error ? (_jsx("span", { className: "block max-w-[12rem] truncate text-xs text-destructive", title: d.last_error, children: d.last_error })) : (_jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" })),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'created_at',
|
|
109
|
+
header: '时间',
|
|
110
|
+
className: 'w-44',
|
|
111
|
+
render: (d) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(d.created_at) })),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: 'actions',
|
|
115
|
+
header: '操作',
|
|
116
|
+
className: 'w-20',
|
|
117
|
+
render: (d) => (_jsx(Button, { variant: "outline", size: "sm", disabled: redeliverM.isPending, onClick: () => redeliverM.mutate(d), children: "\u91CD\u6295" })),
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
return (_jsxs("div", { children: [_jsx(PageHeader, { title: "Webhooks", description: "\u6240\u6709\u5DE5\u4F5C\u533A\u7684 Webhook \u6295\u9012\u4E0E\u91CD\u8BD5\u8BB0\u5F55\uFF0C\u6309\u65F6\u95F4\u5012\u5E8F" }), _jsxs("div", { className: "mb-4 flex flex-wrap items-center gap-2", children: [_jsxs(Select, { value: status || 'all', onValueChange: (v) => {
|
|
121
|
+
setFilter(typeof v === 'string' && v !== 'all' ? v : '', 1);
|
|
122
|
+
}, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u6309\u6295\u9012\u72B6\u6001\u8FC7\u6EE4", children: _jsx(SelectValue, { children: status ? statusLabel(status) : '全部状态' }) }), _jsxs(SelectContent, { align: "start", children: [_jsx(SelectItem, { value: "all", children: "\u5168\u90E8\u72B6\u6001" }), DELIVERY_STATUSES.map((s) => (_jsxs(SelectItem, { value: s, children: [statusLabel(s), typeof statusCounts[s] === 'number' ? ` (${statusCounts[s]})` : ''] }, s)))] })] }), _jsxs(Button, { variant: "outline", size: "sm", disabled: listQ.isFetching, onClick: () => void listQ.refetch(), children: [_jsx(RefreshCw, { size: 14, "aria-hidden": "true" }), listQ.isFetching ? '刷新中…' : '刷新'] })] }), listQ.isError ? (_jsx(PageError, { message: adminError(listQ.error, '投递列表加载失败'), onRetry: () => void listQ.refetch() })) : (_jsx(DataTable, { columns: columns, rows: items, rowKey: (d) => d.id, loading: listQ.isPending, pagination: {
|
|
123
|
+
page,
|
|
124
|
+
pageSize: ADMIN_PAGE_SIZE,
|
|
125
|
+
total,
|
|
126
|
+
onPageChange: (p) => setFilter(status, p),
|
|
127
|
+
}, empty: pastEnd ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(PageEmpty, { label: "\u6CA1\u6709\u66F4\u591A\u6295\u9012\u4E86", hint: "\u5F53\u524D\u9875\u6570\u636E\u53EF\u80FD\u5DF2\u88AB\u5220\u9664" }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => setFilter(status, 1), children: "\u8FD4\u56DE\u7B2C\u4E00\u9875" })] })) : status ? (_jsx(PageEmpty, { label: "\u6CA1\u6709\u8BE5\u72B6\u6001\u7684\u6295\u9012", hint: "\u6362\u4E2A\u72B6\u6001\u6216\u6E05\u9664\u8FC7\u6EE4" })) : (_jsx(PageEmpty, { label: "\u6682\u65E0\u6295\u9012\u8BB0\u5F55", hint: "\u5DE5\u4F5C\u533A\u89E6\u53D1\u4E8B\u4EF6\u540E\uFF0C\u8BA2\u9605\u6295\u9012\u4F1A\u5728\u6B64\u51FA\u73B0" })) }))] }));
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function WorkspaceDetailPage(): import("react").JSX.Element;
|