@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,76 @@
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 { createLazyFileRoute, Link } from '@tanstack/react-router'
19
+ import { RainbowButton } from '@/components/magicui/rainbow-button'
20
+ import { ArrowRight, Settings } from 'lucide-react'
21
+ import { AuthLayout, AuthLayoutLeft, AuthLayoutRight } from '@/components/auth/auth-layout'
22
+ import { AuthHeader } from '@/components/auth/auth-header'
23
+ import { AuthBrandPanel } from '@/components/auth/auth-brand-panel'
24
+ import { m } from '@/paraglide/messages.js'
25
+
26
+ export const Route = createLazyFileRoute('/_login/email-unsubscribe')({
27
+ component: EmailUnsubscribePage,
28
+ })
29
+
30
+ function EmailUnsubscribePage() {
31
+ const { ok } = Route.useRouteContext()
32
+
33
+ return (
34
+ <AuthLayout>
35
+ <AuthLayoutLeft>
36
+ {ok ? (
37
+ <>
38
+ <AuthHeader
39
+ title={m.common_email_unsubscribe_success()}
40
+ subtitle={m.common_email_unsubscribe_successDesc()}
41
+ />
42
+ <RainbowButton asChild className="w-full">
43
+ <Link to="/settings/notifications">
44
+ <Settings className="mr-2 h-4 w-4" />
45
+ {m.common_email_unsubscribe_manage()}
46
+ <ArrowRight className="ml-2 h-4 w-4" />
47
+ </Link>
48
+ </RainbowButton>
49
+ <div className="mt-4 text-center text-sm text-muted-foreground">
50
+ {m.common_email_unsubscribe_transactionalNote()}
51
+ </div>
52
+ </>
53
+ ) : (
54
+ <>
55
+ <AuthHeader
56
+ title={m.common_email_unsubscribe_invalid()}
57
+ subtitle={m.common_email_unsubscribe_invalidDesc()}
58
+ />
59
+ <RainbowButton asChild className="w-full">
60
+ <Link to="/login">
61
+ {m.common_email_unsubscribe_login()}
62
+ <ArrowRight className="ml-2 h-4 w-4" />
63
+ </Link>
64
+ </RainbowButton>
65
+ </>
66
+ )}
67
+ </AuthLayoutLeft>
68
+ <AuthLayoutRight>
69
+ <AuthBrandPanel
70
+ title="AllBlue"
71
+ subtitle={ok ? m.common_email_unsubscribe_success() : m.common_email_unsubscribe_invalid()}
72
+ />
73
+ </AuthLayoutRight>
74
+ </AuthLayout>
75
+ )
76
+ }
@@ -0,0 +1,28 @@
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 { createFileRoute } from '@tanstack/react-router'
19
+ import { unsubscribeByEmailTokenFn } from '@/server/serverFns/email-unsubscribe'
20
+
21
+ export const Route = createFileRoute('/_login/email-unsubscribe')({
22
+ validateSearch: (s: { token?: string }) => ({ token: s.token ?? '' }),
23
+ beforeLoad: async ({ search }) => {
24
+ if (!search.token) return { ok: false }
25
+ const r = await unsubscribeByEmailTokenFn({ data: { token: search.token } })
26
+ return { ok: r.ok }
27
+ },
28
+ })
@@ -62,7 +62,7 @@ function ForgotPasswordPage() {
62
62
  onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit() }}
63
63
  className="space-y-6"
64
64
  >
65
- <AppField name="email" form={form}>
65
+ <AppField name="email" form={form} renderError={false}>
66
66
  {(field) => (
67
67
  <IconInput
68
68
  icon={Mail}
@@ -71,6 +71,7 @@ function ForgotPasswordPage() {
71
71
  value={field.value}
72
72
  onBlur={field.handleBlur}
73
73
  onChange={(e) => field.handleChange(e.target.value)}
74
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
74
75
  />
75
76
  )}
76
77
  </AppField>
@@ -166,7 +166,7 @@ function LoginPage() {
166
166
  onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit() }}
167
167
  className="space-y-6"
168
168
  >
169
- <AppField name="email" form={form}>
169
+ <AppField name="email" form={form} renderError={false}>
170
170
  {(field) => (
171
171
  <IconInput
172
172
  icon={Mail}
@@ -175,6 +175,7 @@ function LoginPage() {
175
175
  value={field.value}
176
176
  onBlur={field.handleBlur}
177
177
  onChange={(e) => field.handleChange(e.target.value)}
178
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
178
179
  />
179
180
  )}
180
181
  </AppField>
@@ -184,7 +185,7 @@ function LoginPage() {
184
185
  <SsoSection email={email} />
185
186
  )}
186
187
  />
187
- <AppField name="password" form={form}>
188
+ <AppField name="password" form={form} renderError={false}>
188
189
  {(field) => (
189
190
  <PasswordInput
190
191
  label={m.auth_login_passwordLabel()}
@@ -192,6 +193,7 @@ function LoginPage() {
192
193
  value={field.value}
193
194
  onBlur={field.handleBlur}
194
195
  onChange={(e) => field.handleChange(e.target.value)}
196
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
195
197
  />
196
198
  )}
197
199
  </AppField>
@@ -39,7 +39,7 @@ export const Route = createLazyFileRoute('/_login/register')({
39
39
 
40
40
  function RegisterPage() {
41
41
  const router = useRouter()
42
- const { googleEnabled } = Route.useLoaderData()
42
+ const { googleEnabled, registrationMode } = Route.useLoaderData()
43
43
  const { invite, email: emailFromUrl } = Route.useSearch()
44
44
  const registerFeatures = [
45
45
  { icon: Users, label: m.auth_features_userResearch(), desc: m.auth_features_userResearchDesc() },
@@ -58,6 +58,10 @@ function RegisterPage() {
58
58
  },
59
59
  validators: [{ run: registerSchema as any, triggers: ['change'] }],
60
60
  onSubmit: async ({ value }) => {
61
+ if (registrationMode === 'invite' && !value.invitationCode?.trim()) {
62
+ toast.error(m.common_form_invitationCodeRequired())
63
+ return
64
+ }
61
65
  try {
62
66
  await registerFn({ data: value })
63
67
  toast.success(m.auth_register_success())
@@ -86,7 +90,7 @@ function RegisterPage() {
86
90
  onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit() }}
87
91
  className="space-y-6"
88
92
  >
89
- <AppField name="username" form={form}>
93
+ <AppField name="username" form={form} renderError={false}>
90
94
  {(field) => (
91
95
  <IconInput
92
96
  icon={User}
@@ -95,10 +99,11 @@ function RegisterPage() {
95
99
  value={field.value}
96
100
  onBlur={field.handleBlur}
97
101
  onChange={(e) => field.handleChange(e.target.value)}
102
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
98
103
  />
99
104
  )}
100
105
  </AppField>
101
- <AppField name="email" form={form}>
106
+ <AppField name="email" form={form} renderError={false}>
102
107
  {(field) => (
103
108
  <IconInput
104
109
  icon={Mail}
@@ -107,11 +112,12 @@ function RegisterPage() {
107
112
  value={field.value}
108
113
  onBlur={field.handleBlur}
109
114
  onChange={(e) => field.handleChange(e.target.value)}
115
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
110
116
  />
111
117
  )}
112
118
  </AppField>
113
119
  <div className="grid grid-cols-2 gap-4">
114
- <AppField name="password" form={form}>
120
+ <AppField name="password" form={form} renderError={false}>
115
121
  {(field) => (
116
122
  <PasswordInput
117
123
  label={m.auth_register_passwordLabel()}
@@ -119,10 +125,11 @@ function RegisterPage() {
119
125
  value={field.value}
120
126
  onBlur={field.handleBlur}
121
127
  onChange={(e) => field.handleChange(e.target.value)}
128
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
122
129
  />
123
130
  )}
124
131
  </AppField>
125
- <AppField name="confirmPassword" form={form}>
132
+ <AppField name="confirmPassword" form={form} renderError={false}>
126
133
  {(field) => (
127
134
  <PasswordInput
128
135
  label={m.auth_register_confirmPasswordLabel()}
@@ -130,22 +137,26 @@ function RegisterPage() {
130
137
  value={field.value}
131
138
  onBlur={field.handleBlur}
132
139
  onChange={(e) => field.handleChange(e.target.value)}
140
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
133
141
  />
134
142
  )}
135
143
  </AppField>
136
144
  </div>
137
- <AppField name="invitationCode" form={form}>
138
- {(field) => (
139
- <IconInput
140
- icon={QrCode}
141
- label={m.auth_register_invitationCodeLabel()}
142
- placeholder={m.auth_register_invitationCodePlaceholder()}
143
- value={field.value}
144
- onBlur={field.handleBlur}
145
- onChange={(e) => field.handleChange(e.target.value)}
146
- />
147
- )}
148
- </AppField>
145
+ {registrationMode === 'invite' && (
146
+ <AppField name="invitationCode" form={form} renderError={false}>
147
+ {(field) => (
148
+ <IconInput
149
+ icon={QrCode}
150
+ label={m.auth_register_invitationCodeLabel()}
151
+ placeholder={m.auth_register_invitationCodePlaceholder()}
152
+ value={field.value}
153
+ onBlur={field.handleBlur}
154
+ onChange={(e) => field.handleChange(e.target.value)}
155
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
156
+ />
157
+ )}
158
+ </AppField>
159
+ )}
149
160
  <form.Subscribe
150
161
  selector={(state) => state.isSubmitting}
151
162
  children={(isSubmitting) => (
@@ -18,11 +18,18 @@
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
19
  import { z } from 'zod'
20
20
  import { getAuthProviders } from '@/server/serverFns/auth/providers'
21
+ import { getRegistrationModeFn } from '@/server/serverFns/auth/registration-mode'
21
22
 
22
23
  export const Route = createFileRoute('/_login/register')({
23
24
  validateSearch: z.object({
24
25
  invite: z.string().optional(),
25
26
  email: z.string().optional(),
26
27
  }),
27
- loader: () => getAuthProviders(),
28
+ loader: async () => {
29
+ const [providers, { mode: registrationMode }] = await Promise.all([
30
+ getAuthProviders(),
31
+ getRegistrationModeFn(),
32
+ ])
33
+ return { ...providers, registrationMode }
34
+ },
28
35
  })
@@ -66,7 +66,7 @@ function ResetPasswordPage() {
66
66
  className="space-y-6"
67
67
  >
68
68
  <div className="grid grid-cols-2 gap-4">
69
- <AppField name="password" form={form}>
69
+ <AppField name="password" form={form} renderError={false}>
70
70
  {(field) => (
71
71
  <PasswordInput
72
72
  label={m.auth_resetPassword_newPasswordLabel()}
@@ -74,10 +74,11 @@ function ResetPasswordPage() {
74
74
  value={field.value}
75
75
  onBlur={field.handleBlur}
76
76
  onChange={(e) => field.handleChange(e.target.value)}
77
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
77
78
  />
78
79
  )}
79
80
  </AppField>
80
- <AppField name="confirmPassword" form={form}>
81
+ <AppField name="confirmPassword" form={form} renderError={false}>
81
82
  {(field) => (
82
83
  <PasswordInput
83
84
  label={m.auth_resetPassword_confirmPasswordLabel()}
@@ -85,6 +86,7 @@ function ResetPasswordPage() {
85
86
  value={field.value}
86
87
  onBlur={field.handleBlur}
87
88
  onChange={(e) => field.handleChange(e.target.value)}
89
+ error={field.meta.isTouched && field.errors.length > 0 ? (field.errors.map((e) => e.message).filter(Boolean)[0] as string | undefined) : undefined}
88
90
  />
89
91
  )}
90
92
  </AppField>
@@ -18,29 +18,52 @@
18
18
  import { z } from "zod"
19
19
  import { m } from '@/paraglide/messages.js'
20
20
 
21
+ const passwordRule = z
22
+ .string()
23
+ .min(8, m.common_form_passwordMin8())
24
+ .max(32, m.common_form_passwordMax32())
25
+ .regex(/[a-zA-Z]/, m.common_form_passwordNeedLetter())
26
+ .regex(/[0-9]/, m.common_form_passwordNeedDigit())
27
+
28
+ const usernameRule = z
29
+ .string()
30
+ .regex(/^[a-zA-Z0-9_-]{3,20}$/, m.common_form_usernameCharset())
31
+
32
+ const invitationCodeRule = z
33
+ .string()
34
+ .refine((v) => v === "" || /^[A-Za-z0-9]{3,32}$/.test(v), {
35
+ message: m.common_form_invitationCodeFormat(),
36
+ })
37
+ .default("")
38
+
39
+ const emailRule = z
40
+ .string()
41
+ .transform((v) => v.toLowerCase().trim())
42
+ .pipe(z.string().email(m.common_form_invalidEmail()))
43
+
21
44
  export const loginSchema = z.object({
22
- email: z.string().email(m.common_form_invalidEmail()),
45
+ email: emailRule,
23
46
  password: z.string().min(1, m.common_form_passwordNotEmpty()),
24
47
  remember: z.boolean(),
25
48
  })
26
49
 
27
50
  export const registerSchema = z.object({
28
- username: z.string().min(3, m.common_form_usernameMin3()).max(20),
29
- email: z.string().email(m.common_form_invalidEmail()),
30
- password: z.string().min(8, m.common_form_passwordMin8()),
51
+ username: usernameRule,
52
+ email: emailRule,
53
+ password: passwordRule,
31
54
  confirmPassword: z.string(),
32
- invitationCode: z.string().min(1, m.common_form_invitationCodeRequired()),
55
+ invitationCode: invitationCodeRule,
33
56
  }).refine((data) => data.password === data.confirmPassword, {
34
57
  message: m.common_form_passwordMismatch(),
35
58
  path: ["confirmPassword"],
36
59
  })
37
60
 
38
61
  export const forgotPasswordSchema = z.object({
39
- email: z.string().email(m.common_form_invalidEmail()),
62
+ email: emailRule,
40
63
  })
41
64
 
42
65
  export const resetPasswordSchema = z.object({
43
- password: z.string().min(8, m.common_form_passwordMin8()),
66
+ password: passwordRule,
44
67
  confirmPassword: z.string(),
45
68
  }).refine((d) => d.password === d.confirmPassword, {
46
69
  message: m.common_form_passwordMismatch(),
@@ -0,0 +1,82 @@
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 { createLazyFileRoute, Link } from '@tanstack/react-router'
19
+ import { toast } from '@/components/ui/sonner'
20
+ import { RainbowButton } from '@/components/magicui/rainbow-button'
21
+ import { ArrowRight } from 'lucide-react'
22
+ import { AuthLayout, AuthLayoutLeft, AuthLayoutRight } from '@/components/auth/auth-layout'
23
+ import { AuthHeader } from '@/components/auth/auth-header'
24
+ import { AuthBrandPanel } from '@/components/auth/auth-brand-panel'
25
+ import { resendVerificationEmailFn } from '@/server/serverFns/auth/email-verification'
26
+ import { m } from '@/paraglide/messages.js'
27
+
28
+ export const Route = createLazyFileRoute('/_login/verify-email')({
29
+ component: VerifyEmailPage,
30
+ })
31
+
32
+ function VerifyEmailPage() {
33
+ const { verified } = Route.useRouteContext()
34
+
35
+ const handleResend = async () => {
36
+ try {
37
+ const r = await resendVerificationEmailFn({ data: {} })
38
+ toast.success(
39
+ r.alreadyVerified ? m.auth_verify_alreadyVerified() : m.auth_verify_resendSuccess(),
40
+ )
41
+ } catch (err) {
42
+ toast.error(err instanceof Error ? err.message : String(err))
43
+ }
44
+ }
45
+
46
+ return (
47
+ <AuthLayout>
48
+ <AuthLayoutLeft>
49
+ {verified ? (
50
+ <>
51
+ <AuthHeader title={m.auth_verify_success()} subtitle={m.auth_verify_successDesc()} />
52
+ <RainbowButton asChild className="w-full">
53
+ <Link to="/dashboard">
54
+ {m.auth_verify_goDashboard()}
55
+ <ArrowRight className="ml-2 h-4 w-4" />
56
+ </Link>
57
+ </RainbowButton>
58
+ </>
59
+ ) : (
60
+ <>
61
+ <AuthHeader title={m.auth_verify_invalid()} subtitle={m.auth_verify_invalidDesc()} />
62
+ <RainbowButton className="w-full" onClick={handleResend}>
63
+ {m.auth_verify_resend()}
64
+ </RainbowButton>
65
+ <div className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground">
66
+ <span>{m.auth_verify_notLogged()}</span>
67
+ <Link to="/login" className="text-primary hover:underline">
68
+ {m.auth_login_submit()}
69
+ </Link>
70
+ </div>
71
+ </>
72
+ )}
73
+ </AuthLayoutLeft>
74
+ <AuthLayoutRight>
75
+ <AuthBrandPanel
76
+ title="AllBlue"
77
+ subtitle={verified ? m.auth_verify_success() : m.auth_verify_invalid()}
78
+ />
79
+ </AuthLayoutRight>
80
+ </AuthLayout>
81
+ )
82
+ }
@@ -0,0 +1,28 @@
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 { createFileRoute } from '@tanstack/react-router'
19
+ import { confirmEmailVerificationFn } from '@/server/serverFns/auth/email-verification'
20
+
21
+ export const Route = createFileRoute('/_login/verify-email')({
22
+ validateSearch: (s: { token?: string }) => ({ token: s.token ?? '' }),
23
+ beforeLoad: async ({ search }) => {
24
+ if (!search.token) return { verified: false }
25
+ const r = await confirmEmailVerificationFn({ data: { token: search.token } })
26
+ return { verified: r.ok }
27
+ },
28
+ })
@@ -23,6 +23,7 @@ import { m } from '@/paraglide/messages.js'
23
23
  import { isDesktopServer } from '@/server/desktop-guest'
24
24
  import { listPinnedWorkspacesBriefFn } from '@/server/serverFns/workspace'
25
25
  import { listKnowledgeFn } from '@/server/serverFns/knowledge'
26
+ import { ensureVerifyReminderFn } from '@/server/serverFns/auth/verify-reminder'
26
27
  import { WORKSPACES_QUERY_KEY } from '@/lib/collections/reference/workspaces-collection'
27
28
  import { KNOWLEDGE_BASES_QUERY_KEY } from '@/lib/collections/knowledge/knowledge-bases-collection'
28
29
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
@@ -177,6 +178,10 @@ export const Route = createFileRoute('/_nav')({
177
178
  ]).catch(() => {
178
179
  // 预取失败降级为客户端 fetch(与旧行为一致),不阻塞页面
179
180
  })
181
+
182
+ if (context.session?.user?.id) {
183
+ ensureVerifyReminderFn({ data: {} }).catch(() => undefined)
184
+ }
180
185
  },
181
186
  notFoundComponent: NavNotFound,
182
187
  errorComponent: GlobalErrorBoundary,
@@ -17,11 +17,12 @@
17
17
 
18
18
  import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { isSaasEdition } from '@/lib/edition'
20
+ import { isDesktopEdition } from '@/lib/edition-desktop'
20
21
  import AgencyHeroSection from '@/components/shadcn-space/blocks/hero-01'
21
22
 
22
23
  export const Route = createFileRoute('/_public/')({
23
24
  beforeLoad: () => {
24
- if (!isSaasEdition) throw redirect({ to: '/dashboard' })
25
+ if (!isSaasEdition || isDesktopEdition()) throw redirect({ to: '/dashboard' })
25
26
  },
26
27
  component: IndexPage,
27
28
  })
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { getSession } from 'start-authjs'
3
3
  import { prisma } from '@allbluecn/database'
4
4
  import { getProService, isProEdition } from '@/server/pro-services'
5
- import { authConfig } from '@/lib/auth/auth.config'
5
+ import { getAuthConfig } from '@/lib/auth/auth.config'
6
6
  import { generateEntitlementsToken } from '@/server/entitlements-token'
7
7
  import type { FeatureKey } from '@allbluecn/kernel'
8
8
 
@@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/auth/desktop-callback')({
10
10
  server: {
11
11
  handlers: {
12
12
  GET: async ({ request }) => {
13
- const session = await getSession(request, authConfig)
13
+ const session = await getSession(request, await getAuthConfig())
14
14
  if (!session?.user) {
15
15
  return new Response('未登录', { status: 401 })
16
16
  }
@@ -18,6 +18,7 @@
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
19
  import { getProService } from '@/server/pro-services'
20
20
  import { WebhookSignatureError } from '@allbluecn/kernel'
21
+ import { sendBillingEmailForEvent } from '@/server/billing-email'
21
22
 
22
23
  export const Route = createFileRoute('/api/billing-webhook')({
23
24
  server: {
@@ -32,6 +33,9 @@ export const Route = createFileRoute('/api/billing-webhook')({
32
33
  const rawBody = await request.text()
33
34
  try {
34
35
  await (getProService('billing')!)?.handleWebhookEvent(rawBody, signature)
36
+ if (getProService('billing')) {
37
+ await sendBillingEmailForEvent(rawBody)
38
+ }
35
39
  return new Response(JSON.stringify({ received: true }), {
36
40
  status: 200,
37
41
  headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,100 @@
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 { createFileRoute } from '@tanstack/react-router'
19
+ import { prisma } from '@allbluecn/database'
20
+ import { sendTemplateEmail } from '@/server/email'
21
+ import { APP_VERSION } from '@/lib/app-version'
22
+
23
+ const BATCH_SIZE = 50
24
+ const LAST_RELEASED_KEY = 'changelog.lastReleasedVersion'
25
+
26
+ async function isAuthorized(request: Request): Promise<boolean> {
27
+ const secret = process.env.CRON_SECRET
28
+ if (!secret) {
29
+ return process.env.NODE_ENV !== 'production'
30
+ }
31
+ return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
32
+ }
33
+
34
+ export const Route = createFileRoute('/api/cron/changelog-release')({
35
+ server: {
36
+ handlers: {
37
+ GET: async ({ request }) => {
38
+ if (!(await isAuthorized(request))) {
39
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
40
+ }
41
+
42
+ const current = APP_VERSION
43
+ const last = await prisma.systemSetting.findUnique({
44
+ where: { key: LAST_RELEASED_KEY },
45
+ })
46
+ if (last?.value === current) {
47
+ return Response.json({ done: true, reason: 'up-to-date' })
48
+ }
49
+
50
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
51
+
52
+ // Prisma 无 NOT EXISTS 便捷写法:用 emailLog.dedupeKey 前缀反查已发送 email,
53
+ // 再在 subscriber 查询层用 notIn 排除,避免「取 batch → filter」导致的死循环。
54
+ const sentEmails = await prisma.emailLog.findMany({
55
+ where: { dedupeKey: { startsWith: `release:${current}:` } },
56
+ select: { to: true },
57
+ })
58
+ const sentEmailSet = new Set(sentEmails.map((e) => e.to))
59
+
60
+ const subs = await prisma.emailSubscriber.findMany({
61
+ where: {
62
+ confirmedAt: { not: null },
63
+ unsubscribedAt: null,
64
+ email: { notIn: [...sentEmailSet] },
65
+ },
66
+ select: { email: true, locale: true, confirmToken: true },
67
+ take: BATCH_SIZE,
68
+ })
69
+
70
+ let sent = 0
71
+ for (const sub of subs) {
72
+ const dedupeKey = `release:${current}:${sub.email}`
73
+ const res = await sendTemplateEmail({
74
+ key: 'product.changelog-release',
75
+ to: sub.email,
76
+ locale: sub.locale,
77
+ dedupeKey,
78
+ unsubscribeUrl: `${appUrl}/email-unsubscribe?token=${sub.confirmToken}`,
79
+ vars: {
80
+ version: current,
81
+ highlights: '',
82
+ detailUrl: `${appUrl}/changelog`,
83
+ },
84
+ })
85
+ if (res.sent) sent++
86
+ }
87
+
88
+ if (subs.length < BATCH_SIZE) {
89
+ await prisma.systemSetting.upsert({
90
+ where: { key: LAST_RELEASED_KEY },
91
+ create: { key: LAST_RELEASED_KEY, value: current, desc: 'changelog 已发布版本号' },
92
+ update: { value: current },
93
+ })
94
+ return Response.json({ done: true, sent })
95
+ }
96
+ return Response.json({ done: false, sent })
97
+ },
98
+ },
99
+ },
100
+ })