@allbluecn/admin-app 0.3.2 → 0.3.4

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 (209) hide show
  1. package/allblue-ui/package.json +6 -0
  2. package/allblue-ui/src/components/confirm-dialog.tsx +94 -0
  3. package/allblue-ui/src/components/infinite-scroll-sentinel.tsx +37 -0
  4. package/allblue-ui/src/components/notion-avatar.tsx +126 -0
  5. package/allblue-ui/src/components/user-avatar.tsx +82 -0
  6. package/{src/components → allblue-ui/src}/data-table/data-table-pagination.tsx +14 -5
  7. package/{src/components → allblue-ui/src}/data-table/data-table.tsx +1 -1
  8. package/{src/components → allblue-ui/src}/data-table/table-detail-layout.tsx +5 -6
  9. package/allblue-ui/src/hooks/use-load-more.ts +57 -0
  10. package/allblue-ui/src/index.ts +31 -0
  11. package/allblue-ui/src/lib/datetime.ts +226 -0
  12. package/allblue-ui/src/lib/sanitize.ts +18 -0
  13. package/allblue-ui/src/lib/utils.ts +1 -0
  14. package/{src/components → allblue-ui/src}/ui/alert-dialog.tsx +1 -3
  15. package/{src/components → allblue-ui/src}/ui/checkbox.tsx +0 -1
  16. package/{src/components → allblue-ui/src}/ui/dialog.tsx +1 -1
  17. package/{src/components → allblue-ui/src}/ui/dropdown-menu.tsx +0 -2
  18. package/{src/components → allblue-ui/src}/ui/field.tsx +2 -2
  19. package/{src/components → allblue-ui/src}/ui/input-group.tsx +3 -4
  20. package/allblue-ui/src/ui/input.tsx +19 -0
  21. package/{src/components → allblue-ui/src}/ui/pagination.tsx +1 -1
  22. package/{src/components → allblue-ui/src}/ui/select.tsx +0 -2
  23. package/{src/components → allblue-ui/src}/ui/separator.tsx +1 -0
  24. package/{src/components → allblue-ui/src}/ui/sheet.tsx +1 -2
  25. package/{src/components → allblue-ui/src}/ui/sidebar.tsx +7 -7
  26. package/{src/components → allblue-ui/src}/ui/switch.tsx +1 -0
  27. package/{src/components → allblue-ui/src}/ui/table.tsx +0 -2
  28. package/{src/components → allblue-ui/src}/ui/toast.tsx +44 -18
  29. package/{src/components → allblue-ui/src}/ui/tooltip.tsx +1 -0
  30. package/package.json +21 -18
  31. package/src/components/admin/admin-user-menu.tsx +4 -4
  32. package/src/components/admin/developer-log.tsx +16 -32
  33. package/src/components/ai-memory/memory-facts-panel.tsx +30 -70
  34. package/src/components/ai-prompts/preview-panel.tsx +1 -1
  35. package/src/components/ai-prompts/prompt-editor.tsx +8 -9
  36. package/src/components/ai-prompts/prompt-table.tsx +6 -6
  37. package/src/components/ai-prompts/section-editor.tsx +3 -3
  38. package/src/components/ai-prompts/variable-panel.tsx +2 -2
  39. package/src/components/ai-prompts/version-history.tsx +21 -41
  40. package/src/components/ai-provider/model-list-editor.tsx +2 -3
  41. package/src/components/ai-provider/provider-form.tsx +7 -7
  42. package/src/components/ai-provider/provider-table.tsx +17 -40
  43. package/src/components/auth/admin-login-form.tsx +7 -7
  44. package/src/components/auth/icon-input.tsx +2 -2
  45. package/src/components/auth/password-input.tsx +2 -2
  46. package/src/components/component/component-form-dialog.tsx +10 -18
  47. package/src/components/component/component-import-dialog.tsx +6 -6
  48. package/src/components/component/component-preview.tsx +311 -0
  49. package/src/components/component/component-table.tsx +3 -3
  50. package/src/components/dependency/changelog-dialog.tsx +2 -2
  51. package/src/components/dependency/columns.tsx +3 -3
  52. package/src/components/dependency/dependency-table.tsx +7 -7
  53. package/src/components/dependency/edit-dialog.tsx +8 -8
  54. package/src/components/invitations/invitations-page.tsx +26 -49
  55. package/src/components/knowledge-public/bookmark-form-dialog.tsx +6 -6
  56. package/src/components/knowledge-public/compliance-form-dialog.tsx +6 -6
  57. package/src/components/knowledge-public/legal-form-dialog.tsx +7 -7
  58. package/src/components/layout/sidebar-02/app-sidebar.tsx +1 -2
  59. package/src/components/layout/sidebar-02/index.tsx +1 -1
  60. package/src/components/layout/sidebar-02/nav-main.tsx +2 -3
  61. package/src/components/layout/sidebar-02/nav-notifications.tsx +3 -3
  62. package/src/components/market/columns.tsx +4 -4
  63. package/src/components/market/market-app-form.tsx +6 -7
  64. package/src/components/market/market-app-list.tsx +14 -29
  65. package/src/components/monitoring/error-list.tsx +2 -2
  66. package/src/components/monitoring/metrics-stats.tsx +1 -1
  67. package/src/components/monitoring/request-log-table.tsx +2 -2
  68. package/src/components/oauth-providers/provider-page.tsx +20 -38
  69. package/src/components/permissions/assign-dialog.tsx +135 -0
  70. package/src/components/permissions/create-role-dialog.tsx +135 -0
  71. package/src/components/permissions/types.ts +49 -0
  72. package/src/components/user/columns.tsx +6 -6
  73. package/src/components/user/settings-activity-log.tsx +7 -7
  74. package/src/components/user/settings-profile/avatar-uploader.tsx +118 -0
  75. package/src/components/user/settings-profile/social-links.tsx +63 -0
  76. package/src/components/user/settings-profile.tsx +26 -125
  77. package/src/components/user/user-create-dialog.tsx +6 -6
  78. package/src/components/user/user-detail/error-boundary.tsx +19 -0
  79. package/src/components/user/user-detail/user-detail-dialogs.tsx +203 -0
  80. package/src/components/user/user-filters.tsx +4 -4
  81. package/src/lib/auth/page-gate.ts +28 -0
  82. package/src/lib/auth.config.ts +25 -9
  83. package/src/lib/i18n/detect.ts +1 -1
  84. package/src/lib/loader-serial.ts +14 -0
  85. package/src/providers/index.tsx +1 -1
  86. package/src/routeTree.gen.ts +67 -78
  87. package/src/routes/__root.tsx +4 -4
  88. package/src/routes/_nav/audit/route.lazy.tsx +1 -2
  89. package/src/routes/_nav/compliance/route.lazy.tsx +5 -3
  90. package/src/routes/_nav/components/route.lazy.tsx +14 -34
  91. package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +18 -38
  92. package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +18 -38
  93. package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +6 -6
  94. package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +3 -3
  95. package/src/routes/_nav/licenses/new/route.lazy.tsx +5 -5
  96. package/src/routes/_nav/licenses/route.lazy.tsx +1 -1
  97. package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +2 -2
  98. package/src/routes/_nav/monitoring/errors/route.lazy.tsx +10 -10
  99. package/src/routes/_nav/monitoring/errors/route.tsx +7 -0
  100. package/src/routes/_nav/monitoring/health/route.lazy.tsx +13 -15
  101. package/src/routes/_nav/monitoring/health/route.tsx +14 -0
  102. package/src/routes/_nav/monitoring/logs/route.lazy.tsx +24 -9
  103. package/src/routes/_nav/monitoring/logs/route.tsx +13 -0
  104. package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +12 -14
  105. package/src/routes/_nav/monitoring/metrics/route.tsx +7 -0
  106. package/src/routes/_nav/monitoring/overview/route.lazy.tsx +14 -13
  107. package/src/routes/_nav/monitoring/overview/route.tsx +17 -0
  108. package/src/routes/_nav/monitoring/route.lazy.tsx +6 -6
  109. package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +2 -2
  110. package/src/routes/_nav/permissions/route.lazy.tsx +21 -311
  111. package/src/routes/_nav/permissions/route.tsx +1 -1
  112. package/src/routes/_nav/route.tsx +5 -13
  113. package/src/routes/_nav/sso/route.lazy.tsx +2 -3
  114. package/src/routes/_nav/users/$userId/route.lazy.tsx +91 -156
  115. package/src/routes/_nav/users/-components/users-table.tsx +1 -1
  116. package/src/routes/_nav/users/route.lazy.tsx +1 -1
  117. package/src/routes/login/route.lazy.tsx +1 -1
  118. package/src/routes/setup/route.lazy.tsx +2 -2
  119. package/src/server/__tests__/admin-billing.test.ts +191 -16
  120. package/src/server/__tests__/compliance.test.ts +44 -7
  121. package/src/server/__tests__/permissions.test.ts +10 -5
  122. package/src/server/__tests__/prompt-defaults.test.ts +3 -3
  123. package/src/server/admin-ai-alerts.ts +2 -1
  124. package/src/server/admin-ai-usage.ts +2 -1
  125. package/src/server/ai-memory/store.ts +6 -2
  126. package/src/server/{serverFns/ai-prompts → ai-prompts}/prompt-defaults.ts +5 -3
  127. package/src/server/{serverFns/dependency → dependency}/helpers.ts +4 -3
  128. package/src/server/knowledge-public/github-sync.ts +2 -1
  129. package/src/server/license-issue.ts +2 -1
  130. package/src/server/middlewares/auth-rate-limit.ts +117 -0
  131. package/src/server/observability/health-check.ts +4 -1
  132. package/src/server/observability/metrics.ts +17 -15
  133. package/src/server/observability/request-middleware.ts +2 -1
  134. package/src/server/observability/timing.ts +14 -16
  135. package/src/server/serverFns/admin-billing.ts +107 -12
  136. package/src/server/serverFns/ai-memory/list-users.ts +2 -1
  137. package/src/server/serverFns/ai-prompts/activate.ts +2 -1
  138. package/src/server/serverFns/ai-prompts/get.ts +3 -2
  139. package/src/server/serverFns/ai-prompts/list.ts +3 -2
  140. package/src/server/serverFns/ai-prompts/rollback.ts +3 -1
  141. package/src/server/serverFns/ai-prompts/save.ts +5 -3
  142. package/src/server/serverFns/ai-provider/create.ts +4 -1
  143. package/src/server/serverFns/ai-provider/delete.ts +4 -1
  144. package/src/server/serverFns/ai-provider/get.ts +4 -1
  145. package/src/server/serverFns/ai-provider/list.ts +28 -1
  146. package/src/server/serverFns/ai-provider/seed-from-seed.ts +2 -1
  147. package/src/server/serverFns/ai-provider/update.ts +4 -1
  148. package/src/server/serverFns/anyhub/market.ts +8 -1
  149. package/src/server/serverFns/audit.ts +14 -1
  150. package/src/server/serverFns/auth/setup.ts +3 -1
  151. package/src/server/serverFns/compliance.ts +32 -3
  152. package/src/server/serverFns/components/import.ts +69 -14
  153. package/src/server/serverFns/components/list.ts +4 -1
  154. package/src/server/serverFns/components/mutations.ts +9 -1
  155. package/src/server/serverFns/dependency/list.ts +28 -5
  156. package/src/server/serverFns/dependency/sync.ts +7 -2
  157. package/src/server/serverFns/dependency/update.ts +4 -1
  158. package/src/server/serverFns/developer-log-auto-generate.ts +63 -32
  159. package/src/server/serverFns/developer-logs.ts +5 -1
  160. package/src/server/serverFns/invitations.ts +7 -1
  161. package/src/server/serverFns/knowledge-public/bookmarks/create.ts +2 -1
  162. package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +2 -1
  163. package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +2 -1
  164. package/src/server/serverFns/knowledge-public/bookmarks/list.ts +2 -1
  165. package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +2 -1
  166. package/src/server/serverFns/knowledge-public/bookmarks/update.ts +2 -1
  167. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  168. package/src/server/serverFns/knowledge-public/compliance/delete.ts +2 -1
  169. package/src/server/serverFns/knowledge-public/compliance/detail.ts +2 -1
  170. package/src/server/serverFns/knowledge-public/compliance/list.ts +2 -1
  171. package/src/server/serverFns/knowledge-public/compliance/update.ts +2 -1
  172. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  173. package/src/server/serverFns/knowledge-public/legal/delete.ts +2 -1
  174. package/src/server/serverFns/knowledge-public/legal/detail.ts +2 -1
  175. package/src/server/serverFns/knowledge-public/legal/list.ts +2 -1
  176. package/src/server/serverFns/knowledge-public/legal/update.ts +2 -1
  177. package/src/server/serverFns/licenses.ts +8 -2
  178. package/src/server/serverFns/monitoring/get-errors.ts +2 -1
  179. package/src/server/serverFns/monitoring/get-health.ts +2 -1
  180. package/src/server/serverFns/monitoring/get-metrics.ts +2 -1
  181. package/src/server/serverFns/monitoring/get-request-logs.ts +2 -1
  182. package/src/server/serverFns/monitoring/report-error.ts +2 -1
  183. package/src/server/serverFns/monitoring/reset.ts +2 -1
  184. package/src/server/serverFns/oauth-providers.ts +4 -1
  185. package/src/server/serverFns/permissions.ts +18 -11
  186. package/src/server/serverFns/sentry/config.ts +11 -8
  187. package/src/server/serverFns/sso-config.ts +17 -2
  188. package/src/server/serverFns/user/create.ts +6 -1
  189. package/src/server/serverFns/user/delete.ts +6 -1
  190. package/src/server/serverFns/user/detail.ts +5 -2
  191. package/src/server/serverFns/user/list.ts +19 -4
  192. package/src/server/serverFns/user/reset-password.ts +10 -2
  193. package/src/server/serverFns/user/update-role.ts +10 -2
  194. package/src/server/serverFns/user/update-status.ts +10 -2
  195. package/src/styles/globals.css +4 -0
  196. package/src/components/ui/input.tsx +0 -19
  197. /package/{src → allblue-ui/src}/hooks/use-mobile.ts +0 -0
  198. /package/{src → allblue-ui/src}/lib/avatar.ts +0 -0
  199. /package/{src/components → allblue-ui/src}/ui/alert.tsx +0 -0
  200. /package/{src/components → allblue-ui/src}/ui/avatar.tsx +0 -0
  201. /package/{src/components → allblue-ui/src}/ui/badge.tsx +0 -0
  202. /package/{src/components → allblue-ui/src}/ui/button.tsx +0 -0
  203. /package/{src/components → allblue-ui/src}/ui/card.tsx +0 -0
  204. /package/{src/components → allblue-ui/src}/ui/collapsible.tsx +0 -0
  205. /package/{src/components → allblue-ui/src}/ui/label.tsx +0 -0
  206. /package/{src/components → allblue-ui/src}/ui/skeleton.tsx +0 -0
  207. /package/{src/components → allblue-ui/src}/ui/textarea.tsx +0 -0
  208. /package/src/routes/{api.auth.$.ts → api/auth/$.ts} +0 -0
  209. /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
  })