@allbluecn/web-app 0.10.3 → 0.11.1

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 (246) hide show
  1. package/package.json +24 -11
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -20,9 +20,10 @@ import { z } from 'zod'
20
20
  import { prisma } from '@allbluecn/database'
21
21
  import { randomBytes } from 'crypto'
22
22
  import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
23
+ import { sendTemplateEmail } from '@/server/email'
23
24
 
24
25
  const forgotPasswordSchema = z.object({
25
- email: z.string().email('请输入有效邮箱'),
26
+ email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email('请输入有效邮箱')),
26
27
  })
27
28
 
28
29
  function generateToken(): string {
@@ -35,20 +36,39 @@ export const forgotPasswordFn = createServerFn({ method: 'POST' })
35
36
  await checkAuthRateLimit('forgotPassword', 5, 15 * 60 * 1000)
36
37
  const user = await prisma.user.findUnique({
37
38
  where: { email: data.email },
38
- select: { id: true },
39
+ select: { id: true, email: true, locale: true },
39
40
  })
40
41
 
41
42
  if (user) {
42
43
  const token = generateToken()
43
44
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
44
45
 
46
+ let created = false
45
47
  try {
46
48
  await prisma.passwordResetToken.create({
47
49
  data: { token, userId: user.id, expiresAt },
48
50
  })
51
+ created = true
49
52
  } catch (dbError) {
50
53
  console.error('Database error:', dbError instanceof Error ? dbError.message : String(dbError))
51
54
  }
55
+
56
+ if (created) {
57
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
58
+ await sendTemplateEmail({
59
+ key: 'auth.reset-password',
60
+ to: user.email,
61
+ locale: user.locale ?? 'zh',
62
+ userId: user.id,
63
+ vars: { url: `${appUrl}/reset-password?token=${token}` },
64
+ }).catch((err) => {
65
+ console.error('reset password email failed', {
66
+ userId: user.id,
67
+ templateKey: 'auth.reset-password',
68
+ reason: err instanceof Error ? err.message : String(err),
69
+ })
70
+ })
71
+ }
52
72
  }
53
73
 
54
74
  return {
@@ -16,12 +16,13 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
+ import { getResolvedOAuthProviders } from '@/server/oauth-config'
19
20
 
20
21
  export interface AuthProvidersInfo {
21
22
  googleEnabled: boolean
22
23
  }
23
24
 
24
25
  export const getAuthProviders = createServerFn({ method: 'GET' }).handler(async (): Promise<AuthProvidersInfo> => {
25
- const googleEnabled = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET)
26
- return { googleEnabled }
27
- })
26
+ const providers = await getResolvedOAuthProviders()
27
+ return { googleEnabled: providers.some((p) => p.provider === 'google') }
28
+ })
@@ -17,22 +17,26 @@
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
19
  import { z } from 'zod'
20
- import { prisma } from '@allbluecn/database'
20
+ import { prisma, Prisma } from '@allbluecn/database'
21
21
  import bcrypt from 'bcryptjs'
22
22
  import { getRandomConfig, presetBgColors } from '@/components/settings/personal-team/account/avatar/avatar-config'
23
23
  import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
24
+ import { getRegistrationMode, consumeInvitationCode } from '@/server/auth-registration'
25
+ import { sendTemplateEmail, generateEmailToken } from '@/server/email'
24
26
 
25
- const registerSchema = z.object({
26
- username: z.string().min(3).max(20),
27
- email: z.string().email(),
28
- password: z.string().min(8),
29
- invitationCode: z.string().optional(),
27
+ const registerServerSchema = z.object({
28
+ username: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/),
29
+ email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email()),
30
+ password: z.string().min(8).max(32).regex(/[a-zA-Z]/).regex(/[0-9]/),
31
+ invitationCode: z.string().optional().default(''),
30
32
  })
31
33
 
32
34
  export const registerFn = createServerFn({ method: 'POST' })
33
- .validator(registerSchema)
35
+ .validator(registerServerSchema)
34
36
  .handler(async ({ data }) => {
35
37
  await checkAuthRateLimit('register', 5, 15 * 60 * 1000)
38
+ const mode = await getRegistrationMode()
39
+
36
40
  const exists = await prisma.user.findFirst({
37
41
  where: { OR: [{ email: data.email }, { username: data.username }] },
38
42
  select: { id: true },
@@ -43,16 +47,56 @@ export const registerFn = createServerFn({ method: 'POST' })
43
47
  const hash = data.username.split('').reduce((h, c) => c.charCodeAt(0) + ((h << 5) - h), 0)
44
48
  const bgColor = presetBgColors[Math.abs(hash) % presetBgColors.length]
45
49
 
46
- const user = await prisma.user.create({
47
- data: {
48
- username: data.username,
49
- email: data.email,
50
- password: hashedPassword,
51
- avatarConfig: getRandomConfig() as object,
52
- bgShape: 'square',
53
- bgColor,
54
- status: 'ACTIVE',
55
- },
56
- })
57
- return { id: user.id, email: user.email, username: user.username }
50
+ try {
51
+ const user = await prisma.$transaction(async (tx) => {
52
+ let codeId: string | null = null
53
+ if (mode === 'invite') {
54
+ if (!data.invitationCode) throw new Error('请输入邀请码')
55
+ const consumed = await consumeInvitationCode(tx, data.invitationCode)
56
+ if (!consumed.ok) throw new Error(consumed.error)
57
+ codeId = consumed.codeId
58
+ }
59
+ const created = await tx.user.create({
60
+ data: {
61
+ username: data.username,
62
+ email: data.email,
63
+ password: hashedPassword,
64
+ avatarConfig: getRandomConfig() as object,
65
+ bgShape: 'square',
66
+ bgColor,
67
+ status: 'ACTIVE',
68
+ },
69
+ })
70
+ if (codeId) {
71
+ await tx.invitationUse.create({ data: { codeId, userId: created.id } })
72
+ }
73
+ return created
74
+ })
75
+
76
+ const token = generateEmailToken()
77
+ await prisma.emailVerificationToken.create({
78
+ data: { token, userId: user.id, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
79
+ })
80
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
81
+ await sendTemplateEmail({
82
+ key: 'auth.verify-email',
83
+ to: user.email,
84
+ locale: 'zh',
85
+ userId: user.id,
86
+ vars: { url: `${appUrl}/verify-email?token=${token}`, username: user.username },
87
+ }).catch((err) => {
88
+ console.error('verification email failed', {
89
+ userId: user.id,
90
+ templateKey: 'auth.verify-email',
91
+ reason: err instanceof Error ? err.message : String(err),
92
+ })
93
+ })
94
+
95
+ return { id: user.id, email: user.email, username: user.username }
96
+ } catch (err) {
97
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
98
+ throw new Error('用户名或邮箱已存在')
99
+ }
100
+ throw new Error(err instanceof Error ? err.message : '注册失败')
101
+ }
58
102
  })
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { getRegistrationMode } from '@/server/auth-registration'
20
+
21
+ export const getRegistrationModeFn = createServerFn({ method: 'GET' }).handler(
22
+ async () => ({ mode: await getRegistrationMode() }),
23
+ )
@@ -23,7 +23,7 @@ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
23
23
 
24
24
  const resetPasswordSchema = z.object({
25
25
  token: z.string().min(1, 'Token is required'),
26
- password: z.string().min(8, '密码至少 8 位').max(32),
26
+ password: z.string().min(8, '密码至少 8 位').max(32).regex(/[a-zA-Z]/, '密码需包含字母').regex(/[0-9]/, '密码需包含数字'),
27
27
  })
28
28
 
29
29
  export const validateResetTokenFn = createServerFn({ method: 'GET' })
@@ -16,34 +16,17 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
+ import { getRequest } from '@tanstack/react-start/server'
19
20
  import { z } from 'zod'
20
- import { prisma } from '@allbluecn/database'
21
- import { requireAdmin } from '@/server/auth-helpers'
21
+ import { getCurrentSession } from '@/lib/session'
22
+ import { ensureVerifyReminder } from '@/server/notifications/verify-reminder'
22
23
 
23
- const DEFAULT_CODE = '123123'
24
-
25
- export const createDefaultInvitationFn = createServerFn({ method: 'POST' })
24
+ export const ensureVerifyReminderFn = createServerFn({ method: 'POST' })
26
25
  .validator(z.object({}))
27
26
  .handler(async () => {
28
- const hasPermission = true
29
-
30
- const existing = await prisma.invitationCode.findUnique({
31
- where: { code: DEFAULT_CODE },
32
- })
33
- if (existing) {
34
- return { message: 'Invitation code already exists', code: existing }
35
- }
36
-
37
- const expiresAt = new Date()
38
- expiresAt.setDate(expiresAt.getDate() + 1)
39
-
40
- const invCode = await prisma.invitationCode.create({
41
- data: {
42
- code: DEFAULT_CODE,
43
- maxUses: 1,
44
- expiresAt,
45
- },
46
- })
47
-
48
- return { message: 'Default invitation code created', code: invCode }
27
+ const session = await getCurrentSession(getRequest())
28
+ const userId = session?.user?.id
29
+ if (!userId) return { reminded: false }
30
+ await ensureVerifyReminder(userId)
31
+ return { reminded: true }
49
32
  })
@@ -0,0 +1,119 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const sendTemplateEmailMock = vi.hoisted(() => vi.fn().mockResolvedValue({ sent: true }))
4
+
5
+ const emailSubscriberMock = vi.hoisted(() => ({
6
+ findUnique: vi.fn(),
7
+ upsert: vi.fn(),
8
+ updateMany: vi.fn(),
9
+ }))
10
+
11
+ vi.mock('@allbluecn/database', () => ({
12
+ prisma: { emailSubscriber: emailSubscriberMock },
13
+ }))
14
+
15
+ vi.mock('@/server/middlewares/rate-limit', () => ({
16
+ checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
17
+ }))
18
+
19
+ vi.mock('@/server/email', () => ({
20
+ sendTemplateEmail: (...args: unknown[]) => sendTemplateEmailMock(...args),
21
+ generateEmailToken: () => 'tok-' + 'x'.repeat(64),
22
+ }))
23
+
24
+ vi.mock('@tanstack/react-start', () => ({
25
+ createServerFn: () => {
26
+ let validator: { safeParse: (d: unknown) => { success: boolean; data?: unknown } } | undefined
27
+ let handler: ((args: { data: unknown }) => Promise<unknown>) | undefined
28
+ const chain = {
29
+ validator(v: unknown) {
30
+ validator = v as { safeParse: (d: unknown) => { success: boolean; data?: unknown } }
31
+ return chain
32
+ },
33
+ handler(h: unknown) {
34
+ handler = h as (args: { data: unknown }) => Promise<unknown>
35
+ return async (args: { data: unknown }) => {
36
+ if (validator) {
37
+ const parsed = validator.safeParse(args.data)
38
+ if (!parsed.success) throw new Error('zod validation failed')
39
+ args = { data: parsed.data }
40
+ }
41
+ return handler!(args)
42
+ }
43
+ },
44
+ }
45
+ return chain
46
+ },
47
+ }))
48
+
49
+ import { subscribeChangelogFn, confirmChangelogSubscriptionFn } from '../subscribe'
50
+
51
+ const invoke = async (fn: unknown, data: unknown) =>
52
+ (fn as (args: { data: unknown }) => Promise<unknown>)({ data })
53
+
54
+ beforeEach(() => {
55
+ vi.clearAllMocks()
56
+ })
57
+
58
+ describe('subscribeChangelogFn', () => {
59
+ it('已 confirmed 且未退订 → already,不发邮件', async () => {
60
+ emailSubscriberMock.findUnique.mockResolvedValue({
61
+ id: '1', email: 'a@b.com', confirmedAt: new Date(), unsubscribedAt: null,
62
+ })
63
+ const r = await invoke(subscribeChangelogFn, { email: 'A@B.com' })
64
+ expect(r).toEqual({ status: 'already' })
65
+ expect(emailSubscriberMock.upsert).not.toHaveBeenCalled()
66
+ expect(sendTemplateEmailMock).not.toHaveBeenCalled()
67
+ })
68
+
69
+ it('未 confirmed → upsert 新 token 并发确认邮件', async () => {
70
+ emailSubscriberMock.findUnique.mockResolvedValue({
71
+ id: '1', email: 'a@b.com', confirmedAt: null, unsubscribedAt: null,
72
+ })
73
+ emailSubscriberMock.upsert.mockResolvedValue({})
74
+ const r = await invoke(subscribeChangelogFn, { email: 'a@b.com', locale: 'en' })
75
+ expect(r).toEqual({ status: 'pending' })
76
+ const upsert = emailSubscriberMock.upsert.mock.calls[0][0]
77
+ expect(upsert.where).toEqual({ email: 'a@b.com' })
78
+ expect(upsert.update.confirmToken).toBe('tok-' + 'x'.repeat(64))
79
+ expect(upsert.update.locale).toBe('en')
80
+ expect(upsert.update.createdAt).toBeInstanceOf(Date)
81
+ const email = sendTemplateEmailMock.mock.calls[0][0]
82
+ expect(email.key).toBe('product.changelog-confirm')
83
+ expect(email.to).toBe('a@b.com')
84
+ expect(email.locale).toBe('en')
85
+ expect(String(email.vars.confirmUrl)).toContain('/changelog/confirm?token=tok-')
86
+ })
87
+
88
+ it('已退订 → 重新发确认邮件', async () => {
89
+ emailSubscriberMock.findUnique.mockResolvedValue({
90
+ id: '1', email: 'a@b.com', confirmedAt: new Date(), unsubscribedAt: new Date(),
91
+ })
92
+ emailSubscriberMock.upsert.mockResolvedValue({})
93
+ const r = await invoke(subscribeChangelogFn, { email: 'a@b.com' })
94
+ expect(r).toEqual({ status: 'pending' })
95
+ expect(sendTemplateEmailMock).toHaveBeenCalledTimes(1)
96
+ })
97
+
98
+ it('email 为空 → zod 拒绝', async () => {
99
+ await expect(invoke(subscribeChangelogFn, { email: 'nope' })).rejects.toThrow()
100
+ })
101
+ })
102
+
103
+ describe('confirmChangelogSubscriptionFn', () => {
104
+ it('updateMany 命中 1 行 → ok', async () => {
105
+ emailSubscriberMock.updateMany.mockResolvedValue({ count: 1 })
106
+ const r = await invoke(confirmChangelogSubscriptionFn, { token: 't' })
107
+ expect(r).toEqual({ ok: true })
108
+ const where = emailSubscriberMock.updateMany.mock.calls[0][0].where
109
+ expect(where.confirmToken).toBe('t')
110
+ expect(where.confirmedAt).toBeNull()
111
+ expect(where.createdAt.gte).toBeInstanceOf(Date)
112
+ })
113
+
114
+ it('token 无效或已确认或过期 → count 0 → not ok(防并发双写)', async () => {
115
+ emailSubscriberMock.updateMany.mockResolvedValue({ count: 0 })
116
+ const r = await invoke(confirmChangelogSubscriptionFn, { token: 'bad' })
117
+ expect(r).toEqual({ ok: false })
118
+ })
119
+ })
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { z } from 'zod'
20
+ import { prisma } from '@allbluecn/database'
21
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
22
+ import { sendTemplateEmail, generateEmailToken } from '@/server/email'
23
+
24
+ const CONFIRM_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
25
+ const CONFIRM_TEMPLATE_KEY = 'product.changelog-confirm'
26
+
27
+ const appUrl = () => process.env.VITE_APP_URL ?? 'http://localhost:3000'
28
+
29
+ const normalizeLocale = (locale: string | undefined) => (locale === 'en' ? 'en' : 'zh')
30
+
31
+ export interface ChangelogSubscribeResult {
32
+ status: 'already' | 'pending'
33
+ }
34
+
35
+ export const subscribeChangelogFn = createServerFn({ method: 'POST' })
36
+ .validator(
37
+ z.object({
38
+ email: z.string().email().transform((v) => v.toLowerCase()),
39
+ locale: z.string().optional(),
40
+ }),
41
+ )
42
+ .handler(async ({ data }): Promise<ChangelogSubscribeResult> => {
43
+ await checkAuthRateLimit('changelogSubscribe', 5, 15 * 60 * 1000)
44
+
45
+ const email = data.email
46
+ const existing = await prisma.emailSubscriber.findUnique({ where: { email } })
47
+ if (existing?.confirmedAt && !existing.unsubscribedAt) {
48
+ return { status: 'already' }
49
+ }
50
+
51
+ const confirmToken = generateEmailToken()
52
+ const locale = normalizeLocale(data.locale)
53
+ const now = new Date()
54
+ await prisma.emailSubscriber.upsert({
55
+ where: { email },
56
+ create: { email, locale, confirmToken },
57
+ // 重订阅时清零 unsubscribedAt,让已退订用户重新订阅后回到「已确认」状态(confirmedAt 保留)。
58
+ update: { confirmToken, locale, createdAt: now, unsubscribedAt: null },
59
+ })
60
+
61
+ await sendTemplateEmail({
62
+ key: CONFIRM_TEMPLATE_KEY,
63
+ to: email,
64
+ locale,
65
+ vars: { confirmUrl: `${appUrl()}/changelog/confirm?token=${confirmToken}` },
66
+ }).catch((err) => {
67
+ console.error('changelog confirm email failed', {
68
+ templateKey: CONFIRM_TEMPLATE_KEY,
69
+ reason: err instanceof Error ? err.message : String(err),
70
+ })
71
+ })
72
+
73
+ return { status: 'pending' }
74
+ })
75
+
76
+ export interface ChangelogConfirmResult {
77
+ ok: boolean
78
+ }
79
+
80
+ export const confirmChangelogSubscriptionFn = createServerFn({ method: 'GET' })
81
+ .validator(z.object({ token: z.string().min(1) }))
82
+ .handler(async ({ data }): Promise<ChangelogConfirmResult> => {
83
+ const floor = new Date(Date.now() - CONFIRM_TOKEN_TTL_MS)
84
+ const claimed = await prisma.emailSubscriber.updateMany({
85
+ where: { confirmToken: data.token, confirmedAt: null, createdAt: { gte: floor } },
86
+ data: { confirmedAt: new Date() },
87
+ })
88
+ return { ok: claimed.count === 1 }
89
+ })
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { z } from 'zod'
20
+ import { prisma } from '@allbluecn/database'
21
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
22
+ import { verifyUnsubscribeToken } from '@/server/email/deliverability'
23
+
24
+ export interface UnsubscribeByEmailTokenResult {
25
+ ok: boolean
26
+ mechanism?: 'user' | 'subscriber'
27
+ }
28
+
29
+ export const unsubscribeByEmailTokenFn = createServerFn({ method: 'GET' })
30
+ .validator(z.object({ token: z.string().min(1) }))
31
+ .handler(async ({ data }): Promise<UnsubscribeByEmailTokenResult> => {
32
+ // 防 token 爆破扫描:单 IP 15 分钟内最多 20 次
33
+ await checkAuthRateLimit('unsubscribe', 20, 15 * 60 * 1000)
34
+
35
+ // 机制 1:注册用户 HMAC 无状态 token(对 userId+email+category 签名,10 年有效)
36
+ // 命中即 upsert EmailPreference,一键全退(本页不做分类精确退订)
37
+ const payload = verifyUnsubscribeToken(data.token)
38
+ if (payload) {
39
+ await prisma.emailPreference.upsert({
40
+ where: { userId: payload.userId },
41
+ create: { userId: payload.userId, unsubscribedAll: true },
42
+ update: { unsubscribedAll: true },
43
+ })
44
+ return { ok: true, mechanism: 'user' }
45
+ }
46
+
47
+ // 机制 2:订阅者 DB token(changelog 确认/退订共用 confirmToken)
48
+ // 幂等保护:仅未退订记录可退订;重复点击已退订链接返回 ok 而非 fail(避免骚扰)
49
+ const now = new Date()
50
+ const claimed = await prisma.emailSubscriber.updateMany({
51
+ where: { confirmToken: data.token, unsubscribedAt: null },
52
+ data: { unsubscribedAt: now },
53
+ })
54
+ if (claimed.count > 0) return { ok: true, mechanism: 'subscriber' }
55
+
56
+ // 重复退订幂等返回:token 命中但已退订 → 也返回 ok(用户可能重复点击链接)
57
+ const alreadyUnsubscribed = await prisma.emailSubscriber.findUnique({
58
+ where: { confirmToken: data.token },
59
+ select: { unsubscribedAt: true },
60
+ })
61
+ if (alreadyUnsubscribed?.unsubscribedAt) {
62
+ return { ok: true, mechanism: 'subscriber' }
63
+ }
64
+
65
+ return { ok: false }
66
+ })
@@ -95,7 +95,7 @@ describe("createIterationsFn", () => {
95
95
  ]);
96
96
  await expect(
97
97
  createIterationsFn({ data: { projectId: "p1", name: "S2", startDate: "2026-09-10", endDate: "2026-09-23" } }),
98
- ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
98
+ ).rejects.toThrow("重叠");
99
99
  });
100
100
 
101
101
  it("合法日期调用 create 且日期按 UTC 0 点入库", async () => {
@@ -130,9 +130,7 @@ describe("updateIterationsFn", () => {
130
130
  archived: false,
131
131
  projectId: "p1",
132
132
  });
133
- await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toMatchObject({
134
- message: expect.stringContaining("不可编辑"),
135
- });
133
+ await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toThrow("不可编辑");
136
134
  });
137
135
 
138
136
  it("改期时校验与其他迭代不重叠", async () => {
@@ -148,7 +146,7 @@ describe("updateIterationsFn", () => {
148
146
  ]);
149
147
  await expect(
150
148
  updateIterationsFn({ data: { id: "i1", startDate: "2026-09-18", endDate: "2026-09-25" } }),
151
- ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
149
+ ).rejects.toThrow("重叠");
152
150
  });
153
151
  });
154
152
 
@@ -83,7 +83,7 @@ describe("setStoryIterationFn", () => {
83
83
  });
84
84
  await expect(
85
85
  setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } }),
86
- ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
86
+ ).rejects.toThrow("已完成");
87
87
  });
88
88
 
89
89
  it("合法目标更新 story 并记录活动", async () => {
@@ -85,7 +85,7 @@ describe("transferUnfinishedFn", () => {
85
85
  });
86
86
  await expect(
87
87
  transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
88
- ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
88
+ ).rejects.toThrow("已完成");
89
89
  });
90
90
 
91
91
  it("where 断言 state notIn 且 iterationId = 源迭代", async () => {
@@ -118,7 +118,7 @@ describe("transferUnfinishedFn", () => {
118
118
  });
119
119
  await expect(
120
120
  transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
121
- ).rejects.toMatchObject({ message: expect.stringContaining("迭代不存在") });
121
+ ).rejects.toThrow("迭代不存在");
122
122
  });
123
123
 
124
124
  it("二次调用(无未完成)返回 { count: 0 },不抛错", async () => {