@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.
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
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: () => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ return async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse(data)
16
+ }
17
+ return fn({ data })
18
+ }
19
+ },
20
+ }
21
+ return api
22
+ },
23
+ }))
24
+
25
+ const mockUserFindUnique = vi.fn()
26
+ const mockUserUpdate = vi.fn().mockResolvedValue({})
27
+ const mockTokenFindUnique = vi.fn()
28
+ const mockTokenCreate = vi.fn().mockResolvedValue({})
29
+ const mockTokenUpdate = vi.fn().mockResolvedValue({})
30
+ const mockTxRun = vi.fn().mockResolvedValue([])
31
+ const mockSendTemplateEmail = vi.fn().mockResolvedValue({ sent: true })
32
+ const mockBcryptHash = vi.fn().mockResolvedValue('hashed')
33
+
34
+ vi.mock('@allbluecn/database', () => ({
35
+ prisma: {
36
+ user: {
37
+ findUnique: (...a: unknown[]) => mockUserFindUnique(...a),
38
+ update: (...a: unknown[]) => mockUserUpdate(...a),
39
+ },
40
+ passwordResetToken: {
41
+ findUnique: (...a: unknown[]) => mockTokenFindUnique(...a),
42
+ create: (...a: unknown[]) => mockTokenCreate(...a),
43
+ update: (...a: unknown[]) => mockTokenUpdate(...a),
44
+ },
45
+ $transaction: (...a: unknown[]) => mockTxRun(...a),
46
+ },
47
+ }))
48
+
49
+ vi.mock('@/server/email', () => ({
50
+ sendTemplateEmail: (...a: unknown[]) => mockSendTemplateEmail(...a),
51
+ }))
52
+
53
+ vi.mock('@/server/middlewares/rate-limit', () => ({
54
+ checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
55
+ }))
56
+
57
+ vi.mock('crypto', () => ({
58
+ randomBytes: () => ({ toString: () => 'tok' }),
59
+ default: { randomBytes: () => ({ toString: () => 'tok' }) },
60
+ }))
61
+
62
+ vi.mock('bcryptjs', () => ({
63
+ default: { hash: (...a: unknown[]) => mockBcryptHash(...a) },
64
+ }))
65
+
66
+ import { forgotPasswordFn } from '../auth/forgot-password'
67
+ import { resetPasswordFn } from '../auth/reset-password'
68
+
69
+ const FUTURE = new Date('2027-01-01T00:00:00.000Z')
70
+
71
+ describe('forgotPasswordFn 邮箱归一化', () => {
72
+ beforeEach(() => {
73
+ vi.clearAllMocks()
74
+ mockUserFindUnique.mockResolvedValue(null)
75
+ mockSendTemplateEmail.mockResolvedValue({ sent: true })
76
+ })
77
+
78
+ it('大写邮箱 + 首尾空格 → 查库用小写 trim 值', async () => {
79
+ await forgotPasswordFn({ data: { email: ' Alice@X.com ' } })
80
+ expect(mockUserFindUnique).toHaveBeenCalledWith({
81
+ where: { email: 'alice@x.com' },
82
+ select: { id: true, email: true, locale: true },
83
+ })
84
+ })
85
+
86
+ it('小写邮箱用户存在 → 创建 token 并发送重置邮件', async () => {
87
+ mockUserFindUnique.mockResolvedValue({
88
+ id: 'u1',
89
+ email: 'alice@x.com',
90
+ locale: null,
91
+ })
92
+ await forgotPasswordFn({ data: { email: 'ALICE@X.COM' } })
93
+ expect(mockTokenCreate).toHaveBeenCalledWith({
94
+ data: expect.objectContaining({ token: 'tok', userId: 'u1' }),
95
+ })
96
+ expect(mockSendTemplateEmail).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ key: 'auth.reset-password',
99
+ to: 'alice@x.com',
100
+ locale: 'zh',
101
+ userId: 'u1',
102
+ vars: expect.objectContaining({
103
+ url: expect.stringContaining('/reset-password?token='),
104
+ }),
105
+ }),
106
+ )
107
+ })
108
+
109
+ it('非法邮箱格式 → schema 拒绝', async () => {
110
+ await expect(forgotPasswordFn({ data: { email: 'not-an-email' } })).rejects.toThrow()
111
+ expect(mockUserFindUnique).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('数据库写入失败 → 不发送重置邮件', async () => {
115
+ mockUserFindUnique.mockResolvedValue({
116
+ id: 'u1',
117
+ email: 'alice@x.com',
118
+ locale: null,
119
+ })
120
+ mockTokenCreate.mockRejectedValueOnce(new Error('db down'))
121
+ await forgotPasswordFn({ data: { email: 'alice@x.com' } })
122
+ expect(mockSendTemplateEmail).not.toHaveBeenCalled()
123
+ })
124
+ })
125
+
126
+ describe('resetPasswordFn 密码强度', () => {
127
+ beforeEach(() => {
128
+ vi.clearAllMocks()
129
+ mockTokenFindUnique.mockResolvedValue(null)
130
+ mockTxRun.mockResolvedValue([])
131
+ mockBcryptHash.mockResolvedValue('hashed')
132
+ })
133
+
134
+ it('纯字母密码(缺数字)→ schema 拒绝', async () => {
135
+ await expect(resetPasswordFn({ data: { token: 't', password: 'abcdefgh' } })).rejects.toThrow(
136
+ /数字/,
137
+ )
138
+ expect(mockTxRun).not.toHaveBeenCalled()
139
+ })
140
+
141
+ it('纯数字密码(缺字母)→ schema 拒绝', async () => {
142
+ await expect(resetPasswordFn({ data: { token: 't', password: '12345678' } })).rejects.toThrow(
143
+ /字母/,
144
+ )
145
+ })
146
+
147
+ it('字母+数字密码 → 通过校验并重置', async () => {
148
+ mockTokenFindUnique.mockResolvedValue({
149
+ id: 'rt1',
150
+ userId: 'u1',
151
+ usedAt: null,
152
+ expiresAt: FUTURE,
153
+ })
154
+ await expect(
155
+ resetPasswordFn({ data: { token: 't', password: 'abc12345' } }),
156
+ ).resolves.toEqual({ message: 'Password reset successfully' })
157
+ expect(mockBcryptHash).toHaveBeenCalledWith('abc12345', 12)
158
+ expect(mockTxRun).toHaveBeenCalledTimes(1)
159
+ expect(mockUserUpdate).toHaveBeenCalledWith({
160
+ where: { id: 'u1' },
161
+ data: { password: 'hashed' },
162
+ })
163
+ expect(mockTokenUpdate).toHaveBeenCalledWith({
164
+ where: { id: 'rt1' },
165
+ data: { usedAt: expect.any(Date) },
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: (config: any) => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ const wrapped = async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse ? this._validator.parse(data) : data
16
+ }
17
+ return fn({ data })
18
+ }
19
+ return wrapped
20
+ },
21
+ }
22
+ return api
23
+ },
24
+ }))
25
+
26
+ vi.mock('@tanstack/react-start/server', () => ({
27
+ getRequest: vi.fn(),
28
+ }))
29
+
30
+ const mockFindUniqueToken = vi.fn()
31
+ const mockTxTokenUpdateMany = vi.fn()
32
+ const mockTxUserUpdate = vi.fn()
33
+ const mockUserFindUnique = vi.fn()
34
+
35
+ vi.mock('@allbluecn/database', () => ({
36
+ prisma: {
37
+ emailVerificationToken: {
38
+ findUnique: (...a: unknown[]) => mockFindUniqueToken(...a),
39
+ updateMany: (...a: unknown[]) => mockTxTokenUpdateMany(...a),
40
+ },
41
+ user: {
42
+ findUnique: (...a: unknown[]) => mockUserFindUnique(...a),
43
+ update: (...a: unknown[]) => mockTxUserUpdate(...a),
44
+ },
45
+ $transaction: (fn: (tx: unknown) => Promise<unknown>) =>
46
+ fn({
47
+ emailVerificationToken: {
48
+ updateMany: (...a: unknown[]) => mockTxTokenUpdateMany(...a),
49
+ },
50
+ user: { update: (...a: unknown[]) => mockTxUserUpdate(...a) },
51
+ }),
52
+ },
53
+ }))
54
+
55
+ vi.mock('@/server/email', () => ({
56
+ sendTemplateEmail: vi.fn().mockResolvedValue({ sent: true }),
57
+ generateEmailToken: vi.fn().mockReturnValue('tok-123'),
58
+ }))
59
+
60
+ vi.mock('@/server/middlewares/rate-limit', () => ({
61
+ checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
62
+ }))
63
+
64
+ vi.mock('@/lib/session', () => ({
65
+ getCurrentSession: vi.fn().mockResolvedValue(null),
66
+ }))
67
+
68
+ import { confirmEmailVerificationFn } from '../auth/email-verification'
69
+
70
+ const NOW = new Date('2026-09-12T00:00:00.000Z')
71
+ const FUTURE = new Date('2027-01-01T00:00:00.000Z')
72
+
73
+ describe('confirmEmailVerificationFn', () => {
74
+ beforeEach(() => {
75
+ vi.clearAllMocks()
76
+ mockFindUniqueToken.mockImplementation(async () => null)
77
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 0 })
78
+ mockTxUserUpdate.mockResolvedValue({})
79
+ mockUserFindUnique.mockResolvedValue(null)
80
+ })
81
+
82
+ it('token 不存在 → ok:false', async () => {
83
+ mockFindUniqueToken.mockResolvedValue(null)
84
+ await expect(
85
+ confirmEmailVerificationFn({ data: { token: 'nope' } }),
86
+ ).resolves.toEqual({ ok: false })
87
+ expect(mockTxUserUpdate).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it('token 已使用 → ok:false', async () => {
91
+ mockFindUniqueToken.mockResolvedValue({
92
+ id: 't1',
93
+ userId: 'u1',
94
+ usedAt: NOW,
95
+ expiresAt: FUTURE,
96
+ })
97
+ await expect(
98
+ confirmEmailVerificationFn({ data: { token: 'used' } }),
99
+ ).resolves.toEqual({ ok: false })
100
+ expect(mockTxTokenUpdateMany).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('token 已过期 → ok:false', async () => {
104
+ mockFindUniqueToken.mockResolvedValue({
105
+ id: 't1',
106
+ userId: 'u1',
107
+ usedAt: null,
108
+ expiresAt: new Date('2020-01-01'),
109
+ })
110
+ await expect(
111
+ confirmEmailVerificationFn({ data: { token: 'exp' } }),
112
+ ).resolves.toEqual({ ok: false })
113
+ })
114
+
115
+ it('首次验证:抢占成功 → 标记已用 + 用户已验证 → ok:true', async () => {
116
+ mockFindUniqueToken.mockResolvedValue({
117
+ id: 't1',
118
+ userId: 'u1',
119
+ usedAt: null,
120
+ expiresAt: FUTURE,
121
+ })
122
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 1 })
123
+ await expect(
124
+ confirmEmailVerificationFn({ data: { token: 'good' } }),
125
+ ).resolves.toEqual({ ok: true })
126
+ expect(mockTxTokenUpdateMany).toHaveBeenCalledWith({
127
+ where: { id: 't1', usedAt: null },
128
+ data: expect.objectContaining({ usedAt: expect.any(Date) }),
129
+ })
130
+ expect(mockTxUserUpdate).toHaveBeenCalledWith({
131
+ where: { id: 'u1' },
132
+ data: expect.objectContaining({ emailVerified: expect.any(Date) }),
133
+ })
134
+ })
135
+
136
+ it('并发第二个请求:抢占失败(count=0)→ ok:false 且不更新用户', async () => {
137
+ mockFindUniqueToken.mockResolvedValue({
138
+ id: 't1',
139
+ userId: 'u1',
140
+ usedAt: null,
141
+ expiresAt: FUTURE,
142
+ })
143
+ mockTxTokenUpdateMany.mockResolvedValue({ count: 0 })
144
+ await expect(
145
+ confirmEmailVerificationFn({ data: { token: 'race' } }),
146
+ ).resolves.toEqual({ ok: false })
147
+ expect(mockTxUserUpdate).not.toHaveBeenCalled()
148
+ })
149
+ })
@@ -59,8 +59,8 @@ vi.mock('@/server/audit', () => ({
59
59
  auditLog: vi.fn(),
60
60
  }))
61
61
 
62
- vi.mock('@/server/email/sender', () => ({
63
- getEmailSender: () => ({ send: vi.fn() }),
62
+ vi.mock('@/server/email', () => ({
63
+ sendTemplateEmail: vi.fn().mockResolvedValue({ sent: true }),
64
64
  }))
65
65
 
66
66
  import { prisma } from '@allbluecn/database'
@@ -0,0 +1,132 @@
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 { getRequest } from '@tanstack/react-start/server'
20
+ import { z } from 'zod'
21
+ import { prisma } from '@allbluecn/database'
22
+ import { getCurrentSession } from '@/lib/session'
23
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
24
+ import { sendTemplateEmail, generateEmailToken } from '@/server/email'
25
+
26
+ const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
27
+
28
+ const appUrl = () => process.env.VITE_APP_URL ?? 'http://localhost:3000'
29
+
30
+ export const confirmEmailVerificationFn = createServerFn({ method: 'GET' })
31
+ .validator(z.object({ token: z.string().min(1) }))
32
+ .handler(async ({ data }) => {
33
+ const row = await prisma.emailVerificationToken.findUnique({
34
+ where: { token: data.token },
35
+ })
36
+ if (!row || row.usedAt || new Date() > row.expiresAt) {
37
+ return { ok: false as const }
38
+ }
39
+
40
+ const claimed = await prisma.$transaction(async (tx) => {
41
+ const claim = await tx.emailVerificationToken.updateMany({
42
+ where: { id: row.id, usedAt: null },
43
+ data: { usedAt: new Date() },
44
+ })
45
+ if (claim.count === 0) return false
46
+ await tx.user.update({
47
+ where: { id: row.userId },
48
+ data: { emailVerified: new Date() },
49
+ })
50
+ return true
51
+ })
52
+ if (!claimed) return { ok: false as const }
53
+
54
+ const user = await prisma.user.findUnique({
55
+ where: { id: row.userId },
56
+ select: { email: true, locale: true },
57
+ })
58
+ if (user) {
59
+ await sendTemplateEmail({
60
+ key: 'auth.welcome',
61
+ to: user.email,
62
+ locale: user.locale ?? 'zh',
63
+ userId: row.userId,
64
+ vars: { dashboardUrl: `${appUrl()}/dashboard` },
65
+ }).catch((err) => {
66
+ console.error('welcome email failed', {
67
+ userId: row.userId,
68
+ templateKey: 'auth.welcome',
69
+ reason: err instanceof Error ? err.message : String(err),
70
+ })
71
+ })
72
+ }
73
+
74
+ return { ok: true as const }
75
+ })
76
+
77
+ export const resendVerificationEmailFn = createServerFn({ method: 'POST' })
78
+ .validator(z.object({}))
79
+ .handler(async () => {
80
+ const request = getRequest()
81
+ const session = await getCurrentSession(request)
82
+ const userId = session?.user?.id
83
+ if (!userId) throw new Error('请先登录')
84
+
85
+ await checkAuthRateLimit('resendVerify', 5, 15 * 60 * 1000)
86
+
87
+ const user = await prisma.user.findUnique({
88
+ where: { id: userId },
89
+ select: { email: true, username: true, emailVerified: true, locale: true },
90
+ })
91
+ if (!user) throw new Error('用户不存在')
92
+ if (user.emailVerified) return { alreadyVerified: true }
93
+
94
+ const token = generateEmailToken()
95
+ await prisma.emailVerificationToken.create({
96
+ data: {
97
+ token,
98
+ userId,
99
+ expiresAt: new Date(Date.now() + VERIFY_TOKEN_TTL_MS),
100
+ },
101
+ })
102
+
103
+ await sendTemplateEmail({
104
+ key: 'auth.verify-email',
105
+ to: user.email,
106
+ locale: user.locale ?? 'zh',
107
+ userId,
108
+ vars: { url: `${appUrl()}/verify-email?token=${token}`, username: user.username },
109
+ }).catch((err) => {
110
+ console.error('resend verification email failed', {
111
+ userId,
112
+ templateKey: 'auth.verify-email',
113
+ reason: err instanceof Error ? err.message : String(err),
114
+ })
115
+ })
116
+
117
+ return { alreadyVerified: false }
118
+ })
119
+
120
+ export const getEmailVerifiedStatusFn = createServerFn({ method: 'GET' })
121
+ .validator(z.object({}))
122
+ .handler(async () => {
123
+ const request = getRequest()
124
+ const session = await getCurrentSession(request)
125
+ if (!session?.user?.id) return { verified: null as boolean | null }
126
+
127
+ const user = await prisma.user.findUnique({
128
+ where: { id: session.user.id },
129
+ select: { emailVerified: true },
130
+ })
131
+ return { verified: !!user?.emailVerified }
132
+ })
@@ -20,9 +20,10 @@ import { z } from 'zod'
20
20
  import { prisma } from '@allbluecn/database'
21
21
  import { randomBytes } from 'crypto'
22
22
  import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
23
+ import { sendTemplateEmail } from '@/server/email'
23
24
 
24
25
  const forgotPasswordSchema = z.object({
25
- email: z.string().email('请输入有效邮箱'),
26
+ email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email('请输入有效邮箱')),
26
27
  })
27
28
 
28
29
  function generateToken(): string {
@@ -35,20 +36,39 @@ export const forgotPasswordFn = createServerFn({ method: 'POST' })
35
36
  await checkAuthRateLimit('forgotPassword', 5, 15 * 60 * 1000)
36
37
  const user = await prisma.user.findUnique({
37
38
  where: { email: data.email },
38
- select: { id: true },
39
+ select: { id: true, email: true, locale: true },
39
40
  })
40
41
 
41
42
  if (user) {
42
43
  const token = generateToken()
43
44
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
44
45
 
46
+ let created = false
45
47
  try {
46
48
  await prisma.passwordResetToken.create({
47
49
  data: { token, userId: user.id, expiresAt },
48
50
  })
51
+ created = true
49
52
  } catch (dbError) {
50
53
  console.error('Database error:', dbError instanceof Error ? dbError.message : String(dbError))
51
54
  }
55
+
56
+ if (created) {
57
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
58
+ await sendTemplateEmail({
59
+ key: 'auth.reset-password',
60
+ to: user.email,
61
+ locale: user.locale ?? 'zh',
62
+ userId: user.id,
63
+ vars: { url: `${appUrl}/reset-password?token=${token}` },
64
+ }).catch((err) => {
65
+ console.error('reset password email failed', {
66
+ userId: user.id,
67
+ templateKey: 'auth.reset-password',
68
+ reason: err instanceof Error ? err.message : String(err),
69
+ })
70
+ })
71
+ }
52
72
  }
53
73
 
54
74
  return {
@@ -16,12 +16,13 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
+ import { getResolvedOAuthProviders } from '@/server/oauth-config'
19
20
 
20
21
  export interface AuthProvidersInfo {
21
22
  googleEnabled: boolean
22
23
  }
23
24
 
24
25
  export const getAuthProviders = createServerFn({ method: 'GET' }).handler(async (): Promise<AuthProvidersInfo> => {
25
- const googleEnabled = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET)
26
- return { googleEnabled }
27
- })
26
+ const providers = await getResolvedOAuthProviders()
27
+ return { googleEnabled: providers.some((p) => p.provider === 'google') }
28
+ })
@@ -17,22 +17,26 @@
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
19
  import { z } from 'zod'
20
- import { prisma } from '@allbluecn/database'
20
+ import { prisma, Prisma } from '@allbluecn/database'
21
21
  import bcrypt from 'bcryptjs'
22
22
  import { getRandomConfig, presetBgColors } from '@/components/settings/personal-team/account/avatar/avatar-config'
23
23
  import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
24
+ import { getRegistrationMode, consumeInvitationCode } from '@/server/auth-registration'
25
+ import { sendTemplateEmail, generateEmailToken } from '@/server/email'
24
26
 
25
- const registerSchema = z.object({
26
- username: z.string().min(3).max(20),
27
- email: z.string().email(),
28
- password: z.string().min(8),
29
- invitationCode: z.string().optional(),
27
+ const registerServerSchema = z.object({
28
+ username: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/),
29
+ email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email()),
30
+ password: z.string().min(8).max(32).regex(/[a-zA-Z]/).regex(/[0-9]/),
31
+ invitationCode: z.string().optional().default(''),
30
32
  })
31
33
 
32
34
  export const registerFn = createServerFn({ method: 'POST' })
33
- .validator(registerSchema)
35
+ .validator(registerServerSchema)
34
36
  .handler(async ({ data }) => {
35
37
  await checkAuthRateLimit('register', 5, 15 * 60 * 1000)
38
+ const mode = await getRegistrationMode()
39
+
36
40
  const exists = await prisma.user.findFirst({
37
41
  where: { OR: [{ email: data.email }, { username: data.username }] },
38
42
  select: { id: true },
@@ -43,16 +47,56 @@ export const registerFn = createServerFn({ method: 'POST' })
43
47
  const hash = data.username.split('').reduce((h, c) => c.charCodeAt(0) + ((h << 5) - h), 0)
44
48
  const bgColor = presetBgColors[Math.abs(hash) % presetBgColors.length]
45
49
 
46
- const user = await prisma.user.create({
47
- data: {
48
- username: data.username,
49
- email: data.email,
50
- password: hashedPassword,
51
- avatarConfig: getRandomConfig() as object,
52
- bgShape: 'square',
53
- bgColor,
54
- status: 'ACTIVE',
55
- },
56
- })
57
- return { id: user.id, email: user.email, username: user.username }
50
+ try {
51
+ const user = await prisma.$transaction(async (tx) => {
52
+ let codeId: string | null = null
53
+ if (mode === 'invite') {
54
+ if (!data.invitationCode) throw new Error('请输入邀请码')
55
+ const consumed = await consumeInvitationCode(tx, data.invitationCode)
56
+ if (!consumed.ok) throw new Error(consumed.error)
57
+ codeId = consumed.codeId
58
+ }
59
+ const created = await tx.user.create({
60
+ data: {
61
+ username: data.username,
62
+ email: data.email,
63
+ password: hashedPassword,
64
+ avatarConfig: getRandomConfig() as object,
65
+ bgShape: 'square',
66
+ bgColor,
67
+ status: 'ACTIVE',
68
+ },
69
+ })
70
+ if (codeId) {
71
+ await tx.invitationUse.create({ data: { codeId, userId: created.id } })
72
+ }
73
+ return created
74
+ })
75
+
76
+ const token = generateEmailToken()
77
+ await prisma.emailVerificationToken.create({
78
+ data: { token, userId: user.id, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
79
+ })
80
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
81
+ await sendTemplateEmail({
82
+ key: 'auth.verify-email',
83
+ to: user.email,
84
+ locale: 'zh',
85
+ userId: user.id,
86
+ vars: { url: `${appUrl}/verify-email?token=${token}`, username: user.username },
87
+ }).catch((err) => {
88
+ console.error('verification email failed', {
89
+ userId: user.id,
90
+ templateKey: 'auth.verify-email',
91
+ reason: err instanceof Error ? err.message : String(err),
92
+ })
93
+ })
94
+
95
+ return { id: user.id, email: user.email, username: user.username }
96
+ } catch (err) {
97
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
98
+ throw new Error('用户名或邮箱已存在')
99
+ }
100
+ throw new Error(err instanceof Error ? err.message : '注册失败')
101
+ }
58
102
  })
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { getRegistrationMode } from '@/server/auth-registration'
20
+
21
+ export const getRegistrationModeFn = createServerFn({ method: 'GET' }).handler(
22
+ async () => ({ mode: await getRegistrationMode() }),
23
+ )
@@ -23,7 +23,7 @@ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
23
23
 
24
24
  const resetPasswordSchema = z.object({
25
25
  token: z.string().min(1, 'Token is required'),
26
- password: z.string().min(8, '密码至少 8 位').max(32),
26
+ password: z.string().min(8, '密码至少 8 位').max(32).regex(/[a-zA-Z]/, '密码需包含字母').regex(/[0-9]/, '密码需包含数字'),
27
27
  })
28
28
 
29
29
  export const validateResetTokenFn = createServerFn({ method: 'GET' })