@allbluecn/web-app 0.11.0 → 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 (101) hide show
  1. package/package.json +6 -3
  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-dark.png +0 -0
  6. package/public/logo/allblue-logo-light.png +0 -0
  7. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  8. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  9. package/src/components/auth/icon-input.tsx +9 -1
  10. package/src/components/auth/password-input.tsx +9 -1
  11. package/src/components/changelog/changelog-page.tsx +44 -1
  12. package/src/components/forms/app-field.tsx +3 -1
  13. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  14. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  15. package/src/components/notifications/settings-notifications.tsx +41 -1
  16. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  17. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  18. package/src/lib/app-version.ts +1 -1
  19. package/src/lib/auth/auth.config.ts +123 -99
  20. package/src/lib/changelog/sdk-versions.ts +4 -4
  21. package/src/lib/edition-desktop.ts +22 -0
  22. package/src/lib/session.ts +2 -2
  23. package/src/routeTree.gen.ts +114 -0
  24. package/src/routes/__root.tsx +20 -3
  25. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  26. package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
  27. package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
  28. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  29. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  30. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  31. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  32. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  33. package/src/routes/_login/login/route.lazy.tsx +4 -2
  34. package/src/routes/_login/register/route.lazy.tsx +28 -17
  35. package/src/routes/_login/register/route.tsx +8 -1
  36. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  37. package/src/routes/_login/schemas/-auth.ts +30 -7
  38. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  39. package/src/routes/_login/verify-email/route.tsx +28 -0
  40. package/src/routes/_nav/route.tsx +5 -0
  41. package/src/routes/_public/index/route.tsx +2 -1
  42. package/src/routes/api/auth/desktop-callback.ts +2 -2
  43. package/src/routes/api/billing-webhook.ts +4 -0
  44. package/src/routes/api/cron/changelog-release.ts +100 -0
  45. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  46. package/src/routes/api.auth.$.ts +3 -5
  47. package/src/server/__tests__/auth-registration.test.ts +60 -0
  48. package/src/server/auth-registration.ts +40 -0
  49. package/src/server/billing-email.ts +39 -0
  50. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  51. package/src/server/email/__tests__/index.test.ts +32 -0
  52. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  53. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  54. package/src/server/email/__tests__/registry.test.tsx +50 -0
  55. package/src/server/email/deliverability.ts +57 -0
  56. package/src/server/email/drivers/console.ts +11 -0
  57. package/src/server/email/drivers/resend.ts +31 -0
  58. package/src/server/email/drivers/smtp.ts +27 -0
  59. package/src/server/email/index.ts +56 -0
  60. package/src/server/email/outbox.ts +141 -0
  61. package/src/server/email/preferences.ts +20 -0
  62. package/src/server/email/registry.ts +38 -0
  63. package/src/server/email/sender.ts +34 -17
  64. package/src/server/email/templates/_base/button.tsx +12 -0
  65. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  66. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  67. package/src/server/email/templates/_base/theme.ts +22 -0
  68. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  69. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  70. package/src/server/email/templates/auth/welcome.tsx +49 -0
  71. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  72. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  73. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  74. package/src/server/email/templates/notification/mirror.tsx +50 -0
  75. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  76. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  77. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  78. package/src/server/email/templates/team/approved.tsx +53 -0
  79. package/src/server/email/templates/team/invite.tsx +57 -0
  80. package/src/server/middlewares/security-headers.ts +11 -2
  81. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  82. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  83. package/src/server/notifications/notification-service.ts +71 -1
  84. package/src/server/notifications/verify-reminder.ts +55 -0
  85. package/src/server/oauth-config.ts +59 -0
  86. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  87. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  88. package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
  89. package/src/server/serverFns/auth/email-verification.ts +132 -0
  90. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  91. package/src/server/serverFns/auth/providers.ts +4 -3
  92. package/src/server/serverFns/auth/register.ts +63 -19
  93. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  94. package/src/server/serverFns/auth/reset-password.ts +1 -1
  95. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  96. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  97. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  98. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  99. package/src/server/serverFns/notifications.ts +10 -0
  100. package/src/server/serverFns/team.ts +49 -11
  101. package/vite.shared.ts +87 -3
@@ -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
+ })
@@ -11,10 +11,13 @@ import {
11
11
  type NotificationItem,
12
12
  type NotificationListResult,
13
13
  type CategoryFrequencies,
14
+ type CategoryEmailFlags,
14
15
  } from '@allbluecn/kernel'
15
16
  import {
16
17
  getOrCreatePreferences,
17
18
  DEFAULT_CATEGORIES,
19
+ DEFAULT_EMAIL_CATEGORIES,
20
+ normalizeEmailCategories,
18
21
  } from '@/server/notifications/notification-service'
19
22
 
20
23
  const PAGE_SIZE = 20
@@ -132,6 +135,10 @@ export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
132
135
 
133
136
  const preferencesSchema = z.object({
134
137
  categories: z.record(z.string(), z.enum(NOTIFICATION_FREQUENCIES)),
138
+ // 外部直连 serverFn 时可能不传 emailCategories,normalize 兜底全 false
139
+ emailCategories: z
140
+ .record(z.enum(NOTIFICATION_CATEGORIES), z.boolean())
141
+ .optional(),
135
142
  dndEnabled: z.boolean(),
136
143
  dndStart: z.string().regex(/^\d{2}:\d{2}$/),
137
144
  dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
@@ -152,17 +159,20 @@ export const updateNotificationPreferencesFn = createServerFn({
152
159
  categories[c] = v as CategoryFrequencies[NotificationCategory]
153
160
  }
154
161
  }
162
+ const emailCategories = normalizeEmailCategories(data.emailCategories)
155
163
  await prisma.notificationPreference.upsert({
156
164
  where: { userId },
157
165
  create: {
158
166
  userId,
159
167
  categories,
168
+ emailCategories,
160
169
  dndEnabled: data.dndEnabled,
161
170
  dndStart: data.dndStart,
162
171
  dndEnd: data.dndEnd,
163
172
  },
164
173
  update: {
165
174
  categories,
175
+ emailCategories,
166
176
  dndEnabled: data.dndEnabled,
167
177
  dndStart: data.dndStart,
168
178
  dndEnd: data.dndEnd,
@@ -6,7 +6,7 @@ import { prisma } from '@allbluecn/database'
6
6
  import { requireAuth } from '@/server/auth-helpers'
7
7
  import { requireTeamSeats } from '@/server/resources'
8
8
  import { auditLog } from '@/server/audit'
9
- import { getEmailSender } from '@/server/email/sender'
9
+ import { sendTemplateEmail } from '@/server/email'
10
10
  import { USER_BRIEF_SELECT } from '@/server/team-access'
11
11
 
12
12
  const INVITE_EXPIRES_DAYS = 7
@@ -100,12 +100,17 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
100
100
  select: OWNER_SELECT,
101
101
  })
102
102
  const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
103
- await getEmailSender().send({
103
+ await sendTemplateEmail({
104
+ key: 'team.invite',
104
105
  to: email,
105
- subject: `${owner?.username ?? '有人'} 邀请你加入 AllBlue 团队`,
106
- html: `<p><a href="${appUrl}/invite/${token}">接受邀请</a></p>`,
107
- text: `接受邀请:${appUrl}/invite/${token}`,
108
- })
106
+ locale: 'zh',
107
+ vars: {
108
+ ownerUsername: owner?.username ?? '',
109
+ inviteUrl: `${appUrl}/invite/${token}`,
110
+ expiresAt: expiresAt.toISOString().slice(0, 10),
111
+ },
112
+ replyTo: owner?.email ?? undefined,
113
+ }).catch(() => undefined)
109
114
  }
110
115
 
111
116
  await auditLog({
@@ -247,13 +252,23 @@ export const resendTeamInvitationFn = createServerFn({ method: 'POST' })
247
252
  },
248
253
  })
249
254
  if (inv.channel === 'email' && inv.email) {
255
+ const owner = await prisma.user.findUnique({
256
+ where: { id: ownerId },
257
+ select: OWNER_SELECT,
258
+ })
250
259
  const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
251
- await getEmailSender().send({
260
+ const newExpiresAt = new Date(Date.now() + INVITE_EXPIRES_DAYS * 24 * 60 * 60 * 1000)
261
+ await sendTemplateEmail({
262
+ key: 'team.invite',
252
263
  to: inv.email,
253
- subject: 'AllBlue 团队邀请(重发)',
254
- html: `<p><a href="${appUrl}/invite/${token}">接受邀请</a></p>`,
255
- text: `接受邀请:${appUrl}/invite/${token}`,
256
- })
264
+ locale: 'zh',
265
+ vars: {
266
+ ownerUsername: owner?.username ?? '',
267
+ inviteUrl: `${appUrl}/invite/${token}`,
268
+ expiresAt: newExpiresAt.toISOString().slice(0, 10),
269
+ },
270
+ replyTo: owner?.email ?? undefined,
271
+ }).catch(() => undefined)
257
272
  }
258
273
  return { success: true, token }
259
274
  })
@@ -683,6 +698,16 @@ export const applyTeamInvitationFn = createServerFn({ method: 'POST' })
683
698
  where: { id: inv.id },
684
699
  data: { status: 'pending_approval', applicantEmail: email },
685
700
  })
701
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
702
+ await sendTemplateEmail({
703
+ key: 'team.apply-notify',
704
+ to: inv.owner.email ?? '',
705
+ userId: inv.owner.id,
706
+ vars: {
707
+ applicantEmail: email,
708
+ approveUrl: `${appUrl}/settings/team`,
709
+ },
710
+ }).catch(() => undefined)
686
711
  }
687
712
  return { success: true, requiresApproval: true, duplicate: !!existing }
688
713
  }
@@ -843,5 +868,18 @@ export const approveTeamInvitationFn = createServerFn({ method: 'POST' })
843
868
  action: 'team.invite.approve',
844
869
  metadata: { seatType: data.seatType },
845
870
  })
871
+
872
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
873
+ const recipientEmail = inv.email ?? inv.applicantEmail
874
+ if (recipientEmail) {
875
+ await sendTemplateEmail({
876
+ key: 'team.approved',
877
+ to: recipientEmail,
878
+ vars: {
879
+ inviteUrl: `${appUrl}/invite/${inv.token}`,
880
+ },
881
+ }).catch(() => undefined)
882
+ }
883
+
846
884
  return { success: true }
847
885
  })
package/vite.shared.ts CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { defineConfig, type PluginOption } from 'vite'
32
32
  import path from 'node:path'
33
- import { readFileSync } from 'node:fs'
33
+ import { existsSync, readFileSync } from 'node:fs'
34
34
  import { execFileSync } from 'node:child_process'
35
35
 
36
36
  // 从 apps/web/package.json 读取版本号,构建时注入到客户端/SSR
@@ -80,6 +80,90 @@ function prismaDirnamePolyfill(rootDir: string): PluginOption {
80
80
  }
81
81
  }
82
82
 
83
+ function cssTreeRuntimeAssetsPlugin(rootDir: string): PluginOption {
84
+ return {
85
+ name: 'css-tree-runtime-assets',
86
+ async closeBundle() {
87
+ // nitro 把 css-tree / jsdom(isomorphic-dompurify 依赖)外部化打平为
88
+ // _libs/*.mjs,其内部运行时 require 的资源文件不随 trace 拷贝 ——
89
+ // closeBundle 后按产物的解析路径补齐,否则冷启动 ENOENT 全站 500。
90
+ const { mkdir, readdir, copyFile } = await import('node:fs/promises')
91
+ const funcDir = path.resolve(rootDir, '.vercel/output/functions/__server.func')
92
+ const pnpmDir = path.resolve(rootDir, '../../node_modules/.pnpm')
93
+ try {
94
+ // a) jsdom 的 xhr-sync-worker.js(运行时相对 require)
95
+ const isoVdirs = (await readdir(pnpmDir)).filter((d) => d.startsWith('isomorphic-dompurify@'))
96
+ for (const v of isoVdirs) {
97
+ const worker = path.join(pnpmDir, v, 'node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js')
98
+ if (existsSync(worker)) {
99
+ await mkdir(path.join(funcDir, '_libs'), { recursive: true })
100
+ await copyFile(worker, path.join(funcDir, '_libs/xhr-sync-worker.js'))
101
+ }
102
+ }
103
+ // a2) jsdom XHR 的 syncWorker resolve 在 bundle 后路径失真(真实 worker 文件
104
+ // 已拷贝到 _libs 根,仍解析失败则降级空串——服务端 syncWorker 走 null 分支)
105
+ const isoLib = path.join(funcDir, '_libs/isomorphic-dompurify.mjs')
106
+ {
107
+ const { readFile, writeFile } = await import('node:fs/promises')
108
+ let code = await readFile(isoLib, 'utf8')
109
+ const target = '__require.resolve("./xhr-sync-worker.js")'
110
+ if (code.includes(target)) {
111
+ code = code.replaceAll(
112
+ target,
113
+ '(()=>{try{return __require.resolve("./xhr-sync-worker.js")}catch{return ""}})()',
114
+ )
115
+ await writeFile(isoLib, code)
116
+ }
117
+ }
118
+ // b) css-tree data/patch.json(dom-selector 内 createRequire('../data/patch.json'))
119
+ // css-tree 是 transitive 包,从 .pnpm 虚拟目录扫描定位
120
+ const ctVdirs = (await readdir(pnpmDir)).filter((d) => d.startsWith('css-tree@'))
121
+ let cssTreePkg: string | null = null
122
+ for (const v of ctVdirs) {
123
+ const cand = path.join(pnpmDir, v, 'node_modules/css-tree')
124
+ if (existsSync(path.join(cand, 'data/patch.json'))) {
125
+ cssTreePkg = cand
126
+ break
127
+ }
128
+ }
129
+ if (!cssTreePkg) throw new Error('css-tree data/patch.json 未找到')
130
+ const patchDestDir = path.join(funcDir, '_libs/data')
131
+ await mkdir(patchDestDir, { recursive: true })
132
+ await copyFile(path.join(cssTreePkg, 'data/patch.json'), path.join(patchDestDir, 'patch.json'))
133
+ // c) mdn-data css json(require('mdn-data/css/*.json') 向上解析 node_modules)
134
+ const mdnCssDir = path.join(path.dirname(cssTreePkg), 'mdn-data/css')
135
+ const dest = path.join(funcDir, 'node_modules/mdn-data/css')
136
+ await mkdir(dest, { recursive: true })
137
+ for (const f of await readdir(mdnCssDir)) {
138
+ if (f.endsWith('.json')) await copyFile(path.join(mdnCssDir, f), path.join(dest, f))
139
+ }
140
+ } catch (e) {
141
+ console.error('[css-tree-runtime-assets] 资源补齐失败:', e instanceof Error ? e.message : e)
142
+ }
143
+ },
144
+ }
145
+ }
146
+
147
+ function runtimeResourceInlinePlugin(): PluginOption {
148
+ return {
149
+ name: 'runtime-resource-inline',
150
+ transform(code, id) {
151
+ // jsdom 模块顶层同步读取默认样式表,nitro 打平 bundle 后
152
+ // __dirname 相对路径失效,冷启动 ENOENT 全站 500 → 构建期内联
153
+ if (id.includes('jsdom') && id.includes('computed-style.js') && code.includes('default-stylesheet.css')) {
154
+ const cssPath = path.resolve(path.dirname(id), '../../../browser/default-stylesheet.css')
155
+ const css = readFileSync(cssPath, 'utf8')
156
+ const replaced = code.replace(
157
+ /fs\.readFileSync\(\s*path\.resolve\(__dirname,\s*"\.\.\/\.\.\/\.\.\/browser\/default-stylesheet\.css"\),\s*\{\s*encoding:\s*"utf-8"\s*\}\s*\)/,
158
+ JSON.stringify(css),
159
+ )
160
+ if (replaced !== code) return { code: replaced, map: null }
161
+ }
162
+ return undefined
163
+ },
164
+ }
165
+ }
166
+
83
167
  function tiptapAliasPlugin(rootDir: string): PluginOption {
84
168
  const tiptapSrc = path.resolve(rootDir, '../../packages/tiptap/src')
85
169
  return {
@@ -108,7 +192,7 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
108
192
  const appUpdateDate = getAppUpdateDate(opts.rootDir)
109
193
  return defineConfig(async () => {
110
194
  const deployTarget = process.env.DEPLOY_TARGET
111
- const plugins: PluginOption[] = [...opts.basePlugins, tiptapAliasPlugin(opts.rootDir)]
195
+ const plugins: PluginOption[] = [...opts.basePlugins, tiptapAliasPlugin(opts.rootDir), runtimeResourceInlinePlugin()]
112
196
 
113
197
  // Vercel 平台自动注入 VERCEL_ENV 环境变量,nitro 插件始终挂载。DEPLOY_TARGET=vercel
114
198
  // 保留作为本地兼容触发器(Pro 仓 build.sh 及本地 vercel preview 均依赖它)。
@@ -116,7 +200,7 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
116
200
  if (isVercelTarget) {
117
201
  const { nitro } = await import('nitro/vite')
118
202
  plugins.splice(2, 0, nitro({ preset: 'vercel' }))
119
- plugins.push(prismaDirnamePolyfill(opts.rootDir))
203
+ plugins.push(prismaDirnamePolyfill(opts.rootDir), cssTreeRuntimeAssetsPlugin(opts.rootDir))
120
204
  }
121
205
 
122
206
  if (deployTarget === 'cloudflare') {