@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.
- package/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
- package/src/components/story/display/display-dropdown.tsx +6 -7
- package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
- package/src/components/story/story-list-view.tsx +13 -2
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/story/story-crud/labels.ts +45 -6
- package/src/server/serverFns/story/story-crud.ts +8 -2
- package/src/server/serverFns/story/story-groups.ts +26 -7
- package/src/server/serverFns/story/story-search.ts +4 -0
- package/src/server/serverFns/story-display-options.ts +15 -4
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- 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
|
|
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 {
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|