@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,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,150 @@
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
+
15
+ const REDIRECT_DELAY_MS = 3000
16
+
17
+ export function ResetPasswordPage({ onDone }: ResetPasswordPageProps) {
18
+ const { reset } = usePasswordReset()
19
+
20
+ const [params, setParams] = useState<URLSearchParams | null>(null)
21
+ const email = params?.get('email') ?? ''
22
+ const token = params?.get('token') ?? ''
23
+
24
+ const [password, setPassword] = useState('')
25
+ const [confirm, setConfirm] = useState('')
26
+ const [loading, setLoading] = useState(false)
27
+ const [done, setDone] = useState(false)
28
+ const [countdown, setCountdown] = useState(REDIRECT_DELAY_MS / 1000)
29
+
30
+ const [confirmError, setConfirmError] = useState<string | null>(null)
31
+ const [serverError, setServerError] = useState<string | null>(null)
32
+
33
+ useEffect(() => {
34
+ setParams(new URLSearchParams(window.location.search))
35
+ }, [])
36
+
37
+ useEffect(() => {
38
+ if (!done) return
39
+
40
+ try {
41
+ window.history.replaceState(null, '', window.location.pathname)
42
+ } catch {
43
+ /* replaceState 不可用(极端沙箱)时忽略——令牌已失效,仅剩地址残留 */
44
+ }
45
+ const tick = setInterval(() => setCountdown((c) => c - 1), 1000)
46
+ const jump = setTimeout(() => {
47
+ window.location.href = onDone ?? '/login'
48
+ }, REDIRECT_DELAY_MS)
49
+ return () => {
50
+ clearInterval(tick)
51
+ clearTimeout(jump)
52
+ }
53
+ }, [done, onDone])
54
+
55
+ const handleSubmit = async (e: FormEvent) => {
56
+ e.preventDefault()
57
+ setConfirmError(null)
58
+ setServerError(null)
59
+ if (!password || !confirm) return
60
+ if (password !== confirm) {
61
+
62
+ setConfirmError('两次输入的密码不一致')
63
+ return
64
+ }
65
+
66
+ const pwCheck = passwordSchema.safeParse(password)
67
+ if (!pwCheck.success) {
68
+ setServerError(pwCheck.error.issues[0]?.message ?? '密码不符合要求')
69
+ return
70
+ }
71
+ setLoading(true)
72
+ try {
73
+ await reset(email, token, password)
74
+ setDone(true)
75
+ } catch (err) {
76
+ setServerError(err instanceof Error ? err.message : '重置失败,链接可能已失效')
77
+ } finally {
78
+ setLoading(false)
79
+ }
80
+ }
81
+
82
+ return (
83
+ <AuthShell>
84
+ <form onSubmit={handleSubmit} className="space-y-4">
85
+ <div className="space-y-1 text-center">
86
+ <h1 className="text-2xl font-bold tracking-tight">设置新密码</h1>
87
+ </div>
88
+
89
+ {done ? (
90
+ <div className="space-y-2 text-center" data-testid="reset-done">
91
+ <p className="text-sm text-(--success)">密码已重置,全部旧会话已失效。</p>
92
+ <p className="text-sm text-muted-foreground">
93
+ {countdown > 0 ? `${countdown} 秒后跳转登录页…` : '正在跳转…'}
94
+ </p>
95
+ </div>
96
+ ) : params === null ? (
97
+
98
+ <p className="text-center text-sm text-muted-foreground">正在打开链接…</p>
99
+ ) : !email || !token ? (
100
+ <p className="text-center text-sm text-destructive" data-testid="reset-invalid">
101
+ 链接无效:缺少邮箱或令牌参数,请从邮件中的链接进入。
102
+ </p>
103
+ ) : (
104
+ <>
105
+ <p className="text-sm text-muted-foreground">正在为 {email} 设置新密码</p>
106
+
107
+ <FormField label="新密码" htmlFor="reset-new">
108
+ <Input
109
+ id="reset-new"
110
+ type="password"
111
+ value={password}
112
+ onChange={(e) => setPassword(e.target.value)}
113
+ placeholder="至少 10 位,含三类字符"
114
+ autoComplete="new-password"
115
+ required
116
+ />
117
+ </FormField>
118
+
119
+ <FormField
120
+ label="确认新密码"
121
+ error={confirmError ?? undefined}
122
+ htmlFor="reset-confirm"
123
+ >
124
+ <Input
125
+ id="reset-confirm"
126
+ type="password"
127
+ value={confirm}
128
+ onChange={(e) => setConfirm(e.target.value)}
129
+ placeholder="再输入一次"
130
+ autoComplete="new-password"
131
+ aria-invalid={confirmError ? true : undefined}
132
+ required
133
+ />
134
+ </FormField>
135
+
136
+ {serverError && (
137
+ <p role="alert" className="text-sm text-destructive">
138
+ {serverError}
139
+ </p>
140
+ )}
141
+
142
+ <Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
143
+ {loading ? '提交中…' : '重置密码'}
144
+ </Button>
145
+ </>
146
+ )}
147
+ </form>
148
+ </AuthShell>
149
+ )
150
+ }
@@ -0,0 +1,76 @@
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 function VerifyEmailPage() {
14
+ const [state, setState] = useState<VerifyState>('verifying')
15
+ const [message, setMessage] = useState<string | null>(null)
16
+
17
+ const fired = useRef(false)
18
+
19
+ useEffect(() => {
20
+ if (fired.current) return
21
+ fired.current = true
22
+
23
+ const params = new URLSearchParams(window.location.search)
24
+ const email = params.get('email') ?? ''
25
+ const token = params.get('token') ?? ''
26
+ if (!email || !token) {
27
+ setState('failed')
28
+ setMessage('链接无效:缺少邮箱或令牌参数')
29
+ return
30
+ }
31
+ api
32
+ .post('/auth/verify-email', { email, token })
33
+ .then(() => {
34
+
35
+ try {
36
+ window.history.replaceState(null, '', window.location.pathname)
37
+ } catch {
38
+ /* replaceState 不可用时忽略——令牌已失效 */
39
+ }
40
+ setState('ok')
41
+ })
42
+ .catch((err: unknown) => {
43
+ setState('failed')
44
+ setMessage(err instanceof Error ? err.message : '验证失败,链接可能已失效')
45
+ })
46
+ }, [])
47
+
48
+ return (
49
+ <AuthShell>
50
+ <Empty className="border-none p-0">
51
+ <EmptyHeader>
52
+ <EmptyMedia variant="icon" data-testid="verify-state">
53
+ {state === 'ok' && <MailCheck className="text-(--success)" aria-hidden="true" />}
54
+ {state === 'verifying' && <LoaderCircle className="animate-spin" aria-hidden="true" />}
55
+ {state === 'failed' && <CircleX className="text-destructive" aria-hidden="true" />}
56
+ </EmptyMedia>
57
+ <EmptyTitle>
58
+ {state === 'ok' ? '邮箱验证成功' : state === 'failed' ? '验证失败' : '正在验证…'}
59
+ </EmptyTitle>
60
+ <EmptyDescription>
61
+ {state === 'ok'
62
+ ? '感谢确认,现在可以正常登录了。'
63
+ : state === 'failed'
64
+ ? (message ?? '验证失败')
65
+ : '请稍候,正在校验邮件链接…'}
66
+ </EmptyDescription>
67
+ </EmptyHeader>
68
+ <EmptyContent>
69
+ <a href="/login" className={buttonVariants({ variant: 'outline' })}>
70
+ 返回登录
71
+ </a>
72
+ </EmptyContent>
73
+ </Empty>
74
+ </AuthShell>
75
+ )
76
+ }
@@ -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
+ })
@@ -0,0 +1,86 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { MemoryRouter, Routes, Route } from 'react-router'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { InviteAcceptPage } from '../InviteAcceptPage'
6
+
7
+ const invMocks = vi.hoisted(() => ({
8
+ accept: { mutateAsync: vi.fn(), isPending: false },
9
+ decline: { mutateAsync: vi.fn(), isPending: false },
10
+ }))
11
+ vi.mock('../../../../../hooks/src/hooks/useMembers', () => ({
12
+ useMyInvitations: () => ({
13
+ invitations: [
14
+ {
15
+ id: 'inv-1', workspace_id: 'ws-9', workspace_name: 'Beta Team',
16
+ workspace_slug: 'beta', email: 'me@example.com', role: 'admin',
17
+ status: 'pending', expires_at: '2026-12-01T00:00:00Z', created_at: '2026-06-01T00:00:00Z',
18
+ },
19
+ {
20
+ id: 'inv-2', workspace_id: 'ws-8', workspace_name: 'Old Team',
21
+ workspace_slug: 'old', email: 'me@example.com', role: 'member',
22
+ status: 'accepted', expires_at: '2026-12-01T00:00:00Z', created_at: '2026-06-01T00:00:00Z',
23
+ },
24
+ ],
25
+ loading: false,
26
+ error: null,
27
+ refetch: vi.fn(),
28
+ accept: invMocks.accept,
29
+ decline: invMocks.decline,
30
+ }),
31
+ }))
32
+
33
+ const toastMocks = vi.hoisted(() => ({
34
+ success: vi.fn(), error: vi.fn(), info: vi.fn(),
35
+ }))
36
+ vi.mock('../../../components/toast', () => ({ toast: toastMocks }))
37
+
38
+ function renderPage(props: Parameters<typeof InviteAcceptPage>[0] = {}) {
39
+ return render(
40
+ <MemoryRouter initialEntries={['/invitations']}>
41
+ <Routes>
42
+ <Route path="/invitations" element={<InviteAcceptPage {...props} />} />
43
+ <Route path="/app" element={<div>app-page</div>} />
44
+ </Routes>
45
+ </MemoryRouter>,
46
+ )
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks()
51
+ invMocks.accept.mutateAsync.mockResolvedValue({ id: 'ws-9', name: 'Beta Team' })
52
+ invMocks.decline.mutateAsync.mockResolvedValue({})
53
+ })
54
+
55
+ describe('InviteAcceptPage', () => {
56
+ it('只列 pending 邀请(工作区名 / 角色徽标 / 过期时间)', () => {
57
+ renderPage()
58
+ expect(screen.getByText('Beta Team')).toBeInTheDocument()
59
+ expect(screen.getByText('管理员')).toBeInTheDocument()
60
+ expect(screen.queryByText('Old Team')).not.toBeInTheDocument()
61
+ expect(screen.getByRole('button', { name: /接受/ })).toBeInTheDocument()
62
+ expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument()
63
+ })
64
+
65
+ it('接受 → POST accept(hook 内部失效 workspaceList)+ onAccepted 回调', async () => {
66
+ const onAccepted = vi.fn()
67
+ renderPage({ onAccepted })
68
+ fireEvent.click(screen.getByRole('button', { name: /接受/ }))
69
+ await waitFor(() => expect(invMocks.accept.mutateAsync).toHaveBeenCalledWith('inv-1'))
70
+ await waitFor(() => expect(onAccepted).toHaveBeenCalledWith('ws-9'))
71
+ expect(screen.queryByText('app-page')).not.toBeInTheDocument()
72
+ })
73
+
74
+ it('无 onAccepted 时默认跳 /app', async () => {
75
+ renderPage()
76
+ fireEvent.click(screen.getByRole('button', { name: /接受/ }))
77
+ await waitFor(() => expect(screen.getByText('app-page')).toBeInTheDocument())
78
+ })
79
+
80
+ it('拒绝 → decline.mutateAsync(id),不跳转', async () => {
81
+ renderPage()
82
+ fireEvent.click(screen.getByRole('button', { name: /拒绝/ }))
83
+ await waitFor(() => expect(invMocks.decline.mutateAsync).toHaveBeenCalledWith('inv-1'))
84
+ expect(toastMocks.success).toHaveBeenCalledWith('已拒绝「Beta Team」的邀请')
85
+ })
86
+ })