@allbluecn/web-app 0.4.17 → 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 +1 -1
- 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/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/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/story-comments.ts +42 -0
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- package/vite.shared.ts +13 -0
|
@@ -0,0 +1,9 @@
|
|
|
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_1 } from '@allbluecn/plugins-core/prd/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/prd/')({
|
|
7
|
+
loader: ((ctx: PluginRouteContext) => prd_route_1.loader(ctx)) as any,
|
|
8
|
+
component: prd_route_1.Component as any,
|
|
9
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
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_0 } from '@allbluecn/plugins-core/prd/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/prd')({
|
|
7
|
+
component: prd_route_0.Component as any,
|
|
8
|
+
})
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { Link, useLocation } from '@tanstack/react-router'
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
|
-
import { User, Users, Bot, Palette, Settings, CreditCard, Plug, ShieldCheck, type LucideIcon } from 'lucide-react'
|
|
20
|
+
import { User, Users, Bot, Palette, Settings, CreditCard, Plug, ShieldCheck, Bell, type LucideIcon } from 'lucide-react'
|
|
21
21
|
import { cn } from '@/lib/utils'
|
|
22
22
|
import { mergeSettingsGroups } from '@/components/plugin-slots/plugin-settings-tabs'
|
|
23
23
|
|
|
@@ -45,6 +45,7 @@ const groups: SettingsGroup[] = [
|
|
|
45
45
|
{ labelKey: 'tabs.accountProfile', path: '/settings/account', icon: User },
|
|
46
46
|
{ labelKey: 'tabs.teamMembers', path: '/settings/team', icon: Users },
|
|
47
47
|
{ labelKey: 'tabs.dataPrivacy', path: '/settings/privacy', icon: ShieldCheck },
|
|
48
|
+
{ labelKey: 'tabs.notifications', path: '/settings/notifications', icon: Bell },
|
|
48
49
|
],
|
|
49
50
|
},
|
|
50
51
|
{
|
|
@@ -70,7 +70,7 @@ function DesignSettingsPage() {
|
|
|
70
70
|
}, [])
|
|
71
71
|
|
|
72
72
|
const generateMutation = useMutation({
|
|
73
|
-
mutationFn: () => generateSketchTokenFn(),
|
|
73
|
+
mutationFn: () => generateSketchTokenFn({ data: {} }),
|
|
74
74
|
onSuccess: (data) => {
|
|
75
75
|
toast.success(t('design.tokenGenerated'))
|
|
76
76
|
setToken(data.token)
|
|
@@ -0,0 +1,37 @@
|
|
|
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 } from '@tanstack/react-router'
|
|
19
|
+
import { useTranslation } from 'react-i18next'
|
|
20
|
+
import { NotificationPreferences } from '@/components/notifications/notification-preferences'
|
|
21
|
+
|
|
22
|
+
export const Route = createLazyFileRoute('/_nav/settings/notifications')({
|
|
23
|
+
component: NotificationsSettingsPage,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function NotificationsSettingsPage() {
|
|
27
|
+
const { t } = useTranslation('notifications')
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col gap-6 p-6">
|
|
30
|
+
<div className="flex flex-col gap-1">
|
|
31
|
+
<h1 className="text-xl font-semibold">{t('preferences.title')}</h1>
|
|
32
|
+
<p className="text-sm text-muted-foreground">{t('preferences.description')}</p>
|
|
33
|
+
</div>
|
|
34
|
+
<NotificationPreferences />
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
|
|
20
|
+
export const Route = createFileRoute('/_nav/settings/notifications')({})
|
|
@@ -61,7 +61,7 @@ function PrivacySettingsPage() {
|
|
|
61
61
|
const handleExport = async () => {
|
|
62
62
|
setExporting(true)
|
|
63
63
|
try {
|
|
64
|
-
await requestMyExportFn()
|
|
64
|
+
await requestMyExportFn({ data: {} })
|
|
65
65
|
toast.success(t('privacy.submitted'))
|
|
66
66
|
} catch (err) {
|
|
67
67
|
toast.error(err instanceof Error ? err.message : '操作失败')
|
|
@@ -74,7 +74,7 @@ function PrivacySettingsPage() {
|
|
|
74
74
|
if (!userName || confirmName.trim() !== userName.trim()) return
|
|
75
75
|
setDeleting(true)
|
|
76
76
|
try {
|
|
77
|
-
await requestMyDeletionFn()
|
|
77
|
+
await requestMyDeletionFn({ data: {} })
|
|
78
78
|
toast.success(t('privacy.submitted'))
|
|
79
79
|
setDialogOpen(false)
|
|
80
80
|
setConfirmName('')
|
|
@@ -0,0 +1,8 @@
|
|
|
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 { tags_route_2 } from '@allbluecn/plugins-core/tags/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/tags/$id')({
|
|
7
|
+
component: tags_route_2.Component as any,
|
|
8
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
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 { tags_route_1 } from '@allbluecn/plugins-core/tags/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/tags/')({
|
|
7
|
+
component: tags_route_1.Component as any,
|
|
8
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
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 { tags_route_0 } from '@allbluecn/plugins-core/tags/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/tags')({
|
|
7
|
+
component: tags_route_0.Component as any,
|
|
8
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
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 { tags_route_3 } from '@allbluecn/plugins-core/tags/routes'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/_nav/tags/settings')({
|
|
7
|
+
loader: ((ctx: PluginRouteContext) => tags_route_3.loader(ctx)) as any,
|
|
8
|
+
component: tags_route_3.Component as any,
|
|
9
|
+
})
|
|
@@ -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
|
}
|