@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
@@ -0,0 +1,51 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface SubscriptionCanceledProps {
7
+ locale: EmailLocale
8
+ planName: string
9
+ endDate: string
10
+ dashboardUrl: string
11
+ unsubscribeUrl?: string
12
+ }
13
+
14
+ const T = {
15
+ zh: {
16
+ subject: '订阅已取消',
17
+ preview: '你的订阅已取消',
18
+ heading: '订阅已取消',
19
+ body: (plan: string, end: string) => `你的 AllBlue「${plan}」订阅已取消,服务将持续至 ${end}。可随时重新订阅。`,
20
+ cta: '重新订阅',
21
+ },
22
+ en: {
23
+ subject: 'Subscription canceled',
24
+ preview: 'Your subscription was canceled',
25
+ heading: 'Canceled',
26
+ body: (plan: string, end: string) => `Your AllBlue "${plan}" subscription was canceled. Service continues until ${end}. You can resubscribe any time.`,
27
+ cta: 'Resubscribe',
28
+ },
29
+ } as const
30
+
31
+ function SubscriptionCanceledEmail({ locale, planName, endDate, dashboardUrl, unsubscribeUrl }: SubscriptionCanceledProps) {
32
+ const t = T[locale]
33
+ return (
34
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
35
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(planName, endDate)}</Text>
36
+ <Section className="mb-6 text-center">
37
+ <EmailButton href={dashboardUrl} label={t.cta} />
38
+ </Section>
39
+ </EmailLayout>
40
+ )
41
+ }
42
+
43
+ export function registerSubscriptionCanceledTemplate() {
44
+ registerTemplate({
45
+ key: 'billing.subscription-canceled',
46
+ category: 'billing',
47
+ transactional: true,
48
+ subject: { zh: T.zh.subject, en: T.en.subject },
49
+ component: SubscriptionCanceledEmail,
50
+ })
51
+ }
@@ -0,0 +1,51 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface SubscriptionEndingProps {
7
+ locale: EmailLocale
8
+ planName: string
9
+ endDate: string
10
+ dashboardUrl: string
11
+ unsubscribeUrl?: string
12
+ }
13
+
14
+ const T = {
15
+ zh: {
16
+ subject: '你的订阅即将到期',
17
+ preview: '订阅即将到期',
18
+ heading: '即将到期',
19
+ body: (plan: string, end: string) => `你的 AllBlue「${plan}」订阅将于 ${end} 到期,请及时续订。`,
20
+ cta: '续订',
21
+ },
22
+ en: {
23
+ subject: 'Your subscription is ending soon',
24
+ preview: 'Subscription ending soon',
25
+ heading: 'Ending soon',
26
+ body: (plan: string, end: string) => `Your AllBlue "${plan}" subscription ends on ${end}. Renew to keep your access.`,
27
+ cta: 'Renew',
28
+ },
29
+ } as const
30
+
31
+ function SubscriptionEndingEmail({ locale, planName, endDate, dashboardUrl, unsubscribeUrl }: SubscriptionEndingProps) {
32
+ const t = T[locale]
33
+ return (
34
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
35
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(planName, endDate)}</Text>
36
+ <Section className="mb-6 text-center">
37
+ <EmailButton href={dashboardUrl} label={t.cta} />
38
+ </Section>
39
+ </EmailLayout>
40
+ )
41
+ }
42
+
43
+ export function registerSubscriptionEndingTemplate() {
44
+ registerTemplate({
45
+ key: 'billing.subscription-ending',
46
+ category: 'billing',
47
+ transactional: true,
48
+ subject: { zh: T.zh.subject, en: T.en.subject },
49
+ component: SubscriptionEndingEmail,
50
+ })
51
+ }
@@ -0,0 +1,50 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface NotificationMirrorProps {
7
+ locale: EmailLocale
8
+ title: string
9
+ body?: string
10
+ link?: string
11
+ linkLabel?: string
12
+ unsubscribeUrl?: string
13
+ }
14
+
15
+ const T = {
16
+ zh: {
17
+ subject: '你有一条新通知',
18
+ defaultLinkLabel: '查看详情',
19
+ },
20
+ en: {
21
+ subject: 'You have a new notification',
22
+ defaultLinkLabel: 'View details',
23
+ },
24
+ } as const
25
+
26
+ function NotificationMirrorEmail({ locale, title, body, link, linkLabel, unsubscribeUrl }: NotificationMirrorProps) {
27
+ const t = T[locale]
28
+ return (
29
+ <EmailLayout locale={locale} preview={title} heading={title} unsubscribeUrl={unsubscribeUrl} footerKind="notification">
30
+ {body ? (
31
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{body}</Text>
32
+ ) : null}
33
+ {link ? (
34
+ <Section className="mb-6 text-center">
35
+ <EmailButton href={link} label={linkLabel ?? t.defaultLinkLabel} />
36
+ </Section>
37
+ ) : null}
38
+ </EmailLayout>
39
+ )
40
+ }
41
+
42
+ export function registerNotificationMirrorTemplate() {
43
+ registerTemplate({
44
+ key: 'notification.mirror',
45
+ category: 'notification',
46
+ transactional: false,
47
+ subject: { zh: T.zh.subject, en: T.en.subject },
48
+ component: NotificationMirrorEmail,
49
+ })
50
+ }
@@ -0,0 +1,53 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface ChangelogConfirmProps {
7
+ locale: EmailLocale
8
+ confirmUrl: string
9
+ unsubscribeUrl?: string
10
+ }
11
+
12
+ const T = {
13
+ zh: {
14
+ subject: '确认订阅 AllBlue 开发更新',
15
+ preview: '请确认订阅 AllBlue 开发更新',
16
+ heading: '确认订阅',
17
+ body: '感谢订阅 AllBlue 开发更新。请点击下方按钮确认订阅(链接 24 小时内有效)。无营销邮件,可随时退订。',
18
+ cta: '确认订阅',
19
+ urlLabel: '如果按钮无法点击,请复制以下链接:',
20
+ },
21
+ en: {
22
+ subject: 'Confirm your AllBlue updates subscription',
23
+ preview: 'Confirm your AllBlue developer updates subscription',
24
+ heading: 'Confirm subscription',
25
+ body: 'Thanks for subscribing to AllBlue developer updates. Click the button below to confirm your subscription (link valid for 24 hours). No marketing emails. Unsubscribe any time.',
26
+ cta: 'Confirm subscription',
27
+ urlLabel: 'If the button does not work, copy this link:',
28
+ },
29
+ } as const
30
+
31
+ function ChangelogConfirmEmail({ locale, confirmUrl, unsubscribeUrl }: ChangelogConfirmProps) {
32
+ const t = T[locale]
33
+ return (
34
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="notification">
35
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body}</Text>
36
+ <Section className="mb-6 text-center">
37
+ <EmailButton href={confirmUrl} label={t.cta} />
38
+ </Section>
39
+ <Text className="text-sm text-fg-3 break-all font-sans">{t.urlLabel}</Text>
40
+ <Text className="text-sm text-fg-3 break-all font-sans">{confirmUrl}</Text>
41
+ </EmailLayout>
42
+ )
43
+ }
44
+
45
+ export function registerChangelogConfirmTemplate() {
46
+ registerTemplate({
47
+ key: 'product.changelog-confirm',
48
+ category: 'product',
49
+ transactional: false,
50
+ subject: { zh: T.zh.subject, en: T.en.subject },
51
+ component: ChangelogConfirmEmail,
52
+ })
53
+ }
@@ -0,0 +1,65 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface ChangelogReleaseProps {
7
+ locale: EmailLocale
8
+ version: string
9
+ highlights: string
10
+ detailUrl: string
11
+ unsubscribeUrl?: string
12
+ }
13
+
14
+ const T = {
15
+ zh: {
16
+ subject: 'AllBlue v{version} 发布',
17
+ preview: 'AllBlue 新版本发布',
18
+ heading: '新版本发布',
19
+ body: (version: string) => `AllBlue v${version} 已发布。`,
20
+ highlightsLabel: '本次更新要点:',
21
+ cta: '查看更新日志',
22
+ },
23
+ en: {
24
+ subject: 'AllBlue v{version} released',
25
+ preview: 'New AllBlue version released',
26
+ heading: 'New release',
27
+ body: (version: string) => `AllBlue v${version} has been released.`,
28
+ highlightsLabel: 'Highlights:',
29
+ cta: 'View changelog',
30
+ },
31
+ } as const
32
+
33
+ function ChangelogReleaseEmail({ locale, version, highlights, detailUrl, unsubscribeUrl }: ChangelogReleaseProps) {
34
+ const t = T[locale]
35
+ const highlightsList = highlights ? highlights.split('\n').filter(Boolean) : []
36
+ return (
37
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="notification">
38
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-6 max-w-[380px] font-sans">{t.body(version)}</Text>
39
+ {highlightsList.length > 0 ? (
40
+ <Section className="mb-8">
41
+ <Text className="text-sm text-fg-3 m-0 mb-2 font-sans">{t.highlightsLabel}</Text>
42
+ {highlightsList.map((line, i) => (
43
+ <Text key={i} className="font-14 text-fg-2 m-0 font-sans">· {line}</Text>
44
+ ))}
45
+ </Section>
46
+ ) : null}
47
+ <Section className="mb-6 text-center">
48
+ <EmailButton href={detailUrl} label={t.cta} />
49
+ </Section>
50
+ </EmailLayout>
51
+ )
52
+ }
53
+
54
+ export function registerChangelogReleaseTemplate() {
55
+ registerTemplate({
56
+ key: 'product.changelog-release',
57
+ category: 'product',
58
+ transactional: false,
59
+ subject: {
60
+ zh: (vars) => `AllBlue v${(vars.version as string) ?? ''} 发布`,
61
+ en: (vars) => `AllBlue v${(vars.version as string) ?? ''} released`,
62
+ },
63
+ component: ChangelogReleaseEmail,
64
+ })
65
+ }
@@ -0,0 +1,53 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface TeamApplyNotifyProps {
7
+ locale: EmailLocale
8
+ applicantEmail: string
9
+ approveUrl: string
10
+ unsubscribeUrl?: string
11
+ }
12
+
13
+ const T = {
14
+ zh: {
15
+ subject: '新的团队加入申请',
16
+ preview: '有人申请加入你的团队',
17
+ heading: '加入申请',
18
+ greeting: '你好:',
19
+ body: (email: string) => `有人以邮箱 ${email} 申请加入你的团队,请前往管理页面处理。`,
20
+ cta: '查看申请',
21
+ },
22
+ en: {
23
+ subject: 'New team join request',
24
+ preview: 'Someone requested to join your team',
25
+ heading: 'Join request',
26
+ greeting: 'Hi,',
27
+ body: (email: string) => `Someone with email ${email} requested to join your team. Please review it in the management page.`,
28
+ cta: 'Review request',
29
+ },
30
+ } as const
31
+
32
+ function TeamApplyNotifyEmail({ locale, applicantEmail, approveUrl, unsubscribeUrl }: TeamApplyNotifyProps) {
33
+ const t = T[locale]
34
+ return (
35
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
36
+ <Text className="text-base text-fg-2 mt-0 mb-6 font-sans">{t.greeting}</Text>
37
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(applicantEmail)}</Text>
38
+ <Section className="mb-6 text-center">
39
+ <EmailButton href={approveUrl} label={t.cta} />
40
+ </Section>
41
+ </EmailLayout>
42
+ )
43
+ }
44
+
45
+ export function registerTeamApplyNotifyTemplate() {
46
+ registerTemplate({
47
+ key: 'team.apply-notify',
48
+ category: 'team',
49
+ transactional: true,
50
+ subject: { zh: T.zh.subject, en: T.en.subject },
51
+ component: TeamApplyNotifyEmail,
52
+ })
53
+ }
@@ -0,0 +1,53 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface TeamApprovedProps {
7
+ locale: EmailLocale
8
+ teamName?: string
9
+ inviteUrl: string
10
+ unsubscribeUrl?: string
11
+ }
12
+
13
+ const T = {
14
+ zh: {
15
+ subject: '你的加入申请已通过',
16
+ preview: '团队申请已批准',
17
+ heading: '申请已通过',
18
+ greeting: '你好:',
19
+ body: (team?: string) => (team ? `你的加入「${team}」团队的申请已通过。` : '你的团队加入申请已通过。'),
20
+ cta: '加入团队',
21
+ },
22
+ en: {
23
+ subject: 'Your join request was approved',
24
+ preview: 'Team request approved',
25
+ heading: 'Request approved',
26
+ greeting: 'Hi,',
27
+ body: (team?: string) => (team ? `Your request to join "${team}" has been approved.` : 'Your team join request has been approved.'),
28
+ cta: 'Join team',
29
+ },
30
+ } as const
31
+
32
+ function TeamApprovedEmail({ locale, teamName, inviteUrl, unsubscribeUrl }: TeamApprovedProps) {
33
+ const t = T[locale]
34
+ return (
35
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
36
+ <Text className="text-base text-fg-2 mt-0 mb-6 font-sans">{t.greeting}</Text>
37
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(teamName)}</Text>
38
+ <Section className="mb-6 text-center">
39
+ <EmailButton href={inviteUrl} label={t.cta} />
40
+ </Section>
41
+ </EmailLayout>
42
+ )
43
+ }
44
+
45
+ export function registerTeamApprovedTemplate() {
46
+ registerTemplate({
47
+ key: 'team.approved',
48
+ category: 'team',
49
+ transactional: true,
50
+ subject: { zh: T.zh.subject, en: T.en.subject },
51
+ component: TeamApprovedEmail,
52
+ })
53
+ }
@@ -0,0 +1,57 @@
1
+ import { Section, Text } from '@react-email/components'
2
+ import { EmailLayout } from '../_base/email-layout'
3
+ import { EmailButton } from '../_base/button'
4
+ import { registerTemplate, type EmailLocale } from '../../registry'
5
+
6
+ interface TeamInviteProps {
7
+ locale: EmailLocale
8
+ ownerUsername: string
9
+ inviteUrl: string
10
+ expiresAt: string
11
+ unsubscribeUrl?: string
12
+ }
13
+
14
+ const T = {
15
+ zh: {
16
+ subject: '邀请你加入 AllBlue 团队',
17
+ preview: '你收到一个团队邀请',
18
+ heading: '团队邀请',
19
+ greeting: '你好:',
20
+ body: (owner: string) => `${owner || '团队成员'} 邀请你加入 AllBlue 团队。点击下方按钮接受邀请(${'过期时间见下'})。`,
21
+ cta: '接受邀请',
22
+ expiresLabel: '有效期至:',
23
+ },
24
+ en: {
25
+ subject: 'You are invited to an AllBlue team',
26
+ preview: 'You received a team invitation',
27
+ heading: 'Team invitation',
28
+ greeting: 'Hi,',
29
+ body: (owner: string) => `${owner || 'A team member'} invited you to join an AllBlue team. Click the button below to accept.`,
30
+ cta: 'Accept invite',
31
+ expiresLabel: 'Valid until:',
32
+ },
33
+ } as const
34
+
35
+ function TeamInviteEmail({ locale, ownerUsername, inviteUrl, expiresAt, unsubscribeUrl }: TeamInviteProps) {
36
+ const t = T[locale]
37
+ return (
38
+ <EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
39
+ <Text className="text-base text-fg-2 mt-0 mb-6 font-sans">{t.greeting}</Text>
40
+ <Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(ownerUsername)}</Text>
41
+ <Section className="mb-6 text-center">
42
+ <EmailButton href={inviteUrl} label={t.cta} />
43
+ </Section>
44
+ <Text className="text-sm text-fg-3 font-sans">{t.expiresLabel} {expiresAt}</Text>
45
+ </EmailLayout>
46
+ )
47
+ }
48
+
49
+ export function registerTeamInviteTemplate() {
50
+ registerTemplate({
51
+ key: 'team.invite',
52
+ category: 'team',
53
+ transactional: true,
54
+ subject: { zh: T.zh.subject, en: T.en.subject },
55
+ component: TeamInviteEmail,
56
+ })
57
+ }
@@ -0,0 +1,170 @@
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
+ // 服务端 fetch 目标准入判定(SSRF 防护)。
19
+ // 纯函数模块,仅依赖全局 URL,禁止 import prisma 等重模块,
20
+ // 以便同时被服务端路由与单元测试消费。
21
+
22
+ export class FetchGuardError extends Error {
23
+ constructor(url: string, reason: string) {
24
+ super(`拒绝 fetch 目标 ${url}:${reason}`)
25
+ this.name = "FetchGuardError"
26
+ }
27
+ }
28
+
29
+ export interface FetchGuardOptions {
30
+ /** 显式允许私网/回环目标(SearXNG 等自托管内网服务场景) */
31
+ allowPrivate?: boolean
32
+ /** 主机白名单(提供时仅放行列表内主机,与 allowPrivate 独立判定) */
33
+ allowedHosts?: string[]
34
+ }
35
+
36
+ /**
37
+ * 判定点分十进制 IPv4 是否落在私网/回环/链路本地/未指定/组播保留段。
38
+ * 这些地址段是 SSRF 的主要攻击面:本机服务(127.0.0.1)、内网资产(10/172.16/192.168)、
39
+ * 云元数据端点(169.254.169.254,可窃取实例凭证)。
40
+ */
41
+ function isPrivateIPv4(host: string): boolean {
42
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
43
+ if (!m) return false
44
+ const a = Number(m[1])
45
+ const b = Number(m[2])
46
+ if (a === 127 || a === 10 || a === 0) return true
47
+ if (a === 172 && b >= 16 && b <= 31) return true
48
+ if (a === 192 && b === 168) return true
49
+ if (a === 169 && b === 254) return true
50
+ if (a >= 224) return true
51
+ return false
52
+ }
53
+
54
+ /** 32 位整数转点分十进制,供整数形式 IPv4 与 IPv6 映射地址复用 */
55
+ function toDotQuad(n: number): string {
56
+ return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
57
+ }
58
+
59
+ /**
60
+ * 解析 32 位整数形式的 IP 混淆(十进制/十六进制/八进制),返回点分十进制。
61
+ * 纵深防御:WHATWG URL 在 Node 24 已会把这类主机规范化,但不同运行时
62
+ * (浏览器 WebKit、旧版 Node、上游中间件改写后的 hostname)行为不一致,
63
+ * 不能依赖上游规范化结果。非整数形式或超出 32 位返回 null,交由调用方继续判定。
64
+ */
65
+ function normalizeIntegerIp(host: string): string | null {
66
+ let n: number | null = null
67
+ if (/^\d+$/.test(host)) n = Number(host)
68
+ else if (/^0x[0-9a-f]+$/i.test(host)) n = Number.parseInt(host.slice(2), 16)
69
+ else if (/^0[0-7]+$/.test(host)) n = Number.parseInt(host.slice(1), 8)
70
+ if (n === null || !Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) return null
71
+ return toDotQuad(n)
72
+ }
73
+
74
+ /**
75
+ * 展开 IPv6 压缩形式为 8 组 16 位整数(NaN 标记非法段),解析失败返回 null。
76
+ * WHATWG URL 会把 IPv4-mapped 的点分十进制归一为十六进制组(::ffff:127.0.0.1 → ::ffff:7f00:1),
77
+ * 但仍保留点分十进制分支,防止上游未规范化时漏判。
78
+ */
79
+ function expandIPv6(addr: string): number[] | null {
80
+ if (!addr.includes(":")) return null
81
+ const halves = addr.split("::")
82
+ if (halves.length > 2) return null
83
+ const toGroups = (seg: string): number[] =>
84
+ seg === ""
85
+ ? []
86
+ : seg.split(":").flatMap((t) => {
87
+ if (t.includes(".")) {
88
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(t)
89
+ if (!m) return [NaN]
90
+ const n =
91
+ (Number(m[1]) << 24) | (Number(m[2]) << 16) | (Number(m[3]) << 8) | Number(m[4])
92
+ return [(n >>> 16) & 0xffff, n & 0xffff]
93
+ }
94
+ const v = Number.parseInt(t, 16)
95
+ return [v > 0xffff || Number.isNaN(v) ? NaN : v]
96
+ })
97
+ const head = toGroups(halves[0])
98
+ const tail = toGroups(halves[1] ?? "")
99
+ if (head.includes(NaN) || tail.includes(NaN)) return null
100
+ if (halves.length === 1 && head.length !== 8) return null
101
+ if (head.length + tail.length > 8) return null
102
+ return [...head, ...Array.from({ length: 8 - head.length - tail.length }, () => 0), ...tail]
103
+ }
104
+
105
+ /**
106
+ * 判定 IPv6 是否落在私网/保留段。SSRF 最经典绕过是 IPv4-mapped(::ffff:0:0/96)
107
+ * 伪装成 IPv6 直连回环/内网/云元数据,必须按末两组还原 32 位后再走 IPv4 判定。
108
+ */
109
+ function isPrivateIPv6(addr: string): boolean {
110
+ const g = expandIPv6(addr)
111
+ if (!g) return false
112
+ const first = g[0]
113
+ if (first === 0 && g.every((v) => v === 0)) return true // :: 未指定(等同 0.0.0.0)
114
+ if (first === 0 && g.slice(1, 7).every((v) => v === 0) && g[7] === 1) return true // ::1 回环
115
+ if (first >= 0xfe80 && first <= 0xfebf) return true // fe80::/10 链路本地
116
+ if (first >= 0xfc00 && first <= 0xfdff) return true // fc00::/7 ULA 私有段
117
+ // 内嵌 IPv4 统一判定:::/96 兼容、::ffff:0:0/96 映射与翻译三种形态。
118
+ // 结构一致——前 6 组至多一个 0xffff 标记、标记之前全零、末两组为 32 位 IPv4。
119
+ // 注意不能只用 indexOf(0xffff) 判定:compatible 形态无 ffff 标记会漏拦。
120
+ const ffffIdx = g.indexOf(0xffff)
121
+ const markerCount = g.slice(0, 6).filter((v) => v === 0xffff).length
122
+ const prefixSlice = ffffIdx === -1 ? g.slice(0, 6) : g.slice(0, ffffIdx)
123
+ const prefixAllZero = prefixSlice.every((v) => v === 0)
124
+ const isEmbeddedIPv4 = ffffIdx <= 5 && markerCount <= 1 && prefixAllZero
125
+ if (isEmbeddedIPv4) {
126
+ return isPrivateIPv4(toDotQuad((g[6] << 16) | g[7]))
127
+ }
128
+ return false
129
+ }
130
+
131
+ /**
132
+ * 校验 fetch 目标 URL 可访问,通过则返回规范化后的 URL 供 fetch 使用。
133
+ * 不抛异常即视为放行;违规抛 FetchGuardError 携带原因说明。
134
+ */
135
+ export function assertFetchableUrl(
136
+ input: string,
137
+ options: FetchGuardOptions = {},
138
+ ): URL {
139
+ let url: URL
140
+ try {
141
+ url = new URL(input)
142
+ } catch {
143
+ throw new FetchGuardError(input, "无效 URL")
144
+ }
145
+
146
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
147
+ throw new FetchGuardError(input, `协议 ${url.protocol} 不在允许范围(仅 http/https)`)
148
+ }
149
+
150
+ // IPv6 主机在 WHATWG URL 中带方括号([::1]),剥离后与判定规则对齐
151
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "")
152
+
153
+ // 先过一遍整数形式归一化,再判定私网,防止十六进制等混淆绕过点分十进制正则。
154
+ // 含冒号即 IPv6 字面量,交由 IPv6 判定(含 IPv4-mapped 还原),两者互斥不叠加
155
+ const isPrivate =
156
+ host === "localhost" ||
157
+ host.endsWith(".localhost") ||
158
+ host.endsWith(".internal") ||
159
+ (host.includes(":") ? isPrivateIPv6(host) : isPrivateIPv4(normalizeIntegerIp(host) ?? host))
160
+
161
+ if (isPrivate && !options.allowPrivate) {
162
+ throw new FetchGuardError(input, "私网/回环/链路本地地址被 SSRF 防护拒绝")
163
+ }
164
+
165
+ if (options.allowedHosts && !options.allowedHosts.includes(host)) {
166
+ throw new FetchGuardError(input, `主机 ${host} 不在白名单`)
167
+ }
168
+
169
+ return url
170
+ }