@allbluecn/web-app 0.4.16 → 0.4.18

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 (78) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
  15. package/src/components/story/display/display-dropdown.tsx +6 -7
  16. package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
  17. package/src/components/story/story-list-view.tsx +13 -2
  18. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  19. package/src/hooks/use-notifications.ts +119 -0
  20. package/src/lib/app-version.ts +4 -1
  21. package/src/lib/auth/auth.config.ts +17 -1
  22. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  23. package/src/lib/changelog/sdk-versions.ts +29 -19
  24. package/src/lib/collections/ai/index.ts +0 -1
  25. package/src/lib/collections/index.ts +0 -1
  26. package/src/plugins/.gen-manifest.json +13 -0
  27. package/src/plugins/modules.gen.ts +20 -0
  28. package/src/plugins/plugins.gen.ts +20 -0
  29. package/src/plugins/ui.gen.ts +10 -0
  30. package/src/routeTree.gen.ts +70 -0
  31. package/src/routes/_login/login/route.tsx +10 -1
  32. package/src/routes/_login/register/route.lazy.tsx +3 -2
  33. package/src/routes/_login/register/route.tsx +5 -0
  34. package/src/routes/_nav/inspiration/route.tsx +8 -0
  35. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  36. package/src/routes/_nav/notifications/route.tsx +8 -0
  37. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  38. package/src/routes/_nav/prd/index.tsx +9 -0
  39. package/src/routes/_nav/prd/route.tsx +8 -0
  40. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  41. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  43. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  44. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  45. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  46. package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
  47. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  48. package/src/routes/_nav/tags/index.tsx +8 -0
  49. package/src/routes/_nav/tags/route.tsx +8 -0
  50. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  51. package/src/routes/api/notifications/stream.ts +88 -0
  52. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  53. package/src/routes/invite/$token/route.tsx +16 -8
  54. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  55. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  56. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  57. package/src/server/notifications/event-bus.ts +16 -0
  58. package/src/server/notifications/notification-service.ts +86 -0
  59. package/src/server/resources.ts +10 -2
  60. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  61. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  62. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  63. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  64. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  65. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  66. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  67. package/src/server/serverFns/billing.ts +23 -2
  68. package/src/server/serverFns/notifications.ts +155 -0
  69. package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
  70. package/src/server/serverFns/story/story-comments.ts +42 -0
  71. package/src/server/serverFns/story/story-crud/labels.ts +45 -6
  72. package/src/server/serverFns/story/story-crud.ts +8 -2
  73. package/src/server/serverFns/story/story-groups.ts +26 -7
  74. package/src/server/serverFns/story/story-search.ts +4 -0
  75. package/src/server/serverFns/story-display-options.ts +15 -4
  76. package/src/server/serverFns/team.ts +573 -53
  77. package/src/start.ts +23 -1
  78. package/vite.shared.ts +13 -0
@@ -0,0 +1,88 @@
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 { toServerSentEventsResponse } from '@tanstack/ai'
20
+ import type { StreamChunk } from '@tanstack/ai'
21
+ import { prisma } from '@allbluecn/database'
22
+ import { getCurrentSession } from '@/lib/session'
23
+ import { notificationBus } from '@/server/notifications/event-bus'
24
+ import type { NotificationSseValue } from '@allbluecn/shared'
25
+
26
+ export const Route = createFileRoute('/api/notifications/stream')({
27
+ server: {
28
+ handlers: {
29
+ GET: async ({ request }) => {
30
+ let session = null as any
31
+ try {
32
+ session = await getCurrentSession(request)
33
+ } catch {
34
+ return new Response('Unauthorized', { status: 401 })
35
+ }
36
+ if (!session?.user?.id) {
37
+ return new Response('Unauthorized', { status: 401 })
38
+ }
39
+ const userId = session.user.id
40
+
41
+ const abortController = new AbortController()
42
+ request.signal.addEventListener('abort', () => abortController.abort())
43
+
44
+ const queue: NotificationSseValue[] = []
45
+ let notify: (() => void) | null = null
46
+
47
+ const unsubscribe = notificationBus.subscribe(userId, (value) => {
48
+ queue.push(value)
49
+ notify?.()
50
+ })
51
+
52
+ async function* stream(): AsyncGenerator<StreamChunk> {
53
+ try {
54
+ const count = await prisma.notification.count({
55
+ where: { userId, readAt: null },
56
+ })
57
+ yield {
58
+ type: 'CUSTOM',
59
+ name: 'notifications',
60
+ value: { kind: 'unread_snapshot', count },
61
+ } as unknown as StreamChunk
62
+
63
+ while (!abortController.signal.aborted) {
64
+ if (queue.length === 0) {
65
+ await new Promise<void>((resolve) => {
66
+ notify = resolve
67
+ setTimeout(resolve, 25000)
68
+ })
69
+ notify = null
70
+ continue
71
+ }
72
+ const value = queue.shift()!
73
+ yield {
74
+ type: 'CUSTOM',
75
+ name: 'notifications',
76
+ value,
77
+ } as unknown as StreamChunk
78
+ }
79
+ } finally {
80
+ unsubscribe()
81
+ }
82
+ }
83
+
84
+ return toServerSentEventsResponse(stream(), { abortController })
85
+ },
86
+ },
87
+ },
88
+ })
@@ -12,64 +12,328 @@
12
12
  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
13
  // GNU Affero General Public License for more details.
14
14
  //
15
- // You should have received a copy of this license.
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/>.
16
17
 
17
- import { createLazyFileRoute, useNavigate } from '@tanstack/react-router'
18
- import { useQuery, useMutation } from '@tanstack/react-query'
18
+ import { createLazyFileRoute, Link, useNavigate } from '@tanstack/react-router'
19
+ import { useState, useEffect } from 'react'
20
+ import { useMutation } from '@tanstack/react-query'
19
21
  import { useTranslation } from 'react-i18next'
20
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
21
- import { Button } from '@/components/ui/button'
22
- import { Badge } from '@/components/ui/badge'
23
- import { toast } from 'sonner'
24
- import { getTeamInvitationByTokenFn, acceptTeamInvitationFn } from '@/server/serverFns/team'
25
- import { ArrowRight } from 'lucide-react'
22
+ import { toast } from '@/components/ui/sonner'
23
+ import { RainbowButton } from '@/components/magicui/rainbow-button'
24
+ import { UserAvatar } from '@allbluecn/ui'
25
+ import { AuthLayout, AuthLayoutLeft, AuthLayoutRight } from '@/components/auth/auth-layout'
26
+ import { AuthBrandPanel } from '@/components/auth/auth-brand-panel'
27
+ import { IconInput } from '@/components/auth/icon-input'
28
+ import { Card, CardContent } from '@/components/ui/card'
29
+ import { ArrowRight, CheckCircle2, Info, Loader2, Mail, UserCircle } from 'lucide-react'
30
+ import { useDebouncedCallback } from '@/lib/pacer'
31
+ import { acceptTeamInvitationFn, applyTeamInvitationFn, checkInviteEmailRegisteredFn } from '@/server/serverFns/team'
32
+
33
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
26
34
 
27
35
  export const Route = createLazyFileRoute('/invite/$token')({
28
36
  component: InvitePage,
29
37
  })
30
38
 
39
+ type EmailCheckState = 'idle' | 'checking' | 'registered' | 'unregistered'
40
+
31
41
  function InvitePage() {
32
- const { token } = Route.useRouteContext()
42
+ const { token, inv } = Route.useRouteContext()
33
43
  const navigate = useNavigate()
34
44
  const { t } = useTranslation('invite')
35
- const { data, isLoading } = useQuery({
36
- queryKey: ['teamInvitation', token],
37
- queryFn: () => getTeamInvitationByTokenFn({ data: { token } }),
38
- })
39
- const accept = useMutation({
45
+ const ctx = Route.useRouteContext() as any
46
+ const session = ctx?.session
47
+ const isLoggedIn = !!session?.user?.id
48
+ const emailPrefilled = inv?.channel === 'email' && !!inv?.email
49
+
50
+ const [emailInput, setEmailInput] = useState('')
51
+ const [emailCheck, setEmailCheck] = useState<EmailCheckState>(
52
+ emailPrefilled ? 'checking' : 'idle',
53
+ )
54
+ const [applied, setApplied] = useState(false)
55
+
56
+ const acceptMutation = useMutation({
40
57
  mutationFn: () => acceptTeamInvitationFn({ data: { token } }),
41
- onSuccess: () => {
42
- toast.success(t('accepted'))
43
- navigate({ to: '/dashboard' })
44
- },
58
+ onSuccess: () => setApplied(true),
45
59
  onError: (e: Error) => toast.error(e.message),
46
60
  })
47
- if (isLoading) return null
48
- if (!data) return <div className="p-10">{t('notFound')}</div>
49
- const ok = data.effectiveStatus === 'pending'
50
- return (
51
- <div className="flex min-h-dvh items-center justify-center p-6">
52
- <Card className="w-full max-w-md">
53
- <CardHeader>
54
- <CardTitle>{t('title', { owner: data.owner.username })}</CardTitle>
55
- </CardHeader>
56
- <CardContent className="space-y-4">
57
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
58
- {t('seatType')}:
59
- <Badge variant="outline">{data.seatType === 'guest' ? t('guest') : t('member')}</Badge>
61
+
62
+ const applyMutation = useMutation({
63
+ mutationFn: (email: string) => applyTeamInvitationFn({ data: { token, email } }),
64
+ onSuccess: () => setApplied(true),
65
+ onError: (e: Error) => toast.error(e.message),
66
+ })
67
+
68
+ const checkEmail = useDebouncedCallback(async (email: string) => {
69
+ try {
70
+ const { registered } = await checkInviteEmailRegisteredFn({ data: { email } })
71
+ setEmailCheck(registered ? 'registered' : 'unregistered')
72
+ } catch {
73
+ setEmailCheck('idle')
74
+ }
75
+ }, { wait: 500 })
76
+
77
+ useEffect(() => {
78
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
79
+ if (!EMAIL_RE.test(email)) {
80
+ setEmailCheck('idle')
81
+ return
82
+ }
83
+ setEmailCheck('checking')
84
+ void checkEmail(email.toLowerCase())
85
+ }, [emailInput, emailPrefilled, inv?.email, checkEmail])
86
+
87
+ if (!inv || inv.effectiveStatus === 'expired' || inv.effectiveStatus === 'revoked') {
88
+ return (
89
+ <AuthLayout>
90
+ <AuthLayoutLeft>
91
+ <div className="space-y-4 text-center">
92
+ <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
93
+ <Info className="size-8 text-destructive" />
94
+ </div>
95
+ <h1 className="text-2xl font-semibold">{t('expiredOrInvalid')}</h1>
96
+ <p className="text-sm text-muted-foreground">{t('expiredOrInvalidDesc')}</p>
97
+ <Link to="/login" className="mt-4 inline-block text-sm text-primary hover:underline">
98
+ {t('goToLogin')}
99
+ </Link>
60
100
  </div>
61
- {ok ? (
62
- <Button className="w-full" disabled={accept.isPending} onClick={() => accept.mutate()}>
63
- {t('accept')}
64
- <ArrowRight className="ml-2 size-4" />
65
- </Button>
66
- ) : (
67
- <p className="text-sm text-muted-foreground">
68
- {data.effectiveStatus === 'expired' ? t('expired') : t('invalid')}
69
- </p>
70
- )}
71
- </CardContent>
72
- </Card>
101
+ </AuthLayoutLeft>
102
+ <AuthLayoutRight>
103
+ <AuthBrandPanel footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>} title={t('brandTitle')} />
104
+ </AuthLayoutRight>
105
+ </AuthLayout>
106
+ )
107
+ }
108
+
109
+ if (!isLoggedIn && inv.channel === 'email' && inv.effectiveStatus === 'pending_approval') {
110
+ return (
111
+ <AuthLayout>
112
+ <AuthLayoutLeft>
113
+ <div className="space-y-4 text-center">
114
+ <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
115
+ <Loader2 className="size-8 animate-spin text-primary" />
116
+ </div>
117
+ <h1 className="text-2xl font-semibold">{t('awaitingApproval')}</h1>
118
+ <p className="text-sm text-muted-foreground">{t('awaitingApprovalDesc')}</p>
119
+ <div className="space-y-2">
120
+ <Link
121
+ to="/login"
122
+ search={{ redirect: `/invite/${token}` }}
123
+ className="block w-full rounded-lg border border-primary/20 bg-primary/5 px-4 py-2 text-sm text-primary font-medium hover:bg-primary/10 transition-colors"
124
+ >
125
+ {t('loginNow')}
126
+ </Link>
127
+ </div>
128
+ </div>
129
+ </AuthLayoutLeft>
130
+ <AuthLayoutRight>
131
+ <AuthBrandPanel footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>} title={t('brandTitle')} />
132
+ </AuthLayoutRight>
133
+ </AuthLayout>
134
+ )
135
+ }
136
+
137
+ const owner = inv.owner
138
+ const features = [
139
+ { icon: Mail, label: t('featureCollaboration'), desc: t('featureCollaborationDesc') },
140
+ { icon: UserCircle, label: t('featureTeam'), desc: t('featureTeamDesc') },
141
+ ]
142
+
143
+ const awaitingBlock = (
144
+ <div className="space-y-3">
145
+ <div className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 p-3">
146
+ <Loader2 className="size-4 animate-spin text-primary shrink-0" />
147
+ <p className="text-sm text-primary font-medium">{t('awaitingApproval')}</p>
148
+ </div>
149
+ <p className="text-center text-xs text-muted-foreground">{t('awaitingApprovalDesc')}</p>
150
+ <Link
151
+ to="/dashboard"
152
+ className="flex w-full items-center justify-center gap-2 rounded-lg border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
153
+ >
154
+ {t('goToDashboard')}
155
+ <ArrowRight className="size-4" />
156
+ </Link>
73
157
  </div>
74
158
  )
159
+
160
+ return (
161
+ <AuthLayout>
162
+ <AuthLayoutLeft>
163
+ <div className="space-y-3 text-center">
164
+ <UserAvatar
165
+ avatarConfig={owner.avatarConfig as any}
166
+ bgColor={owner.bgColor}
167
+ bgShape={owner.bgShape as 'circle' | 'rounded' | 'square' | null}
168
+ className="mx-auto size-16"
169
+ name={owner.username}
170
+ size="2xl"
171
+ />
172
+ <div className="space-y-1">
173
+ <p className="text-2xl font-bold tracking-tight">{owner.username}</p>
174
+ <p className="text-sm font-medium text-primary">
175
+ {inv.channel === 'link'
176
+ ? t('inviteYou', { owner: owner.email })
177
+ : t('seatMessage', { seat: inv.selectedRole === 'member' ? t('seatMember') : t('seatViewer') })}
178
+ </p>
179
+ </div>
180
+ </div>
181
+
182
+ {isLoggedIn ? (
183
+ <div className="space-y-4">
184
+ <Card className="border border-border">
185
+ <CardContent>
186
+ <div className="flex items-center gap-3">
187
+ <UserAvatar
188
+ avatarConfig={session.user?.avatarConfig as any}
189
+ bgColor={session.user?.bgColor}
190
+ bgShape={session.user?.bgShape as 'circle' | 'rounded' | 'square' | null}
191
+ className="shrink-0"
192
+ name={session.user?.username ?? session.user?.name}
193
+ size="md"
194
+ />
195
+ <div className="min-w-0 flex-1">
196
+ <p className="truncate text-sm font-medium">
197
+ {session.user?.name ?? session.user?.email}
198
+ </p>
199
+ <p className="truncate text-xs text-muted-foreground">{session.user?.email}</p>
200
+ </div>
201
+ <CheckCircle2 className="size-4 text-green-500 shrink-0" />
202
+ </div>
203
+ </CardContent>
204
+ </Card>
205
+
206
+ {applied || inv.myRequestStatus === 'pending' || (inv.channel === 'email' && inv.effectiveStatus === 'pending_approval') ? (
207
+ awaitingBlock
208
+ ) : inv.alreadyJoined ? (
209
+ <div className="space-y-3">
210
+ <div className="flex items-center gap-2 rounded-lg border border-green-500/30 bg-green-500/10 p-3">
211
+ <CheckCircle2 className="size-4 text-green-500 shrink-0" />
212
+ <p className="text-sm font-medium text-green-600">{t('alreadyJoined')}</p>
213
+ </div>
214
+ <p className="text-center text-xs text-muted-foreground">{t('alreadyJoinedDesc')}</p>
215
+ <Link
216
+ to="/dashboard"
217
+ className="flex w-full items-center justify-center gap-2 rounded-lg border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
218
+ >
219
+ {t('goToDashboard')}
220
+ </Link>
221
+ </div>
222
+ ) : (
223
+ <>
224
+ <RainbowButton
225
+ className="w-full"
226
+ disabled={acceptMutation.isPending}
227
+ onClick={() => acceptMutation.mutate()}
228
+ >
229
+ {acceptMutation.isPending ? (
230
+ <>
231
+ <Loader2 className="mr-2 size-4 animate-spin" />
232
+ {t('joining')}
233
+ </>
234
+ ) : (
235
+ <>
236
+ {inv.channel === 'email' ? t('acceptJoin') : t('requestToJoin')}
237
+ <ArrowRight className="ml-2 size-4" />
238
+ </>
239
+ )}
240
+ </RainbowButton>
241
+ {inv.channel === 'link' && (
242
+ <p className="text-center text-xs text-muted-foreground">{t('linkApprovalNote')}</p>
243
+ )}
244
+ </>
245
+ )}
246
+ </div>
247
+ ) : (
248
+ <div className="space-y-4">
249
+ <IconInput
250
+ icon={Mail}
251
+ label={t('emailLabel')}
252
+ onChange={(e) => setEmailInput(e.target.value)}
253
+ placeholder="name@example.com"
254
+ type="email"
255
+ value={emailPrefilled ? inv!.email ?? '' : emailInput}
256
+ disabled={emailPrefilled}
257
+ />
258
+
259
+ {applied ? (
260
+ <>
261
+ <div className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 p-3">
262
+ <CheckCircle2 className="size-4 text-primary shrink-0" />
263
+ <div className="min-w-0">
264
+ <p className="text-sm font-medium text-primary">{t('appliedHint')}</p>
265
+ <p className="text-xs text-muted-foreground">{t('appliedHintLogin')}</p>
266
+ </div>
267
+ </div>
268
+ <Link to="/login" search={{ redirect: `/invite/${token}` }} className="block">
269
+ <RainbowButton className="w-full">
270
+ {t('loginNow')}
271
+ <ArrowRight className="ml-2 size-4" />
272
+ </RainbowButton>
273
+ </Link>
274
+ </>
275
+ ) : emailCheck === 'unregistered' ? (
276
+ <>
277
+ <RainbowButton
278
+ className="w-full"
279
+ onClick={() => {
280
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
281
+ navigate({ to: '/register', search: { invite: token, email } })
282
+ }}
283
+ >
284
+ {t('registerNow')}
285
+ <ArrowRight className="ml-2 size-4" />
286
+ </RainbowButton>
287
+ <p className="text-center text-xs text-muted-foreground">{t('emailUnregisteredHint')}</p>
288
+ </>
289
+ ) : (
290
+ <>
291
+ <RainbowButton
292
+ className="w-full"
293
+ disabled={emailCheck !== 'registered' || applyMutation.isPending}
294
+ onClick={() => {
295
+ const email = (emailPrefilled ? inv!.email ?? '' : emailInput).trim()
296
+ if (!EMAIL_RE.test(email)) {
297
+ toast.error(t('invalidEmail'))
298
+ return
299
+ }
300
+ applyMutation.mutate(email.toLowerCase())
301
+ }}
302
+ >
303
+ {applyMutation.isPending || emailCheck === 'checking' ? (
304
+ <>
305
+ <Loader2 className="mr-2 size-4 animate-spin" />
306
+ {t('checking')}
307
+ </>
308
+ ) : (
309
+ <>
310
+ {t('applyToJoin')}
311
+ <ArrowRight className="ml-2 size-4" />
312
+ </>
313
+ )}
314
+ </RainbowButton>
315
+ <p className="text-center text-xs text-muted-foreground">
316
+ {emailCheck === 'registered' ? t('emailRegisteredHint') : t('notRegisteredNote')}
317
+ </p>
318
+ </>
319
+ )}
320
+
321
+ <div className="text-center text-sm">
322
+ {t('alreadyHaveAccount')}{' '}
323
+ <Link to="/login" className="font-medium text-primary hover:underline">
324
+ {t('login')}
325
+ </Link>
326
+ </div>
327
+ </div>
328
+ )}
329
+ </AuthLayoutLeft>
330
+ <AuthLayoutRight>
331
+ <AuthBrandPanel
332
+ features={features}
333
+ footer={<p className="text-sm text-muted-foreground">{t('brandFooter')}</p>}
334
+ title={t('brandTitle')}
335
+ />
336
+ </AuthLayoutRight>
337
+ </AuthLayout>
338
+ )
75
339
  }
@@ -14,16 +14,24 @@
14
14
  //
15
15
  // You should have received a copy of this license.
16
16
 
17
- import { createFileRoute, redirect } from '@tanstack/react-router'
17
+ import { createFileRoute } from '@tanstack/react-router'
18
+ import { getTeamInvitationByTokenFn } from '@/server/serverFns/team'
18
19
 
19
20
  export const Route = createFileRoute('/invite/$token')({
20
- beforeLoad: async ({ context, params, location }) => {
21
- if (!context.session?.user?.id) {
22
- throw redirect({
23
- to: '/login',
24
- search: { redirect: location.pathname + (location.searchStr ?? '') },
25
- })
21
+ beforeLoad: async ({ context, params }) => {
22
+ const sessionUser = context.session?.user
23
+ const userId = sessionUser?.id ?? null
24
+ let inv = null
25
+ try {
26
+ inv = await getTeamInvitationByTokenFn({ data: { token: params.token, userId } })
27
+ } catch {
28
+ // invitation lookup failure, return null to let component handle
29
+ }
30
+ return {
31
+ token: params.token,
32
+ isLoggedIn: !!sessionUser?.id,
33
+ userId,
34
+ inv,
26
35
  }
27
- return { token: params.token }
28
36
  },
29
37
  })
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_3 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/share/prd/$prdId')({
7
+ beforeLoad: ((ctx: PluginRouteContext) => prd_route_3.beforeLoad(ctx)) as any,
8
+ validateSearch: prd_route_3.validateSearch as any,
9
+ component: prd_route_3.Component as any,
10
+ })
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ vi.mock('@allbluecn/database', () =>
5
+ ({
6
+ prisma: {
7
+ notificationPreference: {
8
+ findUnique: vi.fn(),
9
+ create: vi.fn(),
10
+ update: vi.fn(),
11
+ },
12
+ notification: {
13
+ count: vi.fn(),
14
+ create: vi.fn(),
15
+ },
16
+ },
17
+ }) as any,
18
+ )
19
+
20
+ import { prisma } from '@allbluecn/database'
21
+ import { createNotification, isInDndWindow, DEFAULT_CATEGORIES } from '../notification-service'
22
+ import { notificationBus } from '../event-bus'
23
+
24
+ const mockPreferenceFindUnique = prisma.notificationPreference.findUnique as ReturnType<typeof vi.fn>
25
+ const mockPreferenceCreate = prisma.notificationPreference.create as ReturnType<typeof vi.fn>
26
+ const mockNotificationCreate = prisma.notification.create as ReturnType<typeof vi.fn>
27
+
28
+ describe('isInDndWindow', () => {
29
+ it('跨午夜区间:23:00 在 22:00-08:00 内', () => {
30
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '23:00')).toBe(true)
31
+ })
32
+ it('跨午夜区间:06:30 在 22:00-08:00 内', () => {
33
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '06:30')).toBe(true)
34
+ })
35
+ it('跨午夜区间:12:00 不在 22:00-08:00 内', () => {
36
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '12:00')).toBe(false)
37
+ })
38
+ it('非跨午夜区间:13:00 在 09:00-18:00 内', () => {
39
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '09:00', dndEnd: '18:00' }, '13:00')).toBe(true)
40
+ })
41
+ it('DND 关闭时恒为 false', () => {
42
+ expect(isInDndWindow({ dndEnabled: false, dndStart: '22:00', dndEnd: '08:00' }, '23:00')).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('createNotification', () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks()
49
+ })
50
+
51
+ it('分类关闭时丢弃:不写库不 emit', async () => {
52
+ mockPreferenceFindUnique.mockResolvedValue({
53
+ userId: 'u1',
54
+ categories: { team: false, collaboration: true, system: true, billing: true, security: true },
55
+ dndEnabled: false,
56
+ dndStart: '22:00',
57
+ dndEnd: '08:00',
58
+ } as never)
59
+ const emitSpy = vi.spyOn(notificationBus, 'emit')
60
+
61
+ await createNotification({ userId: 'u1', category: 'team', type: 'team.invite_request', title: 't' })
62
+
63
+ expect(mockNotificationCreate).not.toHaveBeenCalled()
64
+ expect(emitSpy).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('分类开启时写库并 emit', async () => {
68
+ mockPreferenceFindUnique.mockResolvedValue(null)
69
+ mockPreferenceCreate.mockResolvedValue({} as never)
70
+ mockNotificationCreate.mockImplementation(async () =>
71
+ ({
72
+ userId: 'u1',
73
+ category: 'team',
74
+ type: 'team.invite_request',
75
+ title: 't',
76
+ body: 'b',
77
+ link: '/settings/team',
78
+ id: 'n1',
79
+ createdAt: new Date(),
80
+ readAt: null,
81
+ } as never),
82
+ )
83
+ const emitSpy = vi.spyOn(notificationBus, 'emit')
84
+
85
+ await createNotification({
86
+ userId: 'u1',
87
+ category: 'team',
88
+ type: 'team.invite_request',
89
+ title: 't',
90
+ body: 'b',
91
+ link: '/settings/team',
92
+ })
93
+
94
+ expect(mockNotificationCreate).toHaveBeenCalled()
95
+ expect(emitSpy).toHaveBeenCalledWith('u1', expect.objectContaining({ kind: 'notification' }))
96
+ })
97
+
98
+ it('通知失败不抛出(fire-and-forget 安全)', async () => {
99
+ mockPreferenceFindUnique.mockRejectedValue(new Error('db down'))
100
+ await expect(
101
+ createNotification({ userId: 'u1', category: 'team', type: 'team.invite_request', title: 't' }),
102
+ ).resolves.toBeUndefined()
103
+ })
104
+
105
+ it('DEFAULT_CATEGORIES 全开', () => {
106
+ expect(DEFAULT_CATEGORIES).toEqual({
107
+ team: true,
108
+ collaboration: true,
109
+ system: true,
110
+ billing: true,
111
+ security: true,
112
+ })
113
+ })
114
+ })
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect } from 'vitest'
3
+ import { notificationBus } from '../event-bus'
4
+
5
+ describe('notificationBus', () => {
6
+ it('subscribe 收到 emit 的值;unsubscribe 后不再收到', () => {
7
+ const received: unknown[] = []
8
+ const unsubscribe = notificationBus.subscribe('test-user', (v) =>
9
+ received.push(v),
10
+ )
11
+
12
+ notificationBus.emit('test-user', { kind: 'unread_snapshot', count: 3 })
13
+ expect(received).toEqual([{ kind: 'unread_snapshot', count: 3 }])
14
+
15
+ unsubscribe()
16
+ notificationBus.emit('test-user', { kind: 'unread_snapshot', count: 4 })
17
+ expect(received).toHaveLength(1)
18
+ })
19
+
20
+ it('不同 userId 互不干扰', () => {
21
+ const a: unknown[] = []
22
+ const b: unknown[] = []
23
+ notificationBus.subscribe('user-a', (v) => a.push(v))
24
+ notificationBus.subscribe('user-b', (v) => b.push(v))
25
+
26
+ notificationBus.emit('user-a', { kind: 'unread_snapshot', count: 1 })
27
+ expect(a).toHaveLength(1)
28
+ expect(b).toHaveLength(0)
29
+ })
30
+ })
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { EventEmitter } from 'node:events'
3
+ import type { NotificationSseValue } from '@allbluecn/shared'
4
+
5
+ const emitter = new EventEmitter()
6
+ emitter.setMaxListeners(0)
7
+
8
+ export const notificationBus = {
9
+ emit(userId: string, value: NotificationSseValue) {
10
+ emitter.emit(`user:${userId}`, value)
11
+ },
12
+ subscribe(userId: string, listener: (value: NotificationSseValue) => void) {
13
+ emitter.on(`user:${userId}`, listener)
14
+ return () => emitter.off(`user:${userId}`, listener)
15
+ },
16
+ }