@ploykit/ui 0.6.0 → 0.6.2

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.
Files changed (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,641 @@
1
+
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 type { components } from '@ploykit/client';
8
+ import { useConfirm } from '../../components/ConfirmDialog';
9
+ import { DataTable, type Column } from '../../components/DataTable';
10
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
11
+ import { ImpactConfirmation } from '../../components/SecretModal';
12
+ import { SettingsCard } from '../../components/SettingsCard';
13
+ import { toast } from '../../components/toast';
14
+ import { Badge } from '../../components/ui/badge'
15
+ import { StatusBadge } from '../../components/StatusBadge';
16
+ import { Button } from '../../components/ui/button';
17
+ import { Input } from '../../components/ui/input';
18
+ import { useApi, ApiError } from '@ploykit/hooks';
19
+ import { useAuth } from '@ploykit/hooks';
20
+ import { apiErrorMessage } from '../../lib/api-error';
21
+ import { formatDate, formatDateTime, shortID } from '../../lib/utils';
22
+ import { adminError } from './shared';
23
+
24
+ type AdminUser = components['schemas']['AdminUser'];
25
+
26
+
27
+ interface AdminUserSession {
28
+ id: string;
29
+ device: string;
30
+ ip_hash: string;
31
+ created_at: string;
32
+ last_seen_at: string;
33
+ expires_at: string;
34
+ revoked_at?: string;
35
+ }
36
+
37
+
38
+ interface AdminPATRow {
39
+ id: string;
40
+ name: string;
41
+ prefix: string;
42
+ last_used_at?: string;
43
+ expires_at?: string;
44
+ created_at: string;
45
+ revoked_at?: string;
46
+ }
47
+
48
+
49
+ interface AdminUserWorkspaceRow {
50
+ id: string;
51
+ slug: string;
52
+ name: string;
53
+ role: string;
54
+ created_at: string;
55
+ }
56
+
57
+
58
+ interface AdminUserDetail {
59
+ user: AdminUser;
60
+ sessions: AdminUserSession[];
61
+ pats: AdminPATRow[];
62
+ workspaces: AdminUserWorkspaceRow[];
63
+ }
64
+
65
+
66
+ function resendErrorMessage(err: unknown): string {
67
+ if (err instanceof ApiError) {
68
+ if (err.status === 409) return '该用户邮箱已验证,无需重发';
69
+ if (err.status === 429) return '发送冷却中,请约 1 分钟后再试';
70
+ }
71
+ return apiErrorMessage(err, '验证邮件发送失败');
72
+ }
73
+
74
+ export function UserDetailPage() {
75
+ const { id = '' } = useParams();
76
+ const api = useApi();
77
+ const qc = useQueryClient();
78
+ const confirm = useConfirm();
79
+ const navigate = useNavigate();
80
+ const me = useAuth().user;
81
+ const [confirmEmail, setConfirmEmail] = useState('');
82
+ const [deleteOpen, setDeleteOpen] = useState(false);
83
+ const [kickOpen, setKickOpen] = useState(false);
84
+ const [exporting, setExporting] = useState(false);
85
+
86
+ const detailQ = useQuery({
87
+ queryKey: ['admin', 'user', id],
88
+ queryFn: () => api.get<AdminUserDetail>(`/api/admin/users/${id}`),
89
+ enabled: !!id,
90
+ });
91
+
92
+ const invalidate = () => {
93
+ void qc.invalidateQueries({ queryKey: ['admin', 'user', id] });
94
+ void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
95
+ };
96
+
97
+
98
+ const kickM = useMutation({
99
+ mutationFn: () => api.post(`/api/admin/users/${id}/kick`),
100
+ onSuccess: () => {
101
+ toast.success('已吊销该用户全部在途会话');
102
+ invalidate();
103
+ },
104
+ onError: (e) => toast.error(apiErrorMessage(e, '踢下线失败')),
105
+ });
106
+
107
+ const revokeSessionM = useMutation({
108
+ mutationFn: (sid: string) =>
109
+ api.post(`/api/admin/users/${id}/sessions/${sid}/revoke`),
110
+ onSuccess: () => {
111
+ toast.success('会话已吊销');
112
+ invalidate();
113
+ },
114
+ onError: (e) => toast.error(apiErrorMessage(e, '会话吊销失败')),
115
+ });
116
+
117
+ const resetPwdM = useMutation({
118
+ mutationFn: () => api.post(`/api/admin/users/${id}/password-reset`),
119
+ onSuccess: () => toast.success('重置邮件已发送'),
120
+ onError: (e) => toast.error(apiErrorMessage(e, '重置邮件发送失败')),
121
+ });
122
+
123
+ const resendM = useMutation({
124
+ mutationFn: () => api.post(`/api/admin/users/${id}/resend-verification`),
125
+ onSuccess: () => toast.success('验证邮件已发送'),
126
+ onError: (e) => toast.error(resendErrorMessage(e)),
127
+ });
128
+
129
+ const markVerifiedM = useMutation({
130
+ mutationFn: () => api.post(`/api/admin/users/${id}/mark-verified`),
131
+ onSuccess: () => {
132
+ toast.success('已标记邮箱已验证');
133
+ invalidate();
134
+ },
135
+ onError: (e) => toast.error(apiErrorMessage(e, '标记失败')),
136
+ });
137
+
138
+ const revokePatM = useMutation({
139
+ mutationFn: (patID: string) =>
140
+ api.post(`/api/admin/users/${id}/pats/${patID}/revoke`),
141
+ onSuccess: () => {
142
+ toast.success('令牌已吊销');
143
+ invalidate();
144
+ },
145
+ onError: (e) => toast.error(apiErrorMessage(e, '令牌吊销失败')),
146
+ });
147
+
148
+
149
+ const deleteM = useMutation({
150
+ mutationFn: (email: string) =>
151
+ api.delete(`/api/admin/users/${id}?confirm=${encodeURIComponent(email)}`),
152
+ onSuccess: () => {
153
+ toast.success('账号已删除(软删)');
154
+ void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
155
+ navigate('/admin/users');
156
+ },
157
+ onError: (e) => toast.error(apiErrorMessage(e, '删除失败')),
158
+ });
159
+
160
+
161
+
162
+ const onExportData = async () => {
163
+ setExporting(true);
164
+ try {
165
+ const blob = await apiFetch<Blob>(`/api/admin/users/${id}/export`, {
166
+ responseType: 'blob',
167
+ });
168
+ const url = URL.createObjectURL(blob);
169
+ const a = document.createElement('a');
170
+ a.href = url;
171
+ a.download = `userdata-${id}.json`;
172
+ a.click();
173
+ URL.revokeObjectURL(url);
174
+ toast.success('用户数据导出已开始下载');
175
+ } catch (e) {
176
+ toast.error(apiErrorMessage(e, '导出失败'));
177
+ } finally {
178
+ setExporting(false);
179
+ }
180
+ };
181
+
182
+ const onRevokeSession = async (s: AdminUserSession) => {
183
+ const ok = await confirm({
184
+ title: '吊销会话',
185
+ description: `将吊销该设备会话(${s.device || shortID(s.id)}),该设备需要重新登录。`,
186
+ confirmText: '吊销',
187
+ danger: true,
188
+ });
189
+ if (ok) revokeSessionM.mutate(s.id);
190
+ };
191
+
192
+ const onRevokePat = async (p: AdminPATRow) => {
193
+ const ok = await confirm({
194
+ title: '吊销访问令牌',
195
+ description: `将吊销「${p.name}」,使用该令牌的集成立即失效。`,
196
+ confirmText: '吊销',
197
+ danger: true,
198
+ });
199
+ if (ok) revokePatM.mutate(p.id);
200
+ };
201
+
202
+ if (detailQ.isError) {
203
+ return (
204
+ <div>
205
+ <PageHeader title="用户详情" />
206
+ <PageError
207
+ message={adminError(detailQ.error, '用户详情加载失败')}
208
+ onRetry={() => void detailQ.refetch()}
209
+ />
210
+ </div>
211
+ );
212
+ }
213
+
214
+ const d = detailQ.data;
215
+ const u = d?.user;
216
+ const isSelf = !!u && me?.id === u.id;
217
+
218
+ const deleteBlockedReason = isSelf
219
+ ? '不能删除当前登录的管理员账号自己'
220
+ : u?.is_platform_admin
221
+ ? '不能删除另一位平台管理员(admin-on-admin 禁止)'
222
+ : '';
223
+ const emailReadyToDelete =
224
+ !!u && confirmEmail.trim().toLowerCase() === u.email.toLowerCase();
225
+
226
+ const sessionColumns: Column<AdminUserSession>[] = [
227
+ {
228
+ key: 'device',
229
+ header: '设备',
230
+ render: (s) => (
231
+ <div className="min-w-0">
232
+ <p className="truncate">{s.device || '未知设备'}</p>
233
+ <p
234
+ className="truncate font-mono text-xs text-muted-foreground"
235
+ title={s.ip_hash}
236
+ >
237
+ {s.ip_hash ? shortID(s.ip_hash) : '—'}
238
+ </p>
239
+ </div>
240
+ ),
241
+ },
242
+ {
243
+ key: 'last_seen_at',
244
+ header: '最近活跃',
245
+ className: 'w-44',
246
+ render: (s) => (
247
+ <span className="whitespace-nowrap text-muted-foreground">
248
+ {formatDateTime(s.last_seen_at)}
249
+ </span>
250
+ ),
251
+ },
252
+ {
253
+ key: 'expires_at',
254
+ header: '过期时间',
255
+ className: 'w-44',
256
+ render: (s) => (
257
+ <span className="whitespace-nowrap text-muted-foreground">
258
+ {formatDateTime(s.expires_at)}
259
+ </span>
260
+ ),
261
+ },
262
+ {
263
+ key: 'actions',
264
+ header: '操作',
265
+ className: 'w-20',
266
+ render: (s) => (
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ disabled={revokeSessionM.isPending}
271
+ onClick={() => void onRevokeSession(s)}
272
+ >
273
+ 吊销
274
+ </Button>
275
+ ),
276
+ },
277
+ ];
278
+
279
+ const patColumns: Column<AdminPATRow>[] = [
280
+ {
281
+ key: 'name',
282
+ header: '令牌',
283
+ render: (p) => (
284
+ <div className="min-w-0">
285
+ <p className="truncate font-medium">
286
+ {p.name}
287
+ {p.revoked_at && (
288
+ <Badge variant="outline" className="ml-2 text-muted-foreground">
289
+ 已吊销
290
+ </Badge>
291
+ )}
292
+ </p>
293
+ <p className="truncate font-mono text-xs text-muted-foreground">
294
+ {p.prefix}…
295
+ </p>
296
+ </div>
297
+ ),
298
+ },
299
+ {
300
+ key: 'created_at',
301
+ header: '创建时间',
302
+ className: 'w-28',
303
+ render: (p) => (
304
+ <span className="whitespace-nowrap text-muted-foreground">
305
+ {formatDate(p.created_at)}
306
+ </span>
307
+ ),
308
+ },
309
+ {
310
+ key: 'last_used_at',
311
+ header: '最近使用',
312
+ className: 'w-44',
313
+ render: (p) => (
314
+ <span className="whitespace-nowrap text-muted-foreground">
315
+ {p.last_used_at ? formatDateTime(p.last_used_at) : '从未使用'}
316
+ </span>
317
+ ),
318
+ },
319
+ {
320
+ key: 'expires_at',
321
+ header: '过期时间',
322
+ className: 'w-28',
323
+ render: (p) => (
324
+ <span className="whitespace-nowrap text-muted-foreground">
325
+ {p.expires_at ? formatDate(p.expires_at) : '永不过期'}
326
+ </span>
327
+ ),
328
+ },
329
+ {
330
+ key: 'actions',
331
+ header: '操作',
332
+ className: 'w-20',
333
+ render: (p) =>
334
+ p.revoked_at ? (
335
+ <span className="text-xs text-muted-foreground">—</span>
336
+ ) : (
337
+ <Button
338
+ variant="outline"
339
+ size="sm"
340
+ disabled={revokePatM.isPending}
341
+ onClick={() => void onRevokePat(p)}
342
+ >
343
+ 吊销
344
+ </Button>
345
+ ),
346
+ },
347
+ ];
348
+
349
+ const wsColumns: Column<AdminUserWorkspaceRow>[] = [
350
+ {
351
+ key: 'name',
352
+ header: '工作区',
353
+ render: (w) => (
354
+ <div className="min-w-0">
355
+ <p className="truncate font-medium">{w.name}</p>
356
+ <p className="truncate font-mono text-xs text-muted-foreground">
357
+ {w.slug}
358
+ </p>
359
+ </div>
360
+ ),
361
+ },
362
+ {
363
+ key: 'role',
364
+ header: '角色',
365
+ className: 'w-24',
366
+ render: (w) => <StatusBadge status={w.role} />,
367
+ },
368
+ {
369
+ key: 'created_at',
370
+ header: '加入时间',
371
+ className: 'w-28',
372
+ render: (w) => (
373
+ <span className="whitespace-nowrap text-muted-foreground">
374
+ {formatDate(w.created_at)}
375
+ </span>
376
+ ),
377
+ },
378
+ ];
379
+
380
+ return (
381
+ <div className="space-y-6">
382
+ <PageHeader
383
+ title={u ? u.email : '用户详情'}
384
+ description={
385
+ u
386
+ ? `${u.display_name || '未设置昵称'} · 用户 ID ${shortID(u.id)}`
387
+ : '用户资料与会话 / 令牌 / 工作区聚合视图'
388
+ }
389
+ actions={
390
+ <>
391
+ <Button
392
+ variant="outline"
393
+ size="sm"
394
+ disabled={exporting || !u}
395
+ onClick={() => void onExportData()}
396
+ title="GDPR 用户数据导出(资料 / 成员关系 / 在途会话元数据 / PAT 元数据 / 最近审计事件)"
397
+ >
398
+ <Download /> {exporting ? '导出中…' : '导出数据'}
399
+ </Button>
400
+ <Link
401
+ to="/admin/users"
402
+ className="text-sm text-muted-foreground transition-colors hover:text-foreground"
403
+ >
404
+ <ArrowLeft size={15} className="mr-1 inline" aria-hidden="true" />
405
+ 返回用户列表
406
+ </Link>
407
+ </>
408
+ }
409
+ />
410
+
411
+ {detailQ.isPending && (
412
+ <p className="text-sm text-muted-foreground">加载用户详情中…</p>
413
+ )}
414
+
415
+ {u && (
416
+ <>
417
+ <SettingsCard
418
+ title="账号资料与安全操作"
419
+ description="状态、验证与登录会话的全局操作(全部记入平台审计)"
420
+ cta={
421
+ <Button
422
+ variant="destructive"
423
+ onClick={() => setKickOpen(true)}
424
+ disabled={kickM.isPending}
425
+ >
426
+ {kickM.isPending ? '踢下线中…' : '踢下线'}
427
+ </Button>
428
+ }
429
+ >
430
+ <div className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
431
+ <p className="flex items-center gap-2">
432
+ <span className="text-muted-foreground">状态:</span>
433
+ <StatusBadge status={u.status} />
434
+ </p>
435
+ <p className="flex items-center gap-2">
436
+ <span className="text-muted-foreground">邮箱验证:</span>
437
+ {u.email_verified ? (
438
+ <Badge variant="outline">已验证</Badge>
439
+ ) : (
440
+ <Badge variant="outline" className="text-muted-foreground">
441
+ 未验证
442
+ </Badge>
443
+ )}
444
+ </p>
445
+ <p className="flex items-center gap-2">
446
+ <span className="text-muted-foreground">平台管理员:</span>
447
+ {u.is_platform_admin ? (
448
+ <Badge variant="secondary">是</Badge>
449
+ ) : (
450
+ <span>否</span>
451
+ )}
452
+ </p>
453
+ <p>
454
+ <span className="text-muted-foreground">注册时间:</span>
455
+ {formatDateTime(u.created_at)}
456
+ </p>
457
+ <p>
458
+ <span className="text-muted-foreground">最近登录:</span>
459
+ {u.last_login_at ? formatDateTime(u.last_login_at) : '从未登录'}
460
+ </p>
461
+ </div>
462
+ <div className="mt-4 flex flex-wrap gap-2 border-t pt-4">
463
+ <Button
464
+ variant="outline"
465
+ size="sm"
466
+ disabled={resetPwdM.isPending}
467
+ onClick={async () => {
468
+ const ok = await confirm({
469
+ title: '发送密码重置邮件',
470
+ description: `将向 ${u.email} 发送密码重置邮件(走用户自助重置链路,不直接改密码)。`,
471
+ confirmText: '发送邮件',
472
+ });
473
+ if (ok) resetPwdM.mutate();
474
+ }}
475
+ >
476
+ {resetPwdM.isPending ? '发送中…' : '重置密码'}
477
+ </Button>
478
+ <Button
479
+ variant="outline"
480
+ size="sm"
481
+ disabled={resendM.isPending || u.email_verified}
482
+ onClick={async () => {
483
+ const ok = await confirm({
484
+ title: '重发验证邮件',
485
+ description: `将向 ${u.email} 补发邮箱验证邮件(1 分钟冷却)。`,
486
+ confirmText: '发送邮件',
487
+ });
488
+ if (ok) resendM.mutate();
489
+ }}
490
+ >
491
+ {resendM.isPending ? '发送中…' : '重发验证'}
492
+ </Button>
493
+ <Button
494
+ variant="outline"
495
+ size="sm"
496
+ disabled={markVerifiedM.isPending || u.email_verified}
497
+ onClick={async () => {
498
+ const ok = await confirm({
499
+ title: '标记邮箱已验证',
500
+ description:
501
+ '邮件链路不可达时的兜底:直接把该邮箱标记为已验证(幂等)。',
502
+ confirmText: '标记已验证',
503
+ });
504
+ if (ok) markVerifiedM.mutate();
505
+ }}
506
+ >
507
+ {markVerifiedM.isPending ? '标记中…' : '标记已验证'}
508
+ </Button>
509
+ </div>
510
+ </SettingsCard>
511
+
512
+ <SettingsCard
513
+ title={`在途会话(${d?.sessions.length ?? 0})`}
514
+ description="该用户当前有效的登录会话;可单设备吊销"
515
+ bodyClassName="p-0"
516
+ >
517
+ <div className="px-6 pb-6">
518
+ <DataTable
519
+ columns={sessionColumns}
520
+ rows={d?.sessions ?? []}
521
+ rowKey={(s) => s.id}
522
+ loading={detailQ.isPending}
523
+ empty={
524
+ <PageEmpty
525
+ label="没有在途会话"
526
+ hint="该用户当前没有任何活跃登录(或已被踢下线)"
527
+ />
528
+ }
529
+ />
530
+ </div>
531
+ </SettingsCard>
532
+
533
+ <SettingsCard
534
+ title={`访问令牌 PAT(${d?.pats.length ?? 0})`}
535
+ description="目标用户个人访问令牌的只读元数据(不含凭据材料)"
536
+ bodyClassName="p-0"
537
+ >
538
+ <div className="px-6 pb-6">
539
+ <DataTable
540
+ columns={patColumns}
541
+ rows={d?.pats ?? []}
542
+ rowKey={(p) => p.id}
543
+ loading={detailQ.isPending}
544
+ empty={<PageEmpty label="暂无访问令牌" />}
545
+ />
546
+ </div>
547
+ </SettingsCard>
548
+
549
+ <SettingsCard
550
+ title={`所属工作区(${d?.workspaces.length ?? 0})`}
551
+ description="该用户作为成员加入的工作区与角色"
552
+ bodyClassName="p-0"
553
+ >
554
+ <div className="px-6 pb-6">
555
+ <DataTable
556
+ columns={wsColumns}
557
+ rows={d?.workspaces ?? []}
558
+ rowKey={(w) => w.id}
559
+ loading={detailQ.isPending}
560
+ empty={
561
+ <PageEmpty
562
+ label="不属于任何工作区"
563
+ hint="工作区列表器未接线时也显示为空"
564
+ />
565
+ }
566
+ />
567
+ </div>
568
+ </SettingsCard>
569
+
570
+ <SettingsCard
571
+ danger
572
+ title="删除账号"
573
+ description="软删:status=deleted、邮箱改写、PAT 全吊销,不可恢复"
574
+ cta={
575
+ <Button
576
+ variant="destructive"
577
+ disabled={
578
+ !!deleteBlockedReason ||
579
+ !emailReadyToDelete ||
580
+ deleteM.isPending
581
+ }
582
+ onClick={() => setDeleteOpen(true)}
583
+ >
584
+ {deleteM.isPending ? '删除中…' : '删除账号'}
585
+ </Button>
586
+ }
587
+ >
588
+ <div className="max-w-xl space-y-2">
589
+ {deleteBlockedReason ? (
590
+ <p className="text-sm text-muted-foreground">
591
+ {deleteBlockedReason},无法在此删除。
592
+ </p>
593
+ ) : (
594
+ <p className="text-sm text-muted-foreground">
595
+ 输入目标邮箱 {u.email} 以确认删除。
596
+ </p>
597
+ )}
598
+ <Input
599
+ placeholder={u.email}
600
+ value={confirmEmail}
601
+ onChange={(e) => setConfirmEmail(e.target.value)}
602
+ aria-label="输入目标邮箱以确认删除"
603
+ disabled={!!deleteBlockedReason}
604
+ />
605
+ </div>
606
+ </SettingsCard>
607
+
608
+ {/* 删除账号:Input 邮箱确认(按钮门控)+ ImpactConfirmation 双层确认 */}
609
+ <ImpactConfirmation
610
+ open={deleteOpen}
611
+ onOpenChange={setDeleteOpen}
612
+ onConfirm={() => deleteM.mutate(u.email)}
613
+ title={`删除账号 ${u.email}`}
614
+ confirmText="永久删除"
615
+ danger
616
+ impacts={[
617
+ '账号进入 deleted 状态,邮箱被改写释放,无法恢复',
618
+ '该用户全部在途会话与个人访问令牌(PAT)立即吊销',
619
+ '名下工作区与业务数据保留,操作记入平台审计日志',
620
+ ]}
621
+ />
622
+
623
+ {/* 踢下线:影响确认(吊销全部在途会话,账号状态不变) */}
624
+ <ImpactConfirmation
625
+ open={kickOpen}
626
+ onOpenChange={setKickOpen}
627
+ onConfirm={() => kickM.mutate()}
628
+ title={`踢下线 ${u.email}`}
629
+ confirmText="踢下线"
630
+ danger
631
+ impacts={[
632
+ '该用户全部在途会话立即吊销,所有设备需重新登录',
633
+ '账号状态不变(不是封禁),重新登录即可恢复',
634
+ '操作将记入平台审计日志(admin.user_kick)',
635
+ ]}
636
+ />
637
+ </>
638
+ )}
639
+ </div>
640
+ );
641
+ }