@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,189 @@
1
+
2
+ import { useState } from 'react'
3
+ import { z } from 'zod'
4
+ import { PageHeader, PageLoading, PageError } from '../../components/Page'
5
+ import { SettingsCard } from '../../components/SettingsCard'
6
+ import { FormField } from '../../components/FormField'
7
+ import { Button } from '../../components/ui/button'
8
+ import { Input } from '../../components/ui/input'
9
+ import { Badge } from '../../components/ui/badge'
10
+ import { ImpactConfirmation } from '../../components/SecretModal'
11
+ import { toast } from '../../components/toast'
12
+ import { apiErrorMessage } from '../../lib/api-error'
13
+ import { useApi } from '@ploykit/hooks'
14
+ import { useAuth } from '@ploykit/hooks'
15
+ import { useZodForm } from '@ploykit/hooks'
16
+ import type { PKUser } from '@ploykit/hooks'
17
+
18
+
19
+
20
+ const profileSchema = z.object({
21
+ display_name: z.string().trim().min(1, '请输入昵称').max(100, '昵称最多 100 字符'),
22
+
23
+ avatar_url: z.string()
24
+ .refine((v) => v === '' || /^https?:\/\//.test(v), '头像链接需以 http(s):// 开头')
25
+ .refine((v) => v === '' || v.length <= 2048, '头像链接最多 2048 字符'),
26
+ })
27
+ type ProfileForm = z.infer<typeof profileSchema>
28
+
29
+ export interface ProfileSettingsPageProps {
30
+
31
+ onDeleted?: () => void
32
+ }
33
+
34
+ export function ProfileSettingsPage({ onDeleted }: ProfileSettingsPageProps) {
35
+ const { user, loading, refresh, logout } = useAuth()
36
+ const api = useApi()
37
+ const [saving, setSaving] = useState(false)
38
+ const [sending, setSending] = useState(false)
39
+ const [deleting, setDeleting] = useState(false)
40
+ const [confirmEmail, setConfirmEmail] = useState('')
41
+ const [confirmOpen, setConfirmOpen] = useState(false)
42
+
43
+ const form = useZodForm<ProfileForm>({
44
+ schema: profileSchema,
45
+ defaultValues: { display_name: user?.display_name ?? '', avatar_url: user?.avatar_url ?? '' },
46
+ values: user
47
+ ? { display_name: user.display_name, avatar_url: user.avatar_url ?? '' }
48
+ : undefined, // 用户数据到达后同步进表单(保留已输入内容的 RHF 回退语义)
49
+ })
50
+
51
+ if (loading) return <PageLoading label="加载账号信息…" />
52
+ if (!user) {
53
+ return <PageError message="未登录或会话已过期,请重新登录" onRetry={() => refresh()} />
54
+ }
55
+
56
+ const handleSave = form.handleSubmit(async (values) => {
57
+ setSaving(true)
58
+ try {
59
+ await api.patch<PKUser>('/auth/me', {
60
+ display_name: values.display_name,
61
+ avatar_url: values.avatar_url,
62
+ })
63
+ await refresh()
64
+ toast.success('资料已更新')
65
+ } catch (e) {
66
+
67
+ toast.error(apiErrorMessage(e, '保存失败'))
68
+ } finally {
69
+ setSaving(false)
70
+ }
71
+ })
72
+
73
+ const handleResend = async () => {
74
+ setSending(true)
75
+ try {
76
+ await api.post('/auth/send-verification')
77
+ toast.success('验证邮件已发送,请查收')
78
+ } catch (e) {
79
+ toast.error(apiErrorMessage(e, '发送失败'))
80
+ } finally {
81
+ setSending(false)
82
+ }
83
+ }
84
+
85
+ const handleDelete = async () => {
86
+ setDeleting(true)
87
+ try {
88
+ await api.delete('/auth/me')
89
+ if (onDeleted) onDeleted()
90
+ else {
91
+ await logout()
92
+ window.location.href = '/login'
93
+ }
94
+ } catch (e) {
95
+ toast.error(apiErrorMessage(e, '注销失败'))
96
+ } finally {
97
+ setDeleting(false)
98
+ }
99
+ }
100
+
101
+ const emailReadyToDelete = confirmEmail.trim().toLowerCase() === user.email.toLowerCase()
102
+
103
+ return (
104
+ <div>
105
+ <PageHeader title="个人资料" description="管理昵称、头像等账号资料" />
106
+
107
+ <SettingsCard
108
+ title="资料" description="对外展示的昵称与头像"
109
+ cta={<Button type="submit" form="profile-form" disabled={saving}>{saving ? '保存中…' : '保存'}</Button>}
110
+ >
111
+ <form id="profile-form" onSubmit={handleSave} className="max-w-xl space-y-4">
112
+ <FormField
113
+ label="昵称" required
114
+ error={form.formState.errors.display_name?.message} htmlFor="profile-name"
115
+ >
116
+ <Input id="profile-name" maxLength={100} placeholder="怎么称呼你" {...form.register('display_name')} />
117
+ </FormField>
118
+ <FormField
119
+ label="头像链接"
120
+ error={form.formState.errors.avatar_url?.message} htmlFor="profile-avatar"
121
+ description="可选;需 http(s):// 开头、最多 2048 字符。外部图片可能被内容安全策略拦截,无法加载时显示默认头像"
122
+ >
123
+ <Input id="profile-avatar" maxLength={2048} placeholder="https://…(可选)" {...form.register('avatar_url')} />
124
+ </FormField>
125
+ </form>
126
+
127
+ <div className="mt-6 flex flex-row items-center justify-between gap-4 rounded-lg border p-4">
128
+ <div className="min-w-0">
129
+ <p className="text-sm font-medium">登录邮箱</p>
130
+ <p className="mt-0.5 truncate text-sm text-muted-foreground" data-testid="profile-email">
131
+ {user.email}
132
+ </p>
133
+ {/* B3:只读现实如实说明——此前页头承诺"管理登录邮箱"却无修改入口 */}
134
+ <p className="mt-1 text-xs text-muted-foreground">用于登录与接收通知,暂不支持自助修改</p>
135
+ </div>
136
+ <div className="flex shrink-0 items-center gap-2">
137
+ {user.email_verified ? (
138
+ <Badge variant="secondary" className="bg-(--success)/10 text-(--success) border-(--success)/30">
139
+ 已验证
140
+ </Badge>
141
+ ) : (
142
+ <>
143
+ <Badge variant="outline" className="text-(--warning) border-(--warning)/40 bg-(--warning)/15">未验证</Badge>
144
+ <Button variant="outline" size="sm" onClick={handleResend} disabled={sending}>
145
+ {sending ? '发送中…' : '重新发送验证邮件'}
146
+ </Button>
147
+ </>
148
+ )}
149
+ </div>
150
+ </div>
151
+ </SettingsCard>
152
+
153
+ <SettingsCard
154
+ danger title="删除账号" description="注销后账号进入删除状态,无法恢复"
155
+ cta={
156
+ <Button
157
+ variant="destructive" disabled={!emailReadyToDelete || deleting}
158
+ onClick={() => setConfirmOpen(true)}
159
+ >
160
+ 删除账号
161
+ </Button>
162
+ }
163
+ >
164
+ <div className="max-w-xl space-y-4">
165
+ <FormField label="输入登录邮箱以确认" htmlFor="profile-delete-email">
166
+ <Input
167
+ id="profile-delete-email" placeholder={user.email} value={confirmEmail}
168
+ onChange={(e) => setConfirmEmail(e.target.value)}
169
+ />
170
+ </FormField>
171
+ </div>
172
+ </SettingsCard>
173
+
174
+ <ImpactConfirmation
175
+ open={confirmOpen}
176
+ onOpenChange={setConfirmOpen}
177
+ onConfirm={handleDelete}
178
+ title="确认删除账号?"
179
+ confirmText="永久删除"
180
+ danger
181
+ impacts={[
182
+ '账号进入 deleted 状态,邮箱被释放(可用同邮箱重新注册)',
183
+ '所有会话立即失效并吊销全部个人访问令牌(PAT)',
184
+ '昵称、头像等资料被清空,操作不可恢复',
185
+ ]}
186
+ />
187
+ </div>
188
+ )
189
+ }
@@ -0,0 +1,214 @@
1
+
2
+ import { useState } from 'react'
3
+ import { z } from 'zod'
4
+ import { PageHeader, PageError } from '../../components/Page'
5
+ import { SettingsCard } from '../../components/SettingsCard'
6
+ import { FormField } from '../../components/FormField'
7
+ import { Button } from '../../components/ui/button'
8
+ import { Input } from '../../components/ui/input'
9
+ import { Badge } from '../../components/ui/badge'
10
+ import { DataTable, type Column } from '../../components/DataTable'
11
+ import { ImpactConfirmation } from '../../components/SecretModal'
12
+ import { useConfirm } from '../../components/ConfirmDialog'
13
+ import { toast } from '../../components/toast'
14
+ import { useApi } from '@ploykit/hooks'
15
+ import { useAuth } from '@ploykit/hooks'
16
+ import { useZodForm } from '@ploykit/hooks'
17
+ import { useSessions, useRevokeSession, useRevokeAllSessions, type SessionInfo } from '@ploykit/hooks'
18
+ import { passwordSchema } from '../auth/RegisterPage'
19
+ import { apiErrorMessage } from '../../lib/api-error'
20
+ import { formatDateTime, shortID } from '../../lib/utils'
21
+
22
+ const changePwSchema = z
23
+ .object({
24
+ old_password: z.string().min(1, '请输入当前密码'),
25
+ new_password: passwordSchema,
26
+ })
27
+ // B4(ui-audit-10pages §4.2,S3):new==old 客户端拦截——相同值提交后端会
28
+ // 判定通过并"成功"改密,顺带静默吊销全部会话(服务端无 new==old 校验,
29
+ // 半边 ➡️U1 identity 归属)。跨字段校验须挂对象级 refine(字段级拿不到 old);
30
+ // zod 语义:字段规则全过才跑 refine,old 为空时不会叠加误导性报错。
31
+ .superRefine((v, ctx) => {
32
+ if (v.old_password !== '' && v.new_password === v.old_password) {
33
+ ctx.addIssue({
34
+ code: 'custom',
35
+ path: ['new_password'],
36
+ message: '新密码不能与当前密码相同',
37
+ })
38
+ }
39
+ })
40
+ type ChangePwForm = z.infer<typeof changePwSchema>
41
+
42
+ function truncate(s: string, max = 32): string {
43
+ return s.length > max ? s.slice(0, max) + '…' : s
44
+ }
45
+
46
+ export function SecuritySettingsPage() {
47
+ const api = useApi()
48
+ const { refresh, logout } = useAuth()
49
+ const confirm = useConfirm()
50
+ const { sessions, loading, error, refetch } = useSessions()
51
+ const { revoke } = useRevokeSession()
52
+ const { revokeAll } = useRevokeAllSessions()
53
+
54
+ const [saving, setSaving] = useState(false)
55
+ const [revokingAll, setRevokingAll] = useState(false)
56
+ const [confirmAllOpen, setConfirmAllOpen] = useState(false)
57
+
58
+ const form = useZodForm<ChangePwForm>({
59
+ schema: changePwSchema,
60
+ defaultValues: { old_password: '', new_password: '' },
61
+ })
62
+
63
+ const handleChangePw = form.handleSubmit(async (values) => {
64
+ setSaving(true)
65
+ try {
66
+ await api.post('/auth/change-password', {
67
+ old_password: values.old_password,
68
+ new_password: values.new_password,
69
+ })
70
+ toast.success('密码已修改,其他设备可能需要重新登录')
71
+ form.reset()
72
+ await refresh()
73
+ } catch (e) {
74
+
75
+
76
+
77
+ toast.error(apiErrorMessage(e, '修改失败'))
78
+ } finally {
79
+ setSaving(false)
80
+ }
81
+ })
82
+
83
+ const handleRevoke = async (s: SessionInfo) => {
84
+ const ok = await confirm({
85
+ title: '吊销此会话?',
86
+ description: s.current ? '这是当前设备,吊销后需要重新登录。' : truncate(s.user_agent),
87
+ confirmText: '吊销',
88
+ danger: true,
89
+ })
90
+ if (!ok) return
91
+ try {
92
+ await revoke(s.id)
93
+ if (s.current) {
94
+
95
+
96
+
97
+ toast.success('已吊销当前会话,即将返回登录页')
98
+ await logout()
99
+ window.location.href = '/login'
100
+ return
101
+ }
102
+ toast.success('会话已吊销')
103
+ } catch (e) {
104
+ toast.error(apiErrorMessage(e, '吊销失败'))
105
+ }
106
+ }
107
+
108
+ const handleRevokeAll = async () => {
109
+ setRevokingAll(true)
110
+ try {
111
+ await revokeAll()
112
+ toast.success('已吊销全部会话')
113
+ window.location.href = '/login'
114
+ } catch (e) {
115
+ toast.error(apiErrorMessage(e, '吊销失败'))
116
+ setRevokingAll(false)
117
+ }
118
+ }
119
+
120
+ const columns: Column<SessionInfo>[] = [
121
+ {
122
+ key: 'user_agent', header: '设备',
123
+
124
+
125
+
126
+ render: (s) => (
127
+ <span title={s.user_agent} className="text-muted-foreground inline-block max-w-64 truncate">{truncate(s.user_agent)}</span>
128
+ ),
129
+ },
130
+ {
131
+ key: 'ip_hash', header: 'IP(哈希)',
132
+ render: (s) => <span title={s.ip_hash} className="font-mono text-xs">{shortID(s.ip_hash, 10)}</span>,
133
+ },
134
+ { key: 'last_seen_at', header: '最近活跃', render: (s) => formatDateTime(s.last_seen_at) },
135
+ { key: 'expires_at', header: '过期时间', render: (s) => formatDateTime(s.expires_at) },
136
+ {
137
+ key: 'current', header: '当前',
138
+
139
+ render: (s) =>
140
+ s.current === undefined ? (
141
+ <span className="text-muted-foreground">—</span>
142
+ ) : s.current ? (
143
+ <Badge variant="secondary" className="bg-(--success)/10 text-(--success) border-(--success)/30">当前设备</Badge>
144
+ ) : (
145
+ <span className="text-muted-foreground">—</span>
146
+ ),
147
+ },
148
+ {
149
+ key: 'actions', header: '', className: 'text-right',
150
+ render: (s) => (
151
+ <Button variant="ghost" size="sm" className="text-destructive" onClick={() => handleRevoke(s)}>
152
+ 吊销
153
+ </Button>
154
+ ),
155
+ },
156
+ ]
157
+
158
+ return (
159
+ <div>
160
+ <PageHeader title="账户安全" description="修改密码并管理已登录设备" />
161
+
162
+ <SettingsCard
163
+ title="修改密码" description="修改后其他会话将失效,需要重新登录"
164
+ cta={<Button type="submit" form="security-pw-form" disabled={saving}>{saving ? '提交中…' : '修改密码'}</Button>}
165
+ >
166
+ <form id="security-pw-form" onSubmit={handleChangePw} className="max-w-xl space-y-4">
167
+ <FormField label="当前密码" error={form.formState.errors.old_password?.message} htmlFor="sec-old">
168
+ <Input id="sec-old" type="password" autoComplete="current-password" {...form.register('old_password')} />
169
+ </FormField>
170
+ <FormField label="新密码" error={form.formState.errors.new_password?.message} htmlFor="sec-new">
171
+ <Input id="sec-new" type="password" placeholder="至少 10 位,含三类字符"
172
+ autoComplete="new-password" {...form.register('new_password')} />
173
+ </FormField>
174
+ </form>
175
+ </SettingsCard>
176
+
177
+ <SettingsCard
178
+ title="活跃会话"
179
+ description="最近登录过你账号的设备;可疑会话请立即吊销"
180
+ cta={
181
+ <Button variant="destructive" size="sm" disabled={revokingAll} onClick={() => setConfirmAllOpen(true)}>
182
+ 全部吊销
183
+ </Button>
184
+ }
185
+ >
186
+ {error ? (
187
+ <PageError message={apiErrorMessage(error, '会话列表加载失败')} onRetry={() => refetch()} />
188
+ ) : (
189
+ <DataTable
190
+ columns={columns}
191
+ rows={sessions}
192
+ rowKey={(s) => s.id}
193
+ loading={loading}
194
+ empty={<div className="py-8 text-center text-sm text-muted-foreground">暂无活跃会话</div>}
195
+ />
196
+ )}
197
+ </SettingsCard>
198
+
199
+ <ImpactConfirmation
200
+ open={confirmAllOpen}
201
+ onOpenChange={setConfirmAllOpen}
202
+ onConfirm={handleRevokeAll}
203
+ title="吊销全部会话?"
204
+ confirmText="全部吊销"
205
+ danger
206
+ impacts={[
207
+ '所有设备(含当前设备)立即退出登录',
208
+ '会话 cookie 被清除,页面将跳转登录页',
209
+ '个人访问令牌(PAT)不受影响,仍需单独吊销',
210
+ ]}
211
+ />
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,211 @@
1
+
2
+ import { useState } from 'react'
3
+ import { PageHeader, PageEmpty } from '../../components/Page'
4
+ import { FormField } from '../../components/FormField'
5
+ import { Button } from '../../components/ui/button'
6
+ import { Input } from '../../components/ui/input'
7
+ import { Badge } from '../../components/ui/badge'
8
+ import { DataTable, type Column } from '../../components/DataTable'
9
+ import { SecretModal } from '../../components/SecretModal'
10
+ import { useConfirm } from '../../components/ConfirmDialog'
11
+ import {
12
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
13
+ } from '../../components/ui/dialog'
14
+ import { toast } from '../../components/toast'
15
+ import { useTokens, useCreateToken, useRevokeToken, type PAT } from '@ploykit/hooks'
16
+ import { apiErrorMessage } from '../../lib/api-error'
17
+ import { formatDateTime } from '../../lib/utils'
18
+
19
+
20
+ const NAME_MAX_BYTES = 64
21
+ const utf8Bytes = (s: string) => new TextEncoder().encode(s).length
22
+
23
+ export function TokensPage() {
24
+ const { tokens, loading, refetch } = useTokens()
25
+ const { create } = useCreateToken()
26
+ const { revoke } = useRevokeToken()
27
+ const confirm = useConfirm()
28
+
29
+ const [createOpen, setCreateOpen] = useState(false)
30
+ const [name, setName] = useState('')
31
+ const [nameError, setNameError] = useState<string | null>(null)
32
+ const [ttlHours, setTtlHours] = useState('')
33
+ const [ttlError, setTtlError] = useState<string | null>(null)
34
+ const [creating, setCreating] = useState(false)
35
+ const [secret, setSecret] = useState<string | null>(null)
36
+
37
+
38
+ function parseTtl(raw: string): { ok: true; hours?: number } | { ok: false; error: string } {
39
+ const s = raw.trim()
40
+ if (s === '') return { ok: true }
41
+ const n = Number(s)
42
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
43
+ return { ok: false, error: '有效期必须是正整数(小时);不需要过期时间请留空' }
44
+ }
45
+ return { ok: true, hours: n }
46
+ }
47
+
48
+ const handleCreate = async () => {
49
+ const trimmed = name.trim()
50
+ if (!trimmed) return
51
+
52
+ if (utf8Bytes(trimmed) > NAME_MAX_BYTES) {
53
+ setNameError(`名称不能超过 64 字节(约 21 个汉字),当前 ${utf8Bytes(trimmed)} 字节`)
54
+ return
55
+ }
56
+ const ttl = parseTtl(ttlHours)
57
+ if (!ttl.ok) {
58
+ setTtlError(ttl.error)
59
+ return
60
+ }
61
+ setNameError(null)
62
+ setTtlError(null)
63
+ setCreating(true)
64
+ try {
65
+ const res = await create(trimmed, ttl.hours)
66
+ setCreateOpen(false)
67
+ setName('')
68
+ setTtlHours('')
69
+ setSecret(res.token)
70
+ toast.success('令牌已创建')
71
+ } catch (e) {
72
+
73
+ toast.error(apiErrorMessage(e, '创建失败'))
74
+ } finally {
75
+ setCreating(false)
76
+ }
77
+ }
78
+
79
+ const handleRevoke = async (t: PAT) => {
80
+ const ok = await confirm({
81
+ title: '吊销此令牌?',
82
+ description: `使用「${t.name}」(前缀 ${t.prefix}…)的集成将立即失去访问权限。`,
83
+ confirmText: '吊销',
84
+ danger: true,
85
+ })
86
+ if (!ok) return
87
+ try {
88
+ await revoke(t.id)
89
+ toast.success('令牌已吊销')
90
+ } catch (e) {
91
+ toast.error(apiErrorMessage(e, '吊销失败'))
92
+ }
93
+ }
94
+
95
+ const columns: Column<PAT>[] = [
96
+ {
97
+
98
+
99
+
100
+ key: 'name', header: '名称',
101
+ render: (t) => (
102
+ <span className="font-medium block max-w-[16rem] truncate" title={t.name}>
103
+ {t.name}
104
+ </span>
105
+ ),
106
+ },
107
+ { key: 'prefix', header: '前缀', render: (t) => <code className="font-mono text-xs">{t.prefix}…</code> },
108
+ {
109
+ key: 'last_used_at', header: '最近使用',
110
+ render: (t) => (t.last_used_at ? formatDateTime(t.last_used_at) : <span className="text-muted-foreground">从未使用</span>),
111
+ },
112
+ {
113
+ key: 'expires_at', header: '过期时间',
114
+ render: (t) =>
115
+ t.expires_at ? (
116
+ formatDateTime(t.expires_at)
117
+ ) : (
118
+ <Badge variant="outline">永不过期</Badge>
119
+ ),
120
+ },
121
+ { key: 'created_at', header: '创建时间', render: (t) => formatDateTime(t.created_at) },
122
+ {
123
+ key: 'actions', header: '', className: 'text-right',
124
+ render: (t) => (
125
+
126
+ <Button
127
+ variant="ghost" size="sm" className="text-destructive"
128
+ aria-label={`吊销令牌 ${t.name}`}
129
+ onClick={() => handleRevoke(t)}
130
+ >
131
+ 吊销
132
+ </Button>
133
+ ),
134
+ },
135
+ ]
136
+
137
+ return (
138
+ <div>
139
+ <PageHeader
140
+ title="个人访问令牌"
141
+ description="用于脚本与 API 集成;明文只在创建时显示一次"
142
+ actions={<Button onClick={() => setCreateOpen(true)}>新建令牌</Button>}
143
+ />
144
+
145
+ <DataTable
146
+ columns={columns}
147
+ rows={tokens}
148
+ rowKey={(t) => t.id}
149
+ loading={loading}
150
+ empty={
151
+ <PageEmpty
152
+ label="还没有令牌"
153
+ hint="创建一个令牌,让脚本以你的身份调用 API"
154
+ />
155
+ }
156
+ />
157
+
158
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
159
+ <DialogContent>
160
+ <DialogHeader>
161
+ <DialogTitle>新建令牌</DialogTitle>
162
+ <DialogDescription>
163
+ 创建后明文只显示一次,请立即保存到安全的地方。
164
+ </DialogDescription>
165
+ </DialogHeader>
166
+ <div className="space-y-4">
167
+ <FormField label="名称" htmlFor="pat-name" error={nameError ?? undefined}>
168
+ {/* B3:maxLength 只按 UTF-16 计(64 汉字 = 192 字节拦不住),
169
+ 作 ASCII 粗预约束;真正的字节上限在 handleCreate 校验。 */}
170
+ <Input
171
+ id="pat-name" value={name} onChange={(e) => {
172
+ setName(e.target.value)
173
+ if (nameError) setNameError(null)
174
+ }}
175
+ placeholder="如:ci-deploy" maxLength={64} autoFocus
176
+ aria-invalid={nameError ? true : undefined}
177
+ />
178
+ </FormField>
179
+ <FormField label="有效期(小时,可选)" htmlFor="pat-ttl" error={ttlError ?? undefined}>
180
+ {/* P2-1:不用 type="number"——非法字符(如字母)不会进入受控值,
181
+ 会被静默当成留空创建永不过期凭证;text + inputMode 保留数字键盘
182
+ 且让 parseTtl 能真正拒绝非法输入。 */}
183
+ <Input
184
+ id="pat-ttl" type="text" inputMode="numeric" value={ttlHours}
185
+ onChange={(e) => {
186
+ setTtlHours(e.target.value)
187
+ if (ttlError) setTtlError(null)
188
+ }}
189
+ placeholder="留空 = 永不过期(不建议)"
190
+ aria-invalid={ttlError ? true : undefined}
191
+ />
192
+ </FormField>
193
+ </div>
194
+ <DialogFooter>
195
+ <Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
196
+ <Button onClick={handleCreate} disabled={creating || !name.trim()}>
197
+ {creating ? '创建中…' : '创建'}
198
+ </Button>
199
+ </DialogFooter>
200
+ </DialogContent>
201
+ </Dialog>
202
+
203
+ <SecretModal
204
+ open={secret !== null}
205
+ onOpenChange={(o) => !o && setSecret(null)}
206
+ secretName="个人访问令牌"
207
+ secretValue={secret ?? ''}
208
+ />
209
+ </div>
210
+ )
211
+ }