@allbluecn/web-app 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +4 -4
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/session.ts +2 -2
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
- package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/route.tsx +5 -0
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +34 -17
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/middlewares/security-headers.ts +11 -2
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/team.ts +49 -11
- 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 {
|
|
21
|
-
import {
|
|
21
|
+
import { getCurrentSession } from '@/lib/session'
|
|
22
|
+
import { ensureVerifyReminder } from '@/server/notifications/verify-reminder'
|
|
22
23
|
|
|
23
|
-
const
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 {
|
|
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
|
|
103
|
+
await sendTemplateEmail({
|
|
104
|
+
key: 'team.invite',
|
|
104
105
|
to: email,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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') {
|