@ploykit/ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) 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.d.ts +5 -1
  66. package/dist/pages/auth/ResetPasswordPage.js +7 -7
  67. package/dist/pages/auth/VerifyEmailPage.d.ts +9 -1
  68. package/dist/pages/auth/VerifyEmailPage.js +5 -5
  69. package/dist/pages/workspace/BillingPage.js +11 -11
  70. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  71. package/dist/pages/workspace/MembersPage.js +15 -15
  72. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  73. package/dist/pages/workspace/SchedulesPage.js +12 -12
  74. package/dist/pages/workspace/UsagePage.js +6 -6
  75. package/dist/pages/workspace/WebhooksPage.js +16 -16
  76. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  77. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  78. package/package.json +6 -5
  79. package/src/AppProviders.tsx +29 -0
  80. package/src/components/ConfirmDialog.tsx +77 -0
  81. package/src/components/CronInput.tsx +195 -0
  82. package/src/components/DataTable.tsx +152 -0
  83. package/src/components/ErrorBoundary.tsx +46 -0
  84. package/src/components/FormField.tsx +56 -0
  85. package/src/components/ImpersonationBanner.tsx +23 -0
  86. package/src/components/NotificationBell.tsx +135 -0
  87. package/src/components/OnboardingChecklist.tsx +114 -0
  88. package/src/components/Page.tsx +67 -0
  89. package/src/components/ReauthDialog.tsx +94 -0
  90. package/src/components/SecretModal.tsx +147 -0
  91. package/src/components/SettingsCard.tsx +39 -0
  92. package/src/components/SiteBanner.tsx +32 -0
  93. package/src/components/SiteBannerLayer.tsx +29 -0
  94. package/src/components/StatusBadge.tsx +12 -0
  95. package/src/components/UserMenu.tsx +103 -0
  96. package/src/components/WorkspaceSwitcher.tsx +159 -0
  97. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  98. package/src/components/__tests__/CronInput.test.tsx +180 -0
  99. package/src/components/__tests__/DataTable.test.tsx +208 -0
  100. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  101. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  102. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  103. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  104. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  105. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  106. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  107. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  108. package/src/components/__tests__/form.test.tsx +133 -0
  109. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  110. package/src/components/__tests__/smoke.test.tsx +11 -0
  111. package/src/components/__tests__/toast.test.tsx +27 -0
  112. package/src/components/toast.tsx +23 -0
  113. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  114. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  115. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  116. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  117. package/src/components/ui/__tests__/field.test.tsx +43 -0
  118. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  119. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  120. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  121. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  122. package/src/components/ui/__tests__/select.test.tsx +48 -0
  123. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  124. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  125. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  126. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  127. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  128. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  129. package/src/components/ui/alert-dialog.tsx +187 -0
  130. package/src/components/ui/avatar.tsx +108 -0
  131. package/src/components/ui/badge.tsx +51 -0
  132. package/src/components/ui/breadcrumb.tsx +124 -0
  133. package/src/components/ui/button.tsx +57 -0
  134. package/src/components/ui/card.tsx +102 -0
  135. package/src/components/ui/checkbox.tsx +28 -0
  136. package/src/components/ui/dialog.tsx +166 -0
  137. package/src/components/ui/dropdown-menu.tsx +265 -0
  138. package/src/components/ui/empty.tsx +103 -0
  139. package/src/components/ui/field.tsx +238 -0
  140. package/src/components/ui/input.tsx +19 -0
  141. package/src/components/ui/label.tsx +17 -0
  142. package/src/components/ui/popover.tsx +89 -0
  143. package/src/components/ui/progress.tsx +82 -0
  144. package/src/components/ui/select.tsx +235 -0
  145. package/src/components/ui/separator.tsx +24 -0
  146. package/src/components/ui/sheet.tsx +146 -0
  147. package/src/components/ui/sidebar.tsx +723 -0
  148. package/src/components/ui/skeleton.tsx +13 -0
  149. package/src/components/ui/switch.tsx +29 -0
  150. package/src/components/ui/tabs.tsx +79 -0
  151. package/src/components/ui/textarea.tsx +26 -0
  152. package/src/components/ui/tooltip.tsx +63 -0
  153. package/src/dev.css +1 -0
  154. package/src/hooks/use-mobile.ts +19 -0
  155. package/src/index.ts +150 -0
  156. package/src/layouts/AppShell.tsx +163 -0
  157. package/src/layouts/AuthShell.tsx +24 -0
  158. package/src/layouts/SettingsShell.tsx +129 -0
  159. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  160. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  161. package/src/lib/__tests__/api-error.test.ts +163 -0
  162. package/src/lib/__tests__/utils.test.ts +21 -0
  163. package/src/lib/api-error.ts +98 -0
  164. package/src/lib/utils.ts +109 -0
  165. package/src/pages/LandingPage.tsx +88 -0
  166. package/src/pages/LoginPage.tsx +291 -0
  167. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  168. package/src/pages/__tests__/LoginPage.test.tsx +230 -0
  169. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  170. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  171. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  172. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  173. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  174. package/src/pages/account/TokensPage.tsx +211 -0
  175. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  176. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  177. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  178. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  179. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  180. package/src/pages/admin/AdminShell.tsx +199 -0
  181. package/src/pages/admin/AuditPage.tsx +338 -0
  182. package/src/pages/admin/OrdersPage.tsx +642 -0
  183. package/src/pages/admin/OverviewPage.tsx +559 -0
  184. package/src/pages/admin/SSOPage.tsx +166 -0
  185. package/src/pages/admin/UserDetailPage.tsx +641 -0
  186. package/src/pages/admin/UsersPage.tsx +462 -0
  187. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  188. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  189. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  190. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  191. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  192. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  193. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  194. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  195. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  196. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  197. package/src/pages/admin/shared.ts +42 -0
  198. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  199. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  200. package/src/pages/auth/RegisterPage.tsx +204 -0
  201. package/src/pages/auth/ResetPasswordPage.tsx +160 -0
  202. package/src/pages/auth/VerifyEmailPage.tsx +83 -0
  203. package/src/pages/auth/__tests__/AuthPagesI18n.test.tsx +33 -0
  204. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  205. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  206. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  207. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  208. package/src/pages/workspace/BillingPage.tsx +361 -0
  209. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  210. package/src/pages/workspace/MembersPage.tsx +568 -0
  211. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  212. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  213. package/src/pages/workspace/UsagePage.tsx +207 -0
  214. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  215. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  216. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  217. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  218. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  219. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  220. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  221. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  222. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  223. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  224. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  225. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,105 @@
1
+
2
+ import { useNavigate } from 'react-router'
3
+ import { Check, X } from 'lucide-react'
4
+ import { Button } from '../../components/ui/button'
5
+ import { Badge } from '../../components/ui/badge'
6
+ import { PageLoading, PageEmpty } from '../../components/Page'
7
+ import { toast } from '../../components/toast'
8
+ import { useMyInvitations } from '@ploykit/hooks'
9
+ import { formatDateTime } from '../../lib/utils'
10
+ import { apiErrorMessage } from '../../lib/api-error'
11
+
12
+ const ROLE_LABEL: Record<string, string> = { owner: '所有者', admin: '管理员', member: '成员' }
13
+
14
+ export interface InviteAcceptPageProps {
15
+
16
+ onAccepted?: (workspaceId: string) => void
17
+
18
+ onDeclined?: () => void
19
+ }
20
+
21
+ export function InviteAcceptPage({ onAccepted, onDeclined }: InviteAcceptPageProps = {}) {
22
+ const navigate = useNavigate()
23
+ const { invitations, loading, error, refetch, accept, decline } = useMyInvitations()
24
+
25
+ const pending = invitations.filter((i) => i.status === 'pending')
26
+
27
+ const onAccept = async (id: string, name: string) => {
28
+ try {
29
+ const ws = await accept.mutateAsync(id)
30
+ toast.success(`已加入「${name}」`)
31
+
32
+ if (onAccepted) onAccepted(ws.id)
33
+ else navigate('/app')
34
+ } catch (e) {
35
+ toast.error(apiErrorMessage(e, '接受邀请失败'))
36
+ }
37
+ }
38
+
39
+ const onDecline = async (id: string, name: string) => {
40
+ try {
41
+ await decline.mutateAsync(id)
42
+ toast.success(`已拒绝「${name}」的邀请`)
43
+ onDeclined?.()
44
+ } catch (e) {
45
+ toast.error(apiErrorMessage(e, '操作失败'))
46
+ }
47
+ }
48
+
49
+ return (
50
+ <div className="flex min-h-svh w-full items-center justify-center bg-muted/40 p-6 md:p-10">
51
+ <div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
52
+ <h1 className="text-xl font-bold">我的邀请</h1>
53
+ <p className="mt-1 text-sm text-muted-foreground">接受后你将加入对应工作区。</p>
54
+
55
+ <div className="mt-4">
56
+ {loading ? (
57
+ <PageLoading label="加载邀请…" />
58
+ ) : error ? (
59
+ <div className="py-6 text-center text-sm text-destructive">
60
+ {apiErrorMessage(error, '邀请加载失败')}
61
+ <div className="mt-3">
62
+ <Button size="sm" variant="outline" onClick={() => refetch()}>重试</Button>
63
+ </div>
64
+ </div>
65
+ ) : pending.length === 0 ? (
66
+ <PageEmpty label="暂无待处理的邀请" hint="收到新邀请时会出现在这里" />
67
+ ) : (
68
+ <ul className="divide-y">
69
+ {pending.map((inv) => (
70
+ <li key={inv.id} className="flex items-center justify-between gap-3 py-3">
71
+ <div className="min-w-0">
72
+ <p className="truncate text-sm font-medium">
73
+ {inv.workspace_name || inv.workspace_slug || '工作区'}
74
+ </p>
75
+ <p className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
76
+ <Badge variant="secondary">{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
77
+ <span>{formatDateTime(inv.expires_at)} 过期</span>
78
+ </p>
79
+ </div>
80
+ <div className="flex shrink-0 gap-1.5">
81
+ <Button
82
+ size="sm"
83
+ variant="outline"
84
+ disabled={decline.isPending}
85
+ onClick={() => onDecline(inv.id, inv.workspace_name)}
86
+ >
87
+ <X /> 拒绝
88
+ </Button>
89
+ <Button
90
+ size="sm"
91
+ disabled={accept.isPending}
92
+ onClick={() => onAccept(inv.id, inv.workspace_name)}
93
+ >
94
+ <Check /> 接受
95
+ </Button>
96
+ </div>
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ )}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,204 @@
1
+
2
+ import { useState } from 'react';
3
+ import { z } from 'zod';
4
+ import { AuthShell } from '../../layouts/AuthShell';
5
+ import { FormField } from '../../components/FormField';
6
+ import { Button } from '../../components/ui/button';
7
+ import { Input } from '../../components/ui/input';
8
+ import { FieldDescription, FieldSeparator } from '../../components/ui/field';
9
+ import { useApi } from '@ploykit/hooks';
10
+ import { useAuth } from '@ploykit/hooks';
11
+ import { useSiteConfig } from '@ploykit/hooks';
12
+ import { useZodForm } from '@ploykit/hooks';
13
+ import { apiErrorMessage } from '../../lib/api-error';
14
+ import { toast } from '../../components/toast';
15
+ import type { PKUser } from '@ploykit/hooks';
16
+
17
+
18
+ function passwordKinds(pw: string): number {
19
+ let kinds = 0;
20
+ if (/[0-9]/.test(pw)) kinds++;
21
+ if (/[a-z]/.test(pw)) kinds++;
22
+ if (/[A-Z]/.test(pw)) kinds++;
23
+ if (/[^0-9a-zA-Z]/.test(pw)) kinds++;
24
+ return kinds;
25
+ }
26
+
27
+
28
+ export const passwordSchema = z
29
+ .string()
30
+ .min(10, '密码至少 10 位')
31
+ .refine(
32
+ (pw) => new TextEncoder().encode(pw).length <= 128,
33
+ '密码过长(按字节计最多 128,一个汉字占 3 字节)',
34
+ )
35
+ .refine(
36
+ (pw) => passwordKinds(pw) >= 3,
37
+ '需包含大写/小写/数字/符号中的至少三类',
38
+ );
39
+
40
+ export const registerSchema = z.object({
41
+
42
+ email: z.string().min(1, '请输入邮箱').max(254, '邮箱过长').email('邮箱格式不正确'),
43
+ password: passwordSchema,
44
+
45
+ display_name: z.string().max(100, '昵称最多 100 字符').optional(),
46
+ });
47
+
48
+ export type RegisterForm = z.infer<typeof registerSchema>;
49
+
50
+ export interface RegisterPageProps {
51
+
52
+ onSuccess?: () => void;
53
+
54
+ loginPath?: string;
55
+ title?: string;
56
+ description?: string;
57
+ }
58
+
59
+ export function RegisterPage({
60
+ onSuccess,
61
+ loginPath = '/login',
62
+ title = '创建账号',
63
+ description = '填写以下信息开始使用',
64
+ }: RegisterPageProps) {
65
+ const api = useApi();
66
+ const { refresh } = useAuth();
67
+
68
+
69
+
70
+ const { data: siteCfg } = useSiteConfig();
71
+ const inviteOnly = siteCfg?.allow_signup === '0';
72
+ const [submitting, setSubmitting] = useState(false);
73
+ const [serverError, setServerError] = useState<string | null>(null);
74
+
75
+ const form = useZodForm<RegisterForm>({
76
+ schema: registerSchema,
77
+ defaultValues: { email: '', password: '', display_name: '' },
78
+ });
79
+
80
+ const handleSubmit = form.handleSubmit(async (values) => {
81
+ setServerError(null);
82
+ setSubmitting(true);
83
+ try {
84
+
85
+
86
+ const body: { email: string; password: string; display_name?: string } = {
87
+ email: values.email,
88
+ password: values.password,
89
+ }
90
+ if (values.display_name?.trim()) body.display_name = values.display_name.trim()
91
+ await api.post<{ user: PKUser; expires_at: string }>(
92
+ '/auth/register',
93
+ body,
94
+ )
95
+ await refresh();
96
+ toast.success('注册成功');
97
+ if (onSuccess) onSuccess();
98
+ else window.location.href = '/app';
99
+ } catch (e) {
100
+
101
+
102
+ const msg = apiErrorMessage(e, '注册失败,请稍后重试');
103
+ setServerError(msg);
104
+ toast.error(msg);
105
+ } finally {
106
+ setSubmitting(false);
107
+ }
108
+ });
109
+
110
+ const pw = form.watch('password') ?? '';
111
+ const kinds = passwordKinds(pw);
112
+
113
+ return (
114
+ <>
115
+ {inviteOnly && (
116
+ <div className="fixed inset-x-0 top-0 z-50 border-b border-primary/25 bg-primary/10 px-4 py-2 text-center text-sm text-primary">
117
+ 当前为邀请制注册:仅白名单邮箱可完成注册(最终以服务端校验为准)
118
+ </div>
119
+ )}
120
+ <AuthShell footer={<span>注册即代表同意我们的服务条款与隐私政策</span>}>
121
+ <form onSubmit={handleSubmit} className="flex flex-col gap-6">
122
+ <div className="flex flex-col items-center gap-1 text-center">
123
+ <h1 className="text-2xl font-bold">{title}</h1>
124
+ <p className="text-sm text-balance text-muted-foreground">
125
+ {description}
126
+ </p>
127
+ </div>
128
+
129
+ <FormField
130
+ label="邮箱"
131
+ error={form.formState.errors.email?.message}
132
+ htmlFor="reg-email"
133
+ >
134
+ <Input
135
+ id="reg-email"
136
+ type="email"
137
+ placeholder="you@example.com"
138
+ autoComplete="email"
139
+ {...form.register('email')}
140
+ />
141
+ </FormField>
142
+
143
+ <FormField
144
+ label="密码"
145
+ error={form.formState.errors.password?.message}
146
+ htmlFor="reg-password"
147
+ >
148
+ <Input
149
+ id="reg-password"
150
+ type="password"
151
+ placeholder="至少 10 位,含三类字符"
152
+ autoComplete="new-password"
153
+ {...form.register('password')}
154
+ />
155
+ {pw.length > 0 && (
156
+ <FieldDescription>
157
+ 字符类别:大写 {/[A-Z]/.test(pw) ? '✓' : '—'} / 小写{' '}
158
+ {/[a-z]/.test(pw) ? '✓' : '—'} / 数字{' '}
159
+ {/[0-9]/.test(pw) ? '✓' : '—'} / 符号{' '}
160
+ {/[^0-9a-zA-Z]/.test(pw) ? '✓' : '—'}
161
+ (已满足 {kinds}/3 类)
162
+ </FieldDescription>
163
+ )}
164
+ </FormField>
165
+
166
+ <FormField
167
+ label="昵称(可选)"
168
+ error={form.formState.errors.display_name?.message}
169
+ htmlFor="reg-name"
170
+ >
171
+ <Input
172
+ id="reg-name"
173
+ type="text"
174
+ maxLength={100}
175
+ placeholder="怎么称呼你?"
176
+ {...form.register('display_name')}
177
+ />
178
+ </FormField>
179
+
180
+ {serverError && (
181
+ <p role="alert" className="text-sm text-destructive">
182
+ {serverError}
183
+ </p>
184
+ )}
185
+
186
+ <Button type="submit" disabled={submitting}>
187
+ {submitting ? '注册中…' : '创建账号'}
188
+ </Button>
189
+
190
+ <FieldSeparator>已有账号?</FieldSeparator>
191
+ <Button
192
+ type="button"
193
+ variant="outline"
194
+ onClick={() => {
195
+ window.location.href = loginPath;
196
+ }}
197
+ >
198
+ 登录
199
+ </Button>
200
+ </form>
201
+ </AuthShell>
202
+ </>
203
+ );
204
+ }
@@ -0,0 +1,160 @@
1
+
2
+ import { useEffect, useState, type FormEvent } from 'react'
3
+ import { AuthShell } from '../../layouts/AuthShell'
4
+ import { FormField } from '../../components/FormField'
5
+ import { Button } from '../../components/ui/button'
6
+ import { Input } from '../../components/ui/input'
7
+ import { usePasswordReset } from '@ploykit/hooks'
8
+ import { passwordSchema } from './RegisterPage'
9
+
10
+ export interface ResetPasswordPageProps {
11
+
12
+ onDone?: string
13
+
14
+ /** 页面标题(i18n 覆盖点,走查 #5)。 */
15
+ title?: string
16
+
17
+ /** 成功态文案(i18n 覆盖点)。 */
18
+ successHint?: string
19
+ }
20
+
21
+ const REDIRECT_DELAY_MS = 3000
22
+
23
+ export function ResetPasswordPage({
24
+ onDone,
25
+ title = '设置新密码',
26
+ successHint = '密码已重置,全部旧会话已失效。',
27
+ }: ResetPasswordPageProps) {
28
+ const { reset } = usePasswordReset()
29
+
30
+ const [params, setParams] = useState<URLSearchParams | null>(null)
31
+ const email = params?.get('email') ?? ''
32
+ const token = params?.get('token') ?? ''
33
+
34
+ const [password, setPassword] = useState('')
35
+ const [confirm, setConfirm] = useState('')
36
+ const [loading, setLoading] = useState(false)
37
+ const [done, setDone] = useState(false)
38
+ const [countdown, setCountdown] = useState(REDIRECT_DELAY_MS / 1000)
39
+
40
+ const [confirmError, setConfirmError] = useState<string | null>(null)
41
+ const [serverError, setServerError] = useState<string | null>(null)
42
+
43
+ useEffect(() => {
44
+ setParams(new URLSearchParams(window.location.search))
45
+ }, [])
46
+
47
+ useEffect(() => {
48
+ if (!done) return
49
+
50
+ try {
51
+ window.history.replaceState(null, '', window.location.pathname)
52
+ } catch {
53
+ /* replaceState 不可用(极端沙箱)时忽略——令牌已失效,仅剩地址残留 */
54
+ }
55
+ const tick = setInterval(() => setCountdown((c) => c - 1), 1000)
56
+ const jump = setTimeout(() => {
57
+ window.location.href = onDone ?? '/login'
58
+ }, REDIRECT_DELAY_MS)
59
+ return () => {
60
+ clearInterval(tick)
61
+ clearTimeout(jump)
62
+ }
63
+ }, [done, onDone])
64
+
65
+ const handleSubmit = async (e: FormEvent) => {
66
+ e.preventDefault()
67
+ setConfirmError(null)
68
+ setServerError(null)
69
+ if (!password || !confirm) return
70
+ if (password !== confirm) {
71
+
72
+ setConfirmError('两次输入的密码不一致')
73
+ return
74
+ }
75
+
76
+ const pwCheck = passwordSchema.safeParse(password)
77
+ if (!pwCheck.success) {
78
+ setServerError(pwCheck.error.issues[0]?.message ?? '密码不符合要求')
79
+ return
80
+ }
81
+ setLoading(true)
82
+ try {
83
+ await reset(email, token, password)
84
+ setDone(true)
85
+ } catch (err) {
86
+ setServerError(err instanceof Error ? err.message : '重置失败,链接可能已失效')
87
+ } finally {
88
+ setLoading(false)
89
+ }
90
+ }
91
+
92
+ return (
93
+ <AuthShell>
94
+ <form onSubmit={handleSubmit} className="space-y-4">
95
+ <div className="space-y-1 text-center">
96
+ <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
97
+ </div>
98
+
99
+ {done ? (
100
+ <div className="space-y-2 text-center" data-testid="reset-done">
101
+ <p className="text-sm text-(--success)">{successHint}</p>
102
+ <p className="text-sm text-muted-foreground">
103
+ {countdown > 0 ? `${countdown} 秒后跳转登录页…` : '正在跳转…'}
104
+ </p>
105
+ </div>
106
+ ) : params === null ? (
107
+
108
+ <p className="text-center text-sm text-muted-foreground">正在打开链接…</p>
109
+ ) : !email || !token ? (
110
+ <p className="text-center text-sm text-destructive" data-testid="reset-invalid">
111
+ 链接无效:缺少邮箱或令牌参数,请从邮件中的链接进入。
112
+ </p>
113
+ ) : (
114
+ <>
115
+ <p className="text-sm text-muted-foreground">正在为 {email} 设置新密码</p>
116
+
117
+ <FormField label="新密码" htmlFor="reset-new">
118
+ <Input
119
+ id="reset-new"
120
+ type="password"
121
+ value={password}
122
+ onChange={(e) => setPassword(e.target.value)}
123
+ placeholder="至少 10 位,含三类字符"
124
+ autoComplete="new-password"
125
+ required
126
+ />
127
+ </FormField>
128
+
129
+ <FormField
130
+ label="确认新密码"
131
+ error={confirmError ?? undefined}
132
+ htmlFor="reset-confirm"
133
+ >
134
+ <Input
135
+ id="reset-confirm"
136
+ type="password"
137
+ value={confirm}
138
+ onChange={(e) => setConfirm(e.target.value)}
139
+ placeholder="再输入一次"
140
+ autoComplete="new-password"
141
+ aria-invalid={confirmError ? true : undefined}
142
+ required
143
+ />
144
+ </FormField>
145
+
146
+ {serverError && (
147
+ <p role="alert" className="text-sm text-destructive">
148
+ {serverError}
149
+ </p>
150
+ )}
151
+
152
+ <Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
153
+ {loading ? '提交中…' : '重置密码'}
154
+ </Button>
155
+ </>
156
+ )}
157
+ </form>
158
+ </AuthShell>
159
+ )
160
+ }
@@ -0,0 +1,83 @@
1
+
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { api } from '@ploykit/client'
4
+ import { AuthShell } from '../../layouts/AuthShell'
5
+ import { buttonVariants } from '../../components/ui/button'
6
+ import {
7
+ Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
8
+ } from '../../components/ui/empty'
9
+ import { MailCheck, LoaderCircle, CircleX } from 'lucide-react'
10
+
11
+ type VerifyState = 'verifying' | 'ok' | 'failed'
12
+
13
+ export interface VerifyEmailPageProps {
14
+ /** 三态标题(i18n 覆盖点,走查 #5):成功 / 失败 / 验证中。 */
15
+ titles?: { ok?: string; failed?: string; verifying?: string }
16
+ }
17
+
18
+ export function VerifyEmailPage({
19
+ titles = { ok: '邮箱验证成功', failed: '验证失败', verifying: '正在验证…' },
20
+ }: VerifyEmailPageProps) {
21
+ const [state, setState] = useState<VerifyState>('verifying')
22
+ const [message, setMessage] = useState<string | null>(null)
23
+
24
+ const fired = useRef(false)
25
+
26
+ useEffect(() => {
27
+ if (fired.current) return
28
+ fired.current = true
29
+
30
+ const params = new URLSearchParams(window.location.search)
31
+ const email = params.get('email') ?? ''
32
+ const token = params.get('token') ?? ''
33
+ if (!email || !token) {
34
+ setState('failed')
35
+ setMessage('链接无效:缺少邮箱或令牌参数')
36
+ return
37
+ }
38
+ api
39
+ .post('/auth/verify-email', { email, token })
40
+ .then(() => {
41
+
42
+ try {
43
+ window.history.replaceState(null, '', window.location.pathname)
44
+ } catch {
45
+ /* replaceState 不可用时忽略——令牌已失效 */
46
+ }
47
+ setState('ok')
48
+ })
49
+ .catch((err: unknown) => {
50
+ setState('failed')
51
+ setMessage(err instanceof Error ? err.message : '验证失败,链接可能已失效')
52
+ })
53
+ }, [])
54
+
55
+ return (
56
+ <AuthShell>
57
+ <Empty className="border-none p-0">
58
+ <EmptyHeader>
59
+ <EmptyMedia variant="icon" data-testid="verify-state">
60
+ {state === 'ok' && <MailCheck className="text-(--success)" aria-hidden="true" />}
61
+ {state === 'verifying' && <LoaderCircle className="animate-spin" aria-hidden="true" />}
62
+ {state === 'failed' && <CircleX className="text-destructive" aria-hidden="true" />}
63
+ </EmptyMedia>
64
+ <EmptyTitle>
65
+ {state === 'ok' ? titles.ok : state === 'failed' ? titles.failed : titles.verifying}
66
+ </EmptyTitle>
67
+ <EmptyDescription>
68
+ {state === 'ok'
69
+ ? '感谢确认,现在可以正常登录了。'
70
+ : state === 'failed'
71
+ ? (message ?? '验证失败')
72
+ : '请稍候,正在校验邮件链接…'}
73
+ </EmptyDescription>
74
+ </EmptyHeader>
75
+ <EmptyContent>
76
+ <a href="/login" className={buttonVariants({ variant: 'outline' })}>
77
+ 返回登录
78
+ </a>
79
+ </EmptyContent>
80
+ </Empty>
81
+ </AuthShell>
82
+ )
83
+ }
@@ -0,0 +1,33 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { MemoryRouter } from 'react-router'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ // renderx-walk #5: every auth page exposes i18n override props — titles at
7
+ // minimum, so overseas products are not stuck with hardcoded copy.
8
+ vi.mock('@ploykit/client', () => ({
9
+ api: {
10
+ get: vi.fn(),
11
+ // never-settling promise keeps VerifyEmailPage in its 'verifying' state
12
+ post: vi.fn(() => new Promise(() => {})),
13
+ },
14
+ }))
15
+ vi.mock('../../../../hooks/src/hooks/usePasswordReset', () => ({
16
+ usePasswordReset: () => ({ reset: vi.fn() }),
17
+ }))
18
+
19
+ import { ResetPasswordPage } from '../ResetPasswordPage'
20
+ import { VerifyEmailPage } from '../VerifyEmailPage'
21
+ // imports after mocks (mock hoisting applies regardless of position)
22
+
23
+ describe('auth 页 i18n 覆盖面(走查 #5)', () => {
24
+ it('ResetPasswordPage:title/successHint 可覆盖', () => {
25
+ render(<ResetPasswordPage title="Set a new password" successHint="Password reset; old sessions revoked." />)
26
+ expect(screen.getByText('Set a new password')).toBeInTheDocument()
27
+ })
28
+
29
+ it('VerifyEmailPage:三态标题可覆盖(无参渲染即 failed 态)', () => {
30
+ render(<MemoryRouter><VerifyEmailPage titles={{ failed: 'Verification failed' }} /></MemoryRouter>)
31
+ expect(screen.getByText('Verification failed')).toBeInTheDocument()
32
+ })
33
+ })
@@ -0,0 +1,67 @@
1
+
2
+ import '@testing-library/jest-dom/vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { ForgotPasswordPage } from '../ForgotPasswordPage'
6
+
7
+ const mockedPost = vi.hoisted(() => vi.fn())
8
+ vi.mock('@ploykit/client', () => ({
9
+ api: {
10
+ post: mockedPost,
11
+ get: vi.fn(),
12
+ put: vi.fn(),
13
+ patch: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ }))
17
+
18
+ beforeEach(() => {
19
+ mockedPost.mockReset().mockResolvedValue(undefined)
20
+ })
21
+
22
+ describe('ForgotPasswordPage', () => {
23
+ it('提交邮箱 → POST /auth/forgot-password {email}', async () => {
24
+ render(<ForgotPasswordPage />)
25
+ fireEvent.change(screen.getByLabelText('邮箱'), {
26
+ target: { value: 'user@example.com' },
27
+ })
28
+ fireEvent.click(screen.getByRole('button', { name: '发送重置链接' }))
29
+
30
+ await waitFor(() =>
31
+ expect(mockedPost).toHaveBeenCalledWith('/auth/forgot-password', {
32
+ email: 'user@example.com',
33
+ }),
34
+ )
35
+ })
36
+
37
+ it('防枚举:无论结果,成功提示同一句(不区分邮箱是否存在)', async () => {
38
+ render(<ForgotPasswordPage />)
39
+ fireEvent.change(screen.getByLabelText('邮箱'), {
40
+ target: { value: 'whoever@example.com' },
41
+ })
42
+ fireEvent.click(screen.getByRole('button', { name: '发送重置链接' }))
43
+
44
+ expect(await screen.findByTestId('sent-hint')).toHaveTextContent(
45
+ '如果该邮箱存在,重置链接已发送,请查收邮箱。',
46
+ )
47
+ })
48
+
49
+ it('请求失败 → 展示错误(不伪装成已发送)', async () => {
50
+ mockedPost.mockRejectedValueOnce(new Error('发送过于频繁'))
51
+ render(<ForgotPasswordPage />)
52
+ fireEvent.change(screen.getByLabelText('邮箱'), {
53
+ target: { value: 'user@example.com' },
54
+ })
55
+ fireEvent.click(screen.getByRole('button', { name: '发送重置链接' }))
56
+
57
+ expect(await screen.findByText('发送过于频繁')).toBeInTheDocument()
58
+ expect(screen.queryByTestId('sent-hint')).not.toBeInTheDocument()
59
+ })
60
+
61
+ it('空邮箱不可提交(按钮禁用)', () => {
62
+ render(<ForgotPasswordPage />)
63
+ expect(
64
+ screen.getByRole('button', { name: '发送重置链接' }),
65
+ ).toBeDisabled()
66
+ })
67
+ })