@allbluecn/admin-app 0.3.1 → 0.3.3

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 (199) hide show
  1. package/package.json +19 -17
  2. package/src/components/admin/admin-user-menu.tsx +4 -4
  3. package/src/components/admin/developer-log.tsx +16 -32
  4. package/src/components/ai-memory/memory-facts-panel.tsx +30 -70
  5. package/src/components/ai-prompts/preview-panel.tsx +1 -1
  6. package/src/components/ai-prompts/prompt-editor.tsx +8 -9
  7. package/src/components/ai-prompts/prompt-table.tsx +6 -6
  8. package/src/components/ai-prompts/section-editor.tsx +3 -3
  9. package/src/components/ai-prompts/variable-panel.tsx +2 -2
  10. package/src/components/ai-prompts/version-history.tsx +21 -41
  11. package/src/components/ai-provider/model-list-editor.tsx +2 -3
  12. package/src/components/ai-provider/provider-form.tsx +7 -7
  13. package/src/components/ai-provider/provider-table.tsx +17 -40
  14. package/src/components/auth/admin-login-form.tsx +7 -7
  15. package/src/components/auth/icon-input.tsx +2 -2
  16. package/src/components/auth/password-input.tsx +2 -2
  17. package/src/components/component/component-form-dialog.tsx +10 -18
  18. package/src/components/component/component-import-dialog.tsx +6 -6
  19. package/src/components/component/component-preview.tsx +311 -0
  20. package/src/components/component/component-table.tsx +3 -3
  21. package/src/components/dependency/changelog-dialog.tsx +2 -2
  22. package/src/components/dependency/columns.tsx +3 -3
  23. package/src/components/dependency/dependency-table.tsx +7 -7
  24. package/src/components/dependency/edit-dialog.tsx +8 -8
  25. package/src/components/invitations/invitations-page.tsx +26 -49
  26. package/src/components/knowledge-public/bookmark-form-dialog.tsx +6 -6
  27. package/src/components/knowledge-public/compliance-form-dialog.tsx +6 -6
  28. package/src/components/knowledge-public/legal-form-dialog.tsx +7 -7
  29. package/src/components/layout/sidebar-02/app-sidebar.tsx +1 -2
  30. package/src/components/layout/sidebar-02/index.tsx +1 -1
  31. package/src/components/layout/sidebar-02/nav-main.tsx +2 -3
  32. package/src/components/layout/sidebar-02/nav-notifications.tsx +3 -3
  33. package/src/components/market/columns.tsx +4 -4
  34. package/src/components/market/market-app-form.tsx +6 -7
  35. package/src/components/market/market-app-list.tsx +14 -29
  36. package/src/components/monitoring/error-list.tsx +2 -2
  37. package/src/components/monitoring/metrics-stats.tsx +1 -1
  38. package/src/components/monitoring/request-log-table.tsx +2 -2
  39. package/src/components/oauth-providers/provider-page.tsx +20 -38
  40. package/src/components/permissions/assign-dialog.tsx +135 -0
  41. package/src/components/permissions/create-role-dialog.tsx +135 -0
  42. package/src/components/permissions/types.ts +49 -0
  43. package/src/components/user/columns.tsx +6 -6
  44. package/src/components/user/settings-activity-log.tsx +7 -7
  45. package/src/components/user/settings-profile/avatar-uploader.tsx +118 -0
  46. package/src/components/user/settings-profile/social-links.tsx +63 -0
  47. package/src/components/user/settings-profile.tsx +26 -125
  48. package/src/components/user/user-create-dialog.tsx +6 -6
  49. package/src/components/user/user-detail/error-boundary.tsx +19 -0
  50. package/src/components/user/user-detail/user-detail-dialogs.tsx +203 -0
  51. package/src/components/user/user-filters.tsx +4 -4
  52. package/src/lib/auth/page-gate.ts +28 -0
  53. package/src/lib/auth.config.ts +25 -9
  54. package/src/lib/i18n/detect.ts +1 -1
  55. package/src/lib/loader-serial.ts +14 -0
  56. package/src/paraglide/README.md +1 -1
  57. package/src/providers/index.tsx +1 -1
  58. package/src/routeTree.gen.ts +67 -78
  59. package/src/routes/__root.tsx +4 -4
  60. package/src/routes/_nav/audit/route.lazy.tsx +1 -2
  61. package/src/routes/_nav/compliance/route.lazy.tsx +5 -3
  62. package/src/routes/_nav/components/route.lazy.tsx +14 -34
  63. package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +18 -38
  64. package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +18 -38
  65. package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +6 -6
  66. package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +3 -3
  67. package/src/routes/_nav/licenses/new/route.lazy.tsx +5 -5
  68. package/src/routes/_nav/licenses/route.lazy.tsx +1 -1
  69. package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +2 -2
  70. package/src/routes/_nav/monitoring/errors/route.lazy.tsx +10 -10
  71. package/src/routes/_nav/monitoring/errors/route.tsx +7 -0
  72. package/src/routes/_nav/monitoring/health/route.lazy.tsx +13 -15
  73. package/src/routes/_nav/monitoring/health/route.tsx +14 -0
  74. package/src/routes/_nav/monitoring/logs/route.lazy.tsx +24 -9
  75. package/src/routes/_nav/monitoring/logs/route.tsx +13 -0
  76. package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +12 -14
  77. package/src/routes/_nav/monitoring/metrics/route.tsx +7 -0
  78. package/src/routes/_nav/monitoring/overview/route.lazy.tsx +14 -13
  79. package/src/routes/_nav/monitoring/overview/route.tsx +17 -0
  80. package/src/routes/_nav/monitoring/route.lazy.tsx +6 -6
  81. package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +2 -2
  82. package/src/routes/_nav/permissions/route.lazy.tsx +21 -311
  83. package/src/routes/_nav/permissions/route.tsx +1 -1
  84. package/src/routes/_nav/route.tsx +5 -13
  85. package/src/routes/_nav/sso/route.lazy.tsx +2 -3
  86. package/src/routes/_nav/users/$userId/route.lazy.tsx +91 -156
  87. package/src/routes/_nav/users/-components/users-table.tsx +1 -1
  88. package/src/routes/_nav/users/route.lazy.tsx +1 -1
  89. package/src/routes/login/route.lazy.tsx +1 -1
  90. package/src/routes/setup/route.lazy.tsx +2 -2
  91. package/src/server/__tests__/admin-billing.test.ts +191 -16
  92. package/src/server/__tests__/compliance.test.ts +44 -7
  93. package/src/server/__tests__/permissions.test.ts +10 -5
  94. package/src/server/__tests__/prompt-defaults.test.ts +3 -3
  95. package/src/server/admin-ai-alerts.ts +2 -1
  96. package/src/server/admin-ai-usage.ts +2 -1
  97. package/src/server/ai-memory/store.ts +6 -2
  98. package/src/server/{serverFns/ai-prompts → ai-prompts}/prompt-defaults.ts +5 -3
  99. package/src/server/{serverFns/dependency → dependency}/helpers.ts +4 -3
  100. package/src/server/knowledge-public/github-sync.ts +2 -1
  101. package/src/server/license-issue.ts +2 -1
  102. package/src/server/middlewares/auth-rate-limit.ts +117 -0
  103. package/src/server/observability/health-check.ts +4 -1
  104. package/src/server/observability/metrics.ts +17 -15
  105. package/src/server/observability/request-middleware.ts +2 -1
  106. package/src/server/observability/timing.ts +14 -16
  107. package/src/server/serverFns/admin-billing.ts +107 -12
  108. package/src/server/serverFns/ai-memory/list-users.ts +2 -1
  109. package/src/server/serverFns/ai-prompts/activate.ts +2 -1
  110. package/src/server/serverFns/ai-prompts/get.ts +3 -2
  111. package/src/server/serverFns/ai-prompts/list.ts +3 -2
  112. package/src/server/serverFns/ai-prompts/rollback.ts +3 -1
  113. package/src/server/serverFns/ai-prompts/save.ts +5 -3
  114. package/src/server/serverFns/ai-provider/create.ts +4 -1
  115. package/src/server/serverFns/ai-provider/delete.ts +4 -1
  116. package/src/server/serverFns/ai-provider/get.ts +4 -1
  117. package/src/server/serverFns/ai-provider/list.ts +28 -1
  118. package/src/server/serverFns/ai-provider/seed-from-seed.ts +2 -1
  119. package/src/server/serverFns/ai-provider/update.ts +4 -1
  120. package/src/server/serverFns/anyhub/market.ts +8 -1
  121. package/src/server/serverFns/audit.ts +14 -1
  122. package/src/server/serverFns/auth/setup.ts +3 -1
  123. package/src/server/serverFns/compliance.ts +32 -3
  124. package/src/server/serverFns/components/import.ts +69 -14
  125. package/src/server/serverFns/components/list.ts +4 -1
  126. package/src/server/serverFns/components/mutations.ts +9 -1
  127. package/src/server/serverFns/dependency/list.ts +28 -5
  128. package/src/server/serverFns/dependency/sync.ts +7 -2
  129. package/src/server/serverFns/dependency/update.ts +4 -1
  130. package/src/server/serverFns/developer-log-auto-generate.ts +63 -32
  131. package/src/server/serverFns/developer-logs.ts +5 -1
  132. package/src/server/serverFns/invitations.ts +7 -1
  133. package/src/server/serverFns/knowledge-public/bookmarks/create.ts +2 -1
  134. package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +2 -1
  135. package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +2 -1
  136. package/src/server/serverFns/knowledge-public/bookmarks/list.ts +2 -1
  137. package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +2 -1
  138. package/src/server/serverFns/knowledge-public/bookmarks/update.ts +2 -1
  139. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  140. package/src/server/serverFns/knowledge-public/compliance/delete.ts +2 -1
  141. package/src/server/serverFns/knowledge-public/compliance/detail.ts +2 -1
  142. package/src/server/serverFns/knowledge-public/compliance/list.ts +2 -1
  143. package/src/server/serverFns/knowledge-public/compliance/update.ts +2 -1
  144. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  145. package/src/server/serverFns/knowledge-public/legal/delete.ts +2 -1
  146. package/src/server/serverFns/knowledge-public/legal/detail.ts +2 -1
  147. package/src/server/serverFns/knowledge-public/legal/list.ts +2 -1
  148. package/src/server/serverFns/knowledge-public/legal/update.ts +2 -1
  149. package/src/server/serverFns/licenses.ts +8 -2
  150. package/src/server/serverFns/monitoring/get-errors.ts +2 -1
  151. package/src/server/serverFns/monitoring/get-health.ts +2 -1
  152. package/src/server/serverFns/monitoring/get-metrics.ts +2 -1
  153. package/src/server/serverFns/monitoring/get-request-logs.ts +2 -1
  154. package/src/server/serverFns/monitoring/report-error.ts +2 -1
  155. package/src/server/serverFns/monitoring/reset.ts +2 -1
  156. package/src/server/serverFns/oauth-providers.ts +4 -1
  157. package/src/server/serverFns/permissions.ts +18 -11
  158. package/src/server/serverFns/sentry/config.ts +11 -8
  159. package/src/server/serverFns/sso-config.ts +17 -2
  160. package/src/server/serverFns/user/create.ts +6 -1
  161. package/src/server/serverFns/user/delete.ts +6 -1
  162. package/src/server/serverFns/user/detail.ts +5 -2
  163. package/src/server/serverFns/user/list.ts +19 -4
  164. package/src/server/serverFns/user/reset-password.ts +10 -2
  165. package/src/server/serverFns/user/update-role.ts +10 -2
  166. package/src/server/serverFns/user/update-status.ts +10 -2
  167. package/src/styles/globals.css +4 -0
  168. package/src/components/data-table/data-table-pagination.tsx +0 -112
  169. package/src/components/data-table/data-table.tsx +0 -218
  170. package/src/components/data-table/table-detail-layout.tsx +0 -98
  171. package/src/components/ui/alert-dialog.tsx +0 -187
  172. package/src/components/ui/alert.tsx +0 -75
  173. package/src/components/ui/avatar.tsx +0 -106
  174. package/src/components/ui/badge.tsx +0 -51
  175. package/src/components/ui/button.tsx +0 -57
  176. package/src/components/ui/card.tsx +0 -102
  177. package/src/components/ui/checkbox.tsx +0 -28
  178. package/src/components/ui/collapsible.tsx +0 -19
  179. package/src/components/ui/dialog.tsx +0 -155
  180. package/src/components/ui/dropdown-menu.tsx +0 -267
  181. package/src/components/ui/field.tsx +0 -236
  182. package/src/components/ui/input-group.tsx +0 -158
  183. package/src/components/ui/input.tsx +0 -19
  184. package/src/components/ui/label.tsx +0 -17
  185. package/src/components/ui/pagination.tsx +0 -130
  186. package/src/components/ui/select.tsx +0 -234
  187. package/src/components/ui/separator.tsx +0 -22
  188. package/src/components/ui/sheet.tsx +0 -135
  189. package/src/components/ui/sidebar.tsx +0 -721
  190. package/src/components/ui/skeleton.tsx +0 -13
  191. package/src/components/ui/switch.tsx +0 -29
  192. package/src/components/ui/table.tsx +0 -115
  193. package/src/components/ui/textarea.tsx +0 -17
  194. package/src/components/ui/toast.tsx +0 -282
  195. package/src/components/ui/tooltip.tsx +0 -63
  196. package/src/hooks/use-mobile.ts +0 -19
  197. package/src/lib/avatar.ts +0 -41
  198. /package/src/routes/{api.auth.$.ts → api/auth/$.ts} +0 -0
  199. /package/src/routes/{api.sentry-test.ts → api/sentry-test.ts} +0 -0
@@ -15,20 +15,20 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { createLazyFileRoute } from '@tanstack/react-router'
18
+ import { createLazyFileRoute, useNavigate } from '@tanstack/react-router'
19
19
  import { m } from '@/paraglide/messages.js'
20
- import { useState, useTransition, useMemo } from 'react'
21
- import { formatTimestamp } from '@allbluecn/web-app/lib/datetime'
20
+ import { useState, useTransition } from 'react'
21
+ import { formatTimestamp } from '@allbluecn/ui/lib/datetime'
22
22
  import { getUserDetailFn } from '@/server/serverFns/user/detail'
23
23
  import { updateUserRoleFn } from '@/server/serverFns/user/update-role'
24
24
  import { updateUserStatusFn } from '@/server/serverFns/user/update-status'
25
25
  import { resetUserPasswordFn } from '@/server/serverFns/user/reset-password'
26
26
  import { deleteUserFn } from '@/server/serverFns/user/delete'
27
- import { TableDetailLayout } from '@/components/data-table/table-detail-layout'
28
- import { Badge } from '@/components/ui/badge'
29
- import { Separator } from '@/components/ui/separator'
30
- import { Button } from '@/components/ui/button'
31
- import { UserAvatar } from '@allbluecn/web-app/user-avatar'
27
+ import { TableDetailLayout } from '@allbluecn/ui/data-table/table-detail-layout'
28
+ import { Badge } from '@allbluecn/ui/badge'
29
+ import { Separator } from '@allbluecn/ui/separator'
30
+ import { Button } from '@allbluecn/ui/button'
31
+ import { UserAvatar } from '@allbluecn/ui/components/user-avatar'
32
32
 
33
33
  const STATUS_LABELS: Record<string, () => string> = {
34
34
  active: m['admin-users_status_active'],
@@ -43,39 +43,12 @@ const ROLE_LABELS: Record<string, () => string> = {
43
43
  user: m['admin-users_role_user'],
44
44
  }
45
45
  import {
46
- Dialog,
47
- DialogContent,
48
- DialogHeader,
49
- DialogTitle,
50
- DialogFooter,
51
- } from '@/components/ui/dialog'
52
- import {
53
- AlertDialog,
54
- AlertDialogAction,
55
- AlertDialogCancel,
56
- AlertDialogContent,
57
- AlertDialogDescription,
58
- AlertDialogFooter,
59
- AlertDialogHeader,
60
- AlertDialogTitle,
61
- } from '@/components/ui/alert-dialog'
62
- import {
63
- Select,
64
- SelectContent,
65
- SelectItem,
66
- SelectTrigger,
67
- SelectValue,
68
- } from '@/components/ui/select'
69
- import { Input } from '@/components/ui/input'
70
- import { Label } from '@/components/ui/label'
71
- import {
72
- AlertCircle,
73
46
  FileTextIcon,
74
47
  Package2Icon,
75
48
  ShoppingBagIcon,
76
49
  ShieldCheckIcon,
77
50
  } from 'lucide-react'
78
- import { toast } from '@/components/ui/toast'
51
+ import { toast } from '@allbluecn/ui/toast'
79
52
  import { parseUserAgent, formatParsedUA } from '@allbluecn/kernel'
80
53
  import type { AvatarConfig } from '@allbluecn/kernel'
81
54
  import SettingsActivityLog, {
@@ -88,7 +61,14 @@ import {
88
61
  CardDescription,
89
62
  CardHeader,
90
63
  CardTitle,
91
- } from '@/components/ui/card'
64
+ } from '@allbluecn/ui/card'
65
+ import { AdminUserErrorBoundary } from '@/components/user/user-detail/error-boundary'
66
+ import {
67
+ UserDeleteDialog,
68
+ UserPasswordDialog,
69
+ UserRoleDialog,
70
+ UserStatusDialog,
71
+ } from '@/components/user/user-detail/user-detail-dialogs'
92
72
 
93
73
  export const Route = createLazyFileRoute('/_nav/users/$userId')({
94
74
  pendingComponent: () => (
@@ -97,24 +77,18 @@ export const Route = createLazyFileRoute('/_nav/users/$userId')({
97
77
  </div>
98
78
  ),
99
79
  errorComponent: AdminUserErrorBoundary,
100
- component: UserDetailPage,
80
+ component: UserDetailRoute,
101
81
  })
102
82
 
103
- function AdminUserErrorBoundary({ error, reset }: { error: Error; reset: () => void }) {
104
- return <AdminUserErrorBoundaryInner error={error} reset={reset} />
105
- }
106
-
107
- function AdminUserErrorBoundaryInner({ error, reset }: { error: Error; reset: () => void }) {
108
- return (
109
- <div className="flex flex-col items-center justify-center h-64 gap-3">
110
- <AlertCircle className="size-8 text-destructive" />
111
- <p className="text-sm text-muted-foreground">{m['admin-users_detail_errorLoading']()}: {error.message}</p>
112
- <Button variant="outline" size="sm" onClick={reset}>{m['admin-users_detail_retry']()}</Button>
113
- </div>
114
- )
83
+ // route param($userId)变化时组件实例被复用,useState(data.user) 只读初始值,
84
+ // 用 key 强制重建避免残留上一用户的状态(删除用户后跳列表同理不受影响)
85
+ function UserDetailRoute() {
86
+ const { userId } = Route.useParams()
87
+ return <UserDetailPage key={userId} />
115
88
  }
116
89
 
117
90
  function UserDetailPage() {
91
+ const navigate = useNavigate()
118
92
  const statusBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
119
93
  ACTIVE: 'default',
120
94
  INACTIVE: 'secondary',
@@ -152,6 +126,7 @@ function UserDetailPage() {
152
126
  <>
153
127
  <TableDetailLayout
154
128
  backHref="/users"
129
+ backLabel={m.common_common_backToList()}
155
130
  title={user.username}
156
131
  subtitle={user.email}
157
132
  avatar={
@@ -233,7 +208,7 @@ function UserDetailPage() {
233
208
  <div className="flex items-center gap-2 rounded-lg border p-3">
234
209
  <FileTextIcon className="h-4 w-4 text-muted-foreground" />
235
210
  <div>
236
- <div className="text-lg font-semibold">{user._count.prds}</div>
211
+ <div className="text-lg font-semibold">{user._count.designs}</div>
237
212
  <div className="text-xs text-muted-foreground">{m['admin-users_detail_stats_prds']()}</div>
238
213
  </div>
239
214
  </div>
@@ -323,114 +298,74 @@ function UserDetailPage() {
323
298
  </div>
324
299
  </TableDetailLayout>
325
300
 
326
- <Dialog open={roleDialog.open} onOpenChange={(o: boolean) => setRoleDialog((d) => ({ ...d, open: o }))}>
327
- <DialogContent className="sm:max-w-100">
328
- <DialogHeader>
329
- <DialogTitle>{m['admin-users_dialog_role_title']({ username: user.username })}</DialogTitle>
330
- </DialogHeader>
331
- <div className="py-4">
332
- <Label>{m['admin-users_dialog_role_newRole']()}</Label>
333
- <Select value={roleDialog.value} onValueChange={(v) => setRoleDialog((d) => ({ ...d, value: v ?? "" }))}>
334
- <SelectTrigger><SelectValue /></SelectTrigger>
335
- <SelectContent>
336
- <SelectItem value="USER">{m['admin-users_role_user']()}</SelectItem>
337
- <SelectItem value="ADMIN">{m['admin-users_role_admin']()}</SelectItem>
338
- <SelectItem value="SUPER_ADMIN">{m['admin-users_role_superAdmin']()}</SelectItem>
339
- </SelectContent>
340
- </Select>
341
- </div>
342
- <DialogFooter>
343
- <Button variant="outline" onClick={() => setRoleDialog((d) => ({ ...d, open: false }))}>{m.common_button_cancel()}</Button>
344
- <Button disabled={isPending} onClick={() => startTransition(async () => {
345
- try {
346
- await updateUserRoleFn({ data: { userId: user.id, role: roleDialog.value as 'USER' | 'ADMIN' | 'SUPER_ADMIN' } })
347
- toast.success(m['admin-users_toast_roleUpdated']())
348
- setRoleDialog((d) => ({ ...d, open: false }))
349
- await refetchUser()
350
- } catch { toast.error(m['admin-users_toast_updateFailed']()) }
351
- })}>{m.common_button_confirm()}</Button>
352
- </DialogFooter>
353
- </DialogContent>
354
- </Dialog>
301
+ <UserRoleDialog
302
+ open={roleDialog.open}
303
+ value={roleDialog.value}
304
+ username={user.username}
305
+ isPending={isPending}
306
+ onOpenChange={(o) => setRoleDialog((d) => ({ ...d, open: o }))}
307
+ onValueChange={(v) => setRoleDialog((d) => ({ ...d, value: v }))}
308
+ onCancel={() => setRoleDialog((d) => ({ ...d, open: false }))}
309
+ onSubmit={() => startTransition(async () => {
310
+ try {
311
+ await updateUserRoleFn({ data: { userId: user.id, role: roleDialog.value as 'USER' | 'ADMIN' | 'SUPER_ADMIN' } })
312
+ toast.success(m['admin-users_toast_roleUpdated']())
313
+ setRoleDialog((d) => ({ ...d, open: false }))
314
+ await refetchUser()
315
+ } catch { toast.error(m['admin-users_toast_updateFailed']()) }
316
+ })}
317
+ />
355
318
 
356
- <Dialog open={statusDialog.open} onOpenChange={(o: boolean) => setStatusDialog((d) => ({ ...d, open: o }))}>
357
- <DialogContent className="sm:max-w-100">
358
- <DialogHeader>
359
- <DialogTitle>{m['admin-users_dialog_status_title']({ username: user.username })}</DialogTitle>
360
- </DialogHeader>
361
- <div className="py-4">
362
- <Label>{m['admin-users_dialog_status_newStatus']()}</Label>
363
- <Select value={statusDialog.value} onValueChange={(v) => setStatusDialog((d) => ({ ...d, value: v ?? "" }))}>
364
- <SelectTrigger><SelectValue /></SelectTrigger>
365
- <SelectContent>
366
- <SelectItem value="ACTIVE">{m['admin-users_status_active']()}</SelectItem>
367
- <SelectItem value="INACTIVE">{m['admin-users_status_inactive']()}</SelectItem>
368
- <SelectItem value="BANNED">{m['admin-users_status_banned']()}</SelectItem>
369
- </SelectContent>
370
- </Select>
371
- </div>
372
- <DialogFooter>
373
- <Button variant="outline" onClick={() => setStatusDialog((d) => ({ ...d, open: false }))}>{m.common_button_cancel()}</Button>
374
- <Button disabled={isPending} onClick={() => startTransition(async () => {
375
- try {
376
- await updateUserStatusFn({ data: { userId: user.id, status: statusDialog.value as 'ACTIVE' | 'INACTIVE' | 'BANNED' } })
377
- toast.success(m['admin-users_toast_statusUpdated']())
378
- setStatusDialog((d) => ({ ...d, open: false }))
379
- await refetchUser()
380
- } catch { toast.error(m['admin-users_toast_updateFailed']()) }
381
- })}>{m.common_button_confirm()}</Button>
382
- </DialogFooter>
383
- </DialogContent>
384
- </Dialog>
319
+ <UserStatusDialog
320
+ open={statusDialog.open}
321
+ value={statusDialog.value}
322
+ username={user.username}
323
+ isPending={isPending}
324
+ onOpenChange={(o) => setStatusDialog((d) => ({ ...d, open: o }))}
325
+ onValueChange={(v) => setStatusDialog((d) => ({ ...d, value: v }))}
326
+ onCancel={() => setStatusDialog((d) => ({ ...d, open: false }))}
327
+ onSubmit={() => startTransition(async () => {
328
+ try {
329
+ await updateUserStatusFn({ data: { userId: user.id, status: statusDialog.value as 'ACTIVE' | 'INACTIVE' | 'BANNED' } })
330
+ toast.success(m['admin-users_toast_statusUpdated']())
331
+ setStatusDialog((d) => ({ ...d, open: false }))
332
+ await refetchUser()
333
+ } catch { toast.error(m['admin-users_toast_updateFailed']()) }
334
+ })}
335
+ />
385
336
 
386
- <Dialog open={passwordDialog.open} onOpenChange={(o: boolean) => setPasswordDialog((d) => ({ ...d, open: o }))}>
387
- <DialogContent className="sm:max-w-100">
388
- <DialogHeader>
389
- <DialogTitle>{m['admin-users_dialog_password_title']({ username: user.username })}</DialogTitle>
390
- </DialogHeader>
391
- <form onSubmit={(e) => { e.preventDefault(); startTransition(async () => {
392
- try {
393
- await resetUserPasswordFn({ data: { userId: user.id, newPassword: passwordDialog.value } })
394
- toast.success(m['admin-users_toast_passwordReset']())
395
- setPasswordDialog((d) => ({ ...d, open: false, value: '' }))
396
- } catch { toast.error(m['admin-users_toast_resetFailed']()) }
397
- })}}>
398
- <div className="py-4">
399
- <Label>{m['admin-users_dialog_password_newPassword']()}</Label>
400
- <Input type="password" required value={passwordDialog.value} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordDialog((d) => ({ ...d, value: e.target.value }))} />
401
- </div>
402
- <DialogFooter>
403
- <Button type="button" variant="outline" onClick={() => setPasswordDialog((d) => ({ ...d, open: false }))}>{m.common_button_cancel()}</Button>
404
- <Button type="submit" disabled={isPending}>{m['admin-users_dialog_password_confirm']()}</Button>
405
- </DialogFooter>
406
- </form>
407
- </DialogContent>
408
- </Dialog>
337
+ <UserPasswordDialog
338
+ open={passwordDialog.open}
339
+ value={passwordDialog.value}
340
+ username={user.username}
341
+ isPending={isPending}
342
+ onOpenChange={(o) => setPasswordDialog((d) => ({ ...d, open: o }))}
343
+ onValueChange={(v) => setPasswordDialog((d) => ({ ...d, value: v }))}
344
+ onCancel={() => setPasswordDialog((d) => ({ ...d, open: false }))}
345
+ onSubmit={() => startTransition(async () => {
346
+ try {
347
+ await resetUserPasswordFn({ data: { userId: user.id, newPassword: passwordDialog.value } })
348
+ toast.success(m['admin-users_toast_passwordReset']())
349
+ setPasswordDialog((d) => ({ ...d, open: false, value: '' }))
350
+ } catch { toast.error(m['admin-users_toast_resetFailed']()) }
351
+ })}
352
+ />
409
353
 
410
- <AlertDialog open={deleteDialog.open} onOpenChange={(o: boolean) => setDeleteDialog((d) => ({ ...d, open: o }))}>
411
- <AlertDialogContent>
412
- <AlertDialogHeader>
413
- <AlertDialogTitle>{m['admin-users_dialog_delete_title']()}</AlertDialogTitle>
414
- <AlertDialogDescription>
415
- {m['admin-users_dialog_delete_description']({ username: user.username })}
416
- </AlertDialogDescription>
417
- </AlertDialogHeader>
418
- <Input placeholder={m['admin-users_dialog_delete_placeholder']()} value={deleteDialog.confirmName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDeleteDialog((d) => ({ ...d, confirmName: e.target.value }))} />
419
- <AlertDialogFooter>
420
- <AlertDialogCancel>{m.common_button_cancel()}</AlertDialogCancel>
421
- <AlertDialogAction
422
- disabled={deleteDialog.confirmName !== user.username || isPending}
423
- onClick={() => startTransition(async () => {
424
- try {
425
- await deleteUserFn({ data: { userId: user.id } })
426
- toast.success(m['admin-users_toast_userDeleted']())
427
- window.location.href = '/users'
428
- } catch { toast.error(m['admin-users_toast_deleteFailed']()) }
429
- })}
430
- >{m['admin-users_dialog_delete_confirm']()}</AlertDialogAction>
431
- </AlertDialogFooter>
432
- </AlertDialogContent>
433
- </AlertDialog>
354
+ <UserDeleteDialog
355
+ open={deleteDialog.open}
356
+ confirmName={deleteDialog.confirmName}
357
+ username={user.username}
358
+ isPending={isPending}
359
+ onOpenChange={(o) => setDeleteDialog((d) => ({ ...d, open: o }))}
360
+ onConfirmNameChange={(v) => setDeleteDialog((d) => ({ ...d, confirmName: v }))}
361
+ onSubmit={() => startTransition(async () => {
362
+ try {
363
+ await deleteUserFn({ data: { userId: user.id } })
364
+ toast.success(m['admin-users_toast_userDeleted']())
365
+ navigate({ to: '/users' })
366
+ } catch { toast.error(m['admin-users_toast_deleteFailed']()) }
367
+ })}
368
+ />
434
369
  </>
435
370
  )
436
371
  }
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
 
19
- import { DataTable } from "@/components/data-table/data-table"
19
+ import { DataTable } from "@allbluecn/ui/data-table"
20
20
  import { getUserColumns } from "@/components/user/columns"
21
21
  import type { UserRow } from "@/components/user/columns"
22
22
  import { useNavigate } from "@tanstack/react-router"
@@ -21,7 +21,7 @@ import { useState } from 'react'
21
21
  import { UsersTable } from './-components/users-table'
22
22
  import { UserFilters } from '@/components/user/user-filters'
23
23
  import { UserCreateDialog } from '@/components/user/user-create-dialog'
24
- import { Button } from '@/components/ui/button'
24
+ import { Button } from '@allbluecn/ui/button'
25
25
  import { PlusIcon } from 'lucide-react'
26
26
 
27
27
  export const Route = createLazyFileRoute('/_nav/users')({
@@ -18,7 +18,7 @@
18
18
  import { createLazyFileRoute } from '@tanstack/react-router'
19
19
  import AdminLoginForm from '@/components/auth/admin-login-form'
20
20
  import { useSession } from '@/lib/auth-client'
21
- import type { AvatarConfig, ShapeType } from '@allbluecn/kernel'
21
+ import type { AvatarConfig } from '@allbluecn/kernel'
22
22
 
23
23
  export const Route = createLazyFileRoute('/login')({
24
24
  component: AdminLoginPage,
@@ -18,14 +18,14 @@
18
18
  import { createLazyFileRoute, useNavigate } from '@tanstack/react-router'
19
19
  import { m } from '@/paraglide/messages.js'
20
20
  import { useState } from 'react'
21
- import { toast } from '@/components/ui/toast'
21
+ import { toast } from '@allbluecn/ui/toast'
22
22
  import { Loader2, ShieldCheck, User, Mail, Database } from 'lucide-react'
23
23
  import { AuthLayout, AuthLayoutLeft, AuthLayoutRight } from '@/components/auth/auth-layout'
24
24
  import { AuthHeader } from '@/components/auth/auth-header'
25
25
  import { AuthBrandPanel } from '@/components/auth/auth-brand-panel'
26
26
  import { PasswordInput } from '@/components/auth/password-input'
27
27
  import { IconInput } from '@/components/auth/icon-input'
28
- import { Button } from '@/components/ui/button'
28
+ import { Button } from '@allbluecn/ui/button'
29
29
  import { setupAdminFn } from '@/server/serverFns/auth/setup'
30
30
 
31
31
  export const Route = createLazyFileRoute('/setup')({
@@ -99,29 +99,92 @@ describe('listAllSubscriptionsFn', () => {
99
99
  expect(subscription.count).not.toHaveBeenCalled()
100
100
  })
101
101
 
102
- it('默认分页:page=1 pageSize=20 时 skip=0 take=20', async () => {
102
+ it('默认分页:page=1 pageSize=20 时 skip=0 take=20,白名单 select', async () => {
103
+ subscription.findMany.mockResolvedValue([])
104
+
103
105
  await listAllSubscriptionsFn({ data: {} })
104
106
 
105
107
  expect(subscription.findMany).toHaveBeenCalledWith({
106
108
  where: {},
107
- include: { plan: true, workspace: true },
108
109
  orderBy: { createdAt: 'desc' },
109
110
  skip: 0,
110
111
  take: 20,
112
+ select: {
113
+ id: true,
114
+ status: true,
115
+ addonSeats: true,
116
+ currentPeriodEnd: true,
117
+ plan: { select: { name: true, maxSeats: true } },
118
+ user: { select: { name: true, email: true } },
119
+ },
111
120
  })
121
+ expect(subscription.count).toHaveBeenCalledWith({ where: {} })
112
122
  })
113
123
 
114
- it('search 按工作空间名模糊匹配(管理员全局检索入口)', async () => {
124
+ it('DB-05 shape 映射:workspace.name 由 user 映射、seats=maxSeats+addonSeats', async () => {
125
+ subscription.findMany.mockResolvedValue([
126
+ {
127
+ id: 'sub-1',
128
+ status: 'ACTIVE',
129
+ addonSeats: 2,
130
+ currentPeriodEnd: new Date('2026-12-01T00:00:00Z'),
131
+ plan: { name: 'Pro', maxSeats: 5 },
132
+ user: { name: 'Alice', email: 'alice@example.com' },
133
+ },
134
+ {
135
+ id: 'sub-2',
136
+ status: 'TRIALING',
137
+ addonSeats: 0,
138
+ currentPeriodEnd: null,
139
+ plan: null,
140
+ user: null,
141
+ },
142
+ ])
143
+
144
+ const res = await listAllSubscriptionsFn({ data: {} })
145
+
146
+ expect(res.items[0]).toEqual({
147
+ id: 'sub-1',
148
+ workspace: { name: 'Alice' },
149
+ plan: { name: 'Pro' },
150
+ status: 'ACTIVE',
151
+ seats: 7,
152
+ currentPeriodEnd: new Date('2026-12-01T00:00:00Z'),
153
+ })
154
+ // null plan/user 不崩,回退空值
155
+ expect(res.items[1]).toEqual({
156
+ id: 'sub-2',
157
+ workspace: { name: null },
158
+ plan: { name: null },
159
+ status: 'TRIALING',
160
+ seats: 0,
161
+ currentPeriodEnd: null,
162
+ })
163
+ })
164
+
165
+ it('search 按订阅属主 user name/email 模糊匹配(workspace 关系不存在)', async () => {
166
+ subscription.findMany.mockResolvedValue([])
167
+
115
168
  await listAllSubscriptionsFn({ data: { search: 'acme' } })
116
169
 
117
170
  const arg = subscription.findMany.mock.calls[0][0]
118
- expect(arg.where.OR).toEqual([{ workspace: { name: { contains: 'acme' } } }])
171
+ expect(arg.where.OR).toEqual([
172
+ { user: { name: { contains: 'acme' } } },
173
+ { user: { email: { contains: 'acme' } } },
174
+ ])
119
175
  expect(subscription.count).toHaveBeenCalledWith({
120
- where: { OR: [{ workspace: { name: { contains: 'acme' } } }] },
176
+ where: {
177
+ OR: [
178
+ { user: { name: { contains: 'acme' } } },
179
+ { user: { email: { contains: 'acme' } } },
180
+ ],
181
+ },
121
182
  })
122
183
  })
123
184
 
124
185
  it('plan 与 status 组合过滤同时生效', async () => {
186
+ subscription.findMany.mockResolvedValue([])
187
+
125
188
  await listAllSubscriptionsFn({ data: { plan: 'pro', status: 'active' } })
126
189
 
127
190
  expect(subscription.findMany.mock.calls[0][0].where).toEqual({
@@ -131,6 +194,8 @@ describe('listAllSubscriptionsFn', () => {
131
194
  })
132
195
 
133
196
  it('分页偏移正确计算(page=3 pageSize=10 → skip=20)', async () => {
197
+ subscription.findMany.mockResolvedValue([])
198
+
134
199
  await listAllSubscriptionsFn({ data: { page: 3, pageSize: 10 } })
135
200
 
136
201
  const arg = subscription.findMany.mock.calls[0][0]
@@ -140,20 +205,60 @@ describe('listAllSubscriptionsFn', () => {
140
205
  })
141
206
 
142
207
  describe('getSubscriptionDetailFn', () => {
143
- it('按 subscriptionId 查详情并附带发票列表', async () => {
144
- subscription.findUnique.mockResolvedValue({ id: 'sub-1' })
208
+ it('按 subscriptionId 查详情:白名单 select + workspace/seats 映射', async () => {
209
+ subscription.findUnique.mockResolvedValue({
210
+ id: 'sub-1',
211
+ status: 'ACTIVE',
212
+ addonSeats: 1,
213
+ currentPeriodStart: new Date('2026-11-01T00:00:00Z'),
214
+ currentPeriodEnd: new Date('2026-12-01T00:00:00Z'),
215
+ providerSubscriptionId: 'cus_pro_sub_123',
216
+ plan: { id: 'plan-1', name: 'Pro', displayName: 'Pro', maxSeats: 5 },
217
+ user: { id: 'u-1', name: 'Alice', email: 'alice@example.com' },
218
+ invoices: [],
219
+ })
145
220
 
146
221
  const res = await getSubscriptionDetailFn({ data: { subscriptionId: 'sub-1' } })
147
222
 
148
223
  expect(subscription.findUnique).toHaveBeenCalledWith({
149
224
  where: { id: 'sub-1' },
150
- include: {
151
- plan: true,
152
- workspace: true,
153
- invoices: { orderBy: { createdAt: 'desc' } },
225
+ select: {
226
+ id: true,
227
+ status: true,
228
+ addonSeats: true,
229
+ currentPeriodStart: true,
230
+ currentPeriodEnd: true,
231
+ providerSubscriptionId: true,
232
+ plan: { select: { id: true, name: true, displayName: true, maxSeats: true } },
233
+ user: { select: { id: true, name: true, email: true } },
234
+ invoices: {
235
+ orderBy: { createdAt: 'desc' },
236
+ select: {
237
+ id: true,
238
+ status: true,
239
+ currency: true,
240
+ amountDue: true,
241
+ periodStart: true,
242
+ periodEnd: true,
243
+ createdAt: true,
244
+ },
245
+ },
154
246
  },
155
247
  })
156
- expect(res).toEqual({ id: 'sub-1' })
248
+ expect(res).toMatchObject({
249
+ id: 'sub-1',
250
+ workspace: { name: 'Alice' },
251
+ seats: 6,
252
+ providerSubscriptionId: 'cus_pro_sub_123',
253
+ })
254
+ })
255
+
256
+ it('订阅不存在 → 返回 null', async () => {
257
+ subscription.findUnique.mockResolvedValue(null)
258
+
259
+ const res = await getSubscriptionDetailFn({ data: { subscriptionId: 'nope' } })
260
+
261
+ expect(res).toBeNull()
157
262
  })
158
263
 
159
264
  it('非管理员拒绝:不触达 subscription 查询', async () => {
@@ -167,16 +272,86 @@ describe('getSubscriptionDetailFn', () => {
167
272
  })
168
273
 
169
274
  describe('listAllInvoicesFn', () => {
170
- it('按 createdAt 降序分页并附带订阅归属信息', async () => {
275
+ it('按 createdAt 降序分页 + 白名单 select + count 携带 where', async () => {
276
+ invoice.findMany.mockResolvedValue([])
277
+
171
278
  await listAllInvoicesFn({ data: { page: 2, pageSize: 5 } })
172
279
 
173
280
  expect(invoice.findMany).toHaveBeenCalledWith({
281
+ where: {},
174
282
  orderBy: { createdAt: 'desc' },
175
- include: { subscription: { include: { workspace: true, plan: true } } },
176
283
  skip: 5,
177
284
  take: 5,
285
+ select: {
286
+ id: true,
287
+ status: true,
288
+ currency: true,
289
+ amountDue: true,
290
+ amountPaid: true,
291
+ periodStart: true,
292
+ periodEnd: true,
293
+ createdAt: true,
294
+ subscription: {
295
+ select: {
296
+ id: true,
297
+ plan: { select: { name: true } },
298
+ user: { select: { name: true, email: true } },
299
+ },
300
+ },
301
+ },
302
+ })
303
+ // DB-02:count 与列表共享同一 where
304
+ expect(invoice.count).toHaveBeenCalledWith({ where: {} })
305
+ })
306
+
307
+ it('DB-02:status 过滤参数化,where 同时作用于 findMany 与 count', async () => {
308
+ invoice.findMany.mockResolvedValue([])
309
+
310
+ await listAllInvoicesFn({ data: { page: 1, pageSize: 20, status: 'paid' } })
311
+
312
+ const arg = invoice.findMany.mock.calls[0][0]
313
+ expect(arg.where).toEqual({ status: 'paid' })
314
+ expect(invoice.count).toHaveBeenCalledWith({ where: { status: 'paid' } })
315
+ })
316
+
317
+ it('DB-05 shape 映射:subscription.workspace 由 user 回填,键位不变', async () => {
318
+ invoice.findMany.mockResolvedValue([
319
+ {
320
+ id: 'inv-1',
321
+ status: 'paid',
322
+ currency: 'usd',
323
+ amountDue: 2000,
324
+ amountPaid: 2000,
325
+ periodStart: new Date('2026-11-01T00:00:00Z'),
326
+ periodEnd: new Date('2026-12-01T00:00:00Z'),
327
+ createdAt: new Date('2026-11-01T00:00:00Z'),
328
+ subscription: {
329
+ id: 'sub-1',
330
+ plan: { name: 'Pro' },
331
+ user: { name: null, email: 'alice@example.com' },
332
+ },
333
+ },
334
+ {
335
+ id: 'inv-2',
336
+ status: 'open',
337
+ currency: 'usd',
338
+ amountDue: 1000,
339
+ amountPaid: 0,
340
+ periodStart: new Date('2026-10-01T00:00:00Z'),
341
+ periodEnd: new Date('2026-11-01T00:00:00Z'),
342
+ createdAt: new Date('2026-10-01T00:00:00Z'),
343
+ subscription: null,
344
+ },
345
+ ])
346
+
347
+ const res = await listAllInvoicesFn({ data: {} })
348
+
349
+ expect(res.items[0].subscription).toEqual({
350
+ id: 'sub-1',
351
+ workspace: { name: 'alice@example.com' },
352
+ plan: { name: 'Pro' },
178
353
  })
179
- // 发票总数不受分页影响
180
- expect(invoice.count).toHaveBeenCalledWith()
354
+ expect(res.items[1].subscription).toBeNull()
355
+ expect(res.total).toBe(0)
181
356
  })
182
357
  })