@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,145 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
3
|
+
import { useTranslation } from "react-i18next"
|
|
4
|
+
import { toast } from '@/components/ui/sonner'
|
|
5
|
+
import { Input } from "@/components/ui/input"
|
|
6
|
+
import { Label } from "@/components/ui/label"
|
|
7
|
+
import { Button } from "@/components/ui/button"
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from "@/components/ui/dialog"
|
|
16
|
+
import { createCustomProviderFn } from "@/server/serverFns/ai/providers"
|
|
17
|
+
|
|
18
|
+
interface FormState {
|
|
19
|
+
displayName: string
|
|
20
|
+
baseUrl: string
|
|
21
|
+
apiKey: string
|
|
22
|
+
defaultModel: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY_FORM: FormState = {
|
|
26
|
+
displayName: "",
|
|
27
|
+
baseUrl: "https://api.openai.com/v1",
|
|
28
|
+
apiKey: "",
|
|
29
|
+
defaultModel: "",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CreateCustomProviderDialog({
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
}: {
|
|
36
|
+
open: boolean
|
|
37
|
+
onOpenChange: (v: boolean) => void
|
|
38
|
+
}) {
|
|
39
|
+
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
|
40
|
+
const { t } = useTranslation("settings")
|
|
41
|
+
const queryClient = useQueryClient()
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setForm(EMPTY_FORM)
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
const mutation = useMutation({
|
|
48
|
+
mutationFn: () =>
|
|
49
|
+
createCustomProviderFn({
|
|
50
|
+
data: {
|
|
51
|
+
displayName: form.displayName,
|
|
52
|
+
baseUrl: form.baseUrl,
|
|
53
|
+
apiKey: form.apiKey,
|
|
54
|
+
defaultModel: form.defaultModel,
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
onSuccess: async () => {
|
|
58
|
+
onOpenChange(false)
|
|
59
|
+
await queryClient.invalidateQueries({ queryKey: ["ai-providers"] })
|
|
60
|
+
await queryClient.invalidateQueries({ queryKey: ["ai-enabled-models"] })
|
|
61
|
+
toast.success(t("ai.providerCreated"))
|
|
62
|
+
},
|
|
63
|
+
onError: (err) => {
|
|
64
|
+
toast.error(err instanceof Error ? err.message : t("ai.operationFailed"))
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const handleSubmit = () => {
|
|
69
|
+
if (!form.displayName.trim()) {
|
|
70
|
+
toast.error(`${t("ai.customProviderName")} 不能为空`)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (!form.defaultModel.trim()) {
|
|
74
|
+
toast.error(`${t("ai.customDefaultModel")} 不能为空`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
mutation.mutate()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const update = (field: keyof FormState, value: string) =>
|
|
81
|
+
setForm((prev) => ({ ...prev, [field]: value }))
|
|
82
|
+
|
|
83
|
+
const isValid = form.displayName.trim() && form.defaultModel.trim()
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
87
|
+
<DialogContent className="sm:max-w-md">
|
|
88
|
+
<DialogHeader>
|
|
89
|
+
<DialogTitle>{t("ai.createCustomProvider")}</DialogTitle>
|
|
90
|
+
<DialogDescription>
|
|
91
|
+
{t("ai.createCustomProviderDescription")}
|
|
92
|
+
</DialogDescription>
|
|
93
|
+
</DialogHeader>
|
|
94
|
+
|
|
95
|
+
<div className="grid gap-4">
|
|
96
|
+
<div className="grid gap-1.5">
|
|
97
|
+
<Label>{t("ai.customProviderName")} <span className="text-red-500">*</span></Label>
|
|
98
|
+
<Input
|
|
99
|
+
value={form.displayName}
|
|
100
|
+
onChange={(e) => update("displayName", e.target.value)}
|
|
101
|
+
placeholder={t("ai.customProviderNamePlaceholder")}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="grid gap-1.5">
|
|
105
|
+
<Label>{t("ai.customBaseUrl")}</Label>
|
|
106
|
+
<Input
|
|
107
|
+
value={form.baseUrl}
|
|
108
|
+
onChange={(e) => update("baseUrl", e.target.value)}
|
|
109
|
+
placeholder={t("ai.customBaseUrlPlaceholder")}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="grid gap-1.5">
|
|
113
|
+
<Label>{t("ai.customApiKey")}</Label>
|
|
114
|
+
<Input
|
|
115
|
+
type="password"
|
|
116
|
+
value={form.apiKey}
|
|
117
|
+
onChange={(e) => update("apiKey", e.target.value)}
|
|
118
|
+
placeholder="sk-..."
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="grid gap-1.5">
|
|
122
|
+
<Label>{t("ai.customDefaultModel")} <span className="text-red-500">*</span></Label>
|
|
123
|
+
<Input
|
|
124
|
+
value={form.defaultModel}
|
|
125
|
+
onChange={(e) => update("defaultModel", e.target.value)}
|
|
126
|
+
placeholder={t("ai.customDefaultModelPlaceholder")}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<DialogFooter>
|
|
132
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
133
|
+
取消
|
|
134
|
+
</Button>
|
|
135
|
+
<Button
|
|
136
|
+
onClick={handleSubmit}
|
|
137
|
+
disabled={mutation.isPending || !isValid}
|
|
138
|
+
>
|
|
139
|
+
{mutation.isPending ? t("ai.creatingProvider") : t("ai.createProvider")}
|
|
140
|
+
</Button>
|
|
141
|
+
</DialogFooter>
|
|
142
|
+
</DialogContent>
|
|
143
|
+
</Dialog>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -1,23 +1,6 @@
|
|
|
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
|
-
// apps/web/src/components/settings/ai/provider-grid.tsx
|
|
19
1
|
import { useEffect, useMemo, useState } from "react"
|
|
20
|
-
import {
|
|
2
|
+
import { useQuery } from "@tanstack/react-query"
|
|
3
|
+
import { Plus, Search } from "lucide-react"
|
|
21
4
|
import { useTranslation } from "react-i18next"
|
|
22
5
|
import { Input } from "@/components/ui/input"
|
|
23
6
|
import {
|
|
@@ -27,10 +10,11 @@ import {
|
|
|
27
10
|
SelectTrigger,
|
|
28
11
|
SelectValue,
|
|
29
12
|
} from "@/components/ui/select"
|
|
30
|
-
import {
|
|
31
|
-
import { aiProvidersCollection } from "@/lib/collections/ai/ai-providers-collection"
|
|
13
|
+
import { Button } from "@/components/ui/button"
|
|
32
14
|
import { cn } from "@/lib/utils"
|
|
15
|
+
import { listAiProvidersFn } from "@/server/serverFns/ai/providers"
|
|
33
16
|
import { ProviderCard } from "./provider-card"
|
|
17
|
+
import { CreateCustomProviderDialog } from "./create-custom-provider-dialog"
|
|
34
18
|
|
|
35
19
|
type SortMode = "default" | "name" | "configured-first"
|
|
36
20
|
|
|
@@ -42,9 +26,10 @@ function ProviderResults({
|
|
|
42
26
|
sort: SortMode
|
|
43
27
|
}) {
|
|
44
28
|
const { t } = useTranslation("settings")
|
|
45
|
-
const { data: providers } =
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
const { data: providers } = useQuery({
|
|
30
|
+
queryKey: ["ai-providers"],
|
|
31
|
+
queryFn: listAiProvidersFn,
|
|
32
|
+
})
|
|
48
33
|
|
|
49
34
|
const filtered = useMemo(() => {
|
|
50
35
|
const q = query.trim().toLowerCase()
|
|
@@ -73,9 +58,30 @@ function ProviderResults({
|
|
|
73
58
|
|
|
74
59
|
const enabledProviders = filtered.filter((p) => p.config.enabled)
|
|
75
60
|
const disabledProviders = filtered.filter((p) => !p.config.enabled)
|
|
61
|
+
const customCount = filtered.filter(
|
|
62
|
+
(p) => p.meta.providerId.startsWith("custom-"),
|
|
63
|
+
).length
|
|
76
64
|
|
|
77
65
|
return (
|
|
78
66
|
<>
|
|
67
|
+
{customCount > 0 && (
|
|
68
|
+
<div className="space-y-3">
|
|
69
|
+
<h3 className="text-xs font-medium text-muted-foreground">
|
|
70
|
+
{t("ai.customProviders", { count: customCount })}
|
|
71
|
+
</h3>
|
|
72
|
+
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4")}>
|
|
73
|
+
{filtered
|
|
74
|
+
.filter((p) => p.meta.providerId.startsWith("custom-"))
|
|
75
|
+
.map(({ meta, config, platformEnabled }) => (
|
|
76
|
+
<ProviderCard
|
|
77
|
+
key={meta.providerId}
|
|
78
|
+
data={{ meta, config, platformEnabled }}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
79
85
|
{enabledProviders.length > 0 && (
|
|
80
86
|
<div className="space-y-3">
|
|
81
87
|
<h3 className="text-xs font-medium text-muted-foreground">
|
|
@@ -120,6 +126,7 @@ function ProviderResults({
|
|
|
120
126
|
export function ProviderGrid() {
|
|
121
127
|
const [query, setQuery] = useState("")
|
|
122
128
|
const [sort, setSort] = useState<SortMode>("default")
|
|
129
|
+
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
|
123
130
|
const [mounted, setMounted] = useState(false)
|
|
124
131
|
const { t } = useTranslation("settings")
|
|
125
132
|
|
|
@@ -149,9 +156,23 @@ export function ProviderGrid() {
|
|
|
149
156
|
<SelectItem value="configured-first">{t("ai.sortConfiguredFirst")}</SelectItem>
|
|
150
157
|
</SelectContent>
|
|
151
158
|
</Select>
|
|
159
|
+
<Button
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="sm"
|
|
162
|
+
className="gap-1.5"
|
|
163
|
+
onClick={() => setCreateDialogOpen(true)}
|
|
164
|
+
>
|
|
165
|
+
<Plus className="size-3.5" />
|
|
166
|
+
{t("ai.createCustomProvider")}
|
|
167
|
+
</Button>
|
|
152
168
|
</div>
|
|
153
169
|
|
|
154
170
|
{mounted && <ProviderResults query={query} sort={sort} />}
|
|
171
|
+
|
|
172
|
+
<CreateCustomProviderDialog
|
|
173
|
+
open={createDialogOpen}
|
|
174
|
+
onOpenChange={setCreateDialogOpen}
|
|
175
|
+
/>
|
|
155
176
|
</div>
|
|
156
177
|
)
|
|
157
|
-
}
|
|
178
|
+
}
|
|
@@ -25,10 +25,15 @@ import {
|
|
|
25
25
|
listTeamInvitationsFn,
|
|
26
26
|
listTeamUsersFn,
|
|
27
27
|
createTeamInvitationFn,
|
|
28
|
+
refreshTeamInvitationLinkFn,
|
|
28
29
|
revokeTeamInvitationFn,
|
|
29
30
|
resendTeamInvitationFn,
|
|
31
|
+
approveTeamInvitationFn,
|
|
32
|
+
approveTeamJoinRequestFn,
|
|
33
|
+
rejectTeamJoinRequestFn,
|
|
30
34
|
removeTeamMemberFn,
|
|
31
35
|
leaveTeamFn,
|
|
36
|
+
clearExpiredInvitationsFn,
|
|
32
37
|
} from "@/server/serverFns/team"
|
|
33
38
|
import { Button } from "@/components/ui/button"
|
|
34
39
|
import {
|
|
@@ -59,7 +64,7 @@ import {
|
|
|
59
64
|
FieldTitle,
|
|
60
65
|
} from "@/components/ui/field"
|
|
61
66
|
import { Trash2, Infinity as InfinityIcon } from "lucide-react"
|
|
62
|
-
import TeamInvitations, { type TeamInvitation } from "@/components/settings/personal-team/team/team-invitations"
|
|
67
|
+
import TeamInvitations, { type TeamInvitation, type TeamJoinRequestItem } from "@/components/settings/personal-team/team/team-invitations"
|
|
63
68
|
import TeamMemberList, { type TeamMember } from "@/components/settings/personal-team/team/team-member-list"
|
|
64
69
|
|
|
65
70
|
type TeamClientProps = {
|
|
@@ -85,16 +90,17 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
85
90
|
const seats = useResource("seats")
|
|
86
91
|
const guestSeats = useResource("guestSeats")
|
|
87
92
|
|
|
88
|
-
const { data: members = [] } = useQuery({
|
|
93
|
+
const { data: members = [], isLoading: membersLoading } = useQuery({
|
|
89
94
|
queryKey: ["teamMembers"],
|
|
90
95
|
queryFn: () => listTeamMembersFn(),
|
|
91
96
|
})
|
|
92
97
|
|
|
93
|
-
const { data:
|
|
98
|
+
const { data: inviteData } = useQuery({
|
|
94
99
|
queryKey: ["teamInvitations"],
|
|
95
100
|
queryFn: () => listTeamInvitationsFn(),
|
|
96
101
|
enabled: members.length > 0,
|
|
97
102
|
})
|
|
103
|
+
const invitations = inviteData?.invitations ?? []
|
|
98
104
|
|
|
99
105
|
const { data: teamUsers = [] } = useQuery({
|
|
100
106
|
queryKey: ["teamUsers"],
|
|
@@ -114,8 +120,13 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
const createInviteMutation = useMutation({
|
|
117
|
-
mutationFn: (data: {
|
|
118
|
-
|
|
123
|
+
mutationFn: (data: {
|
|
124
|
+
seatType: "member" | "guest"
|
|
125
|
+
channel: "email" | "link"
|
|
126
|
+
email?: string
|
|
127
|
+
expiresInDays?: number
|
|
128
|
+
message?: string
|
|
129
|
+
}) => createTeamInvitationFn({ data }),
|
|
119
130
|
onSuccess: () => {
|
|
120
131
|
toast.success(t("team.inviteSent"))
|
|
121
132
|
invalidate()
|
|
@@ -159,7 +170,7 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
159
170
|
})
|
|
160
171
|
|
|
161
172
|
const leaveTeamMutation = useMutation({
|
|
162
|
-
mutationFn: () => leaveTeamFn(),
|
|
173
|
+
mutationFn: () => leaveTeamFn({ data: {} }),
|
|
163
174
|
onSuccess: () => {
|
|
164
175
|
toast.success(t("team.leaveTeam"))
|
|
165
176
|
window.location.reload()
|
|
@@ -169,6 +180,23 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
169
180
|
},
|
|
170
181
|
})
|
|
171
182
|
|
|
183
|
+
const approveLinkMutation = useMutation({
|
|
184
|
+
mutationFn: ({ invitationId, seatType }: { invitationId: string; seatType: 'member' | 'guest' }) =>
|
|
185
|
+
approveTeamInvitationFn({ data: { invitationId, seatType } }),
|
|
186
|
+
onSuccess: () => {
|
|
187
|
+
toast.success(t("team.inviteApproved"))
|
|
188
|
+
invalidate()
|
|
189
|
+
},
|
|
190
|
+
onError: (err) => {
|
|
191
|
+
toast.error(err instanceof Error ? err.message : t("team.inviteCreateFailed"))
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const handleApproveLink = async (invitationId: string, role: "admin" | "member" | "viewer") => {
|
|
196
|
+
const seatType = role === "viewer" ? "guest" : "member"
|
|
197
|
+
await approveLinkMutation.mutateAsync({ invitationId, seatType })
|
|
198
|
+
}
|
|
199
|
+
|
|
172
200
|
const membershipIdByUserId = members.reduce<Record<string, string>>((acc, m) => {
|
|
173
201
|
acc[m.member.id] = m.id
|
|
174
202
|
return acc
|
|
@@ -176,10 +204,12 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
176
204
|
|
|
177
205
|
const mappedInvitations: TeamInvitation[] = invitations.map((inv) => ({
|
|
178
206
|
id: inv.id,
|
|
179
|
-
email: inv.channel === "email" ? inv.email : undefined,
|
|
207
|
+
email: inv.channel === "email" && inv.email ? inv.email : undefined,
|
|
180
208
|
link: inv.channel === "link" ? `${APP_URL}/invite/${inv.token}` : undefined,
|
|
181
|
-
role: inv.seatType === "member" ? "member" : "viewer",
|
|
182
|
-
|
|
209
|
+
role: inv.channel === "email" ? (inv.seatType === "member" ? "member" : "viewer") : undefined,
|
|
210
|
+
channel: inv.channel as "email" | "link",
|
|
211
|
+
status: inv.status as "pending" | "pending_approval" | "accepted" | "expired" | "revoked",
|
|
212
|
+
applicantEmail: inv.applicantEmail ?? undefined,
|
|
183
213
|
invitedBy: {
|
|
184
214
|
name: inv.owner?.username ?? user.name ?? t("team.you"),
|
|
185
215
|
email: inv.owner?.email ?? user.email ?? "",
|
|
@@ -188,6 +218,16 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
188
218
|
createdAt: new Date(inv.createdAt),
|
|
189
219
|
}))
|
|
190
220
|
|
|
221
|
+
const mappedJoinRequests: TeamJoinRequestItem[] = (inviteData?.joinRequests ?? []).map((r) => ({
|
|
222
|
+
id: r.id,
|
|
223
|
+
email: r.email,
|
|
224
|
+
status: r.status as TeamJoinRequestItem["status"],
|
|
225
|
+
seatType: r.seatType as "member" | "guest",
|
|
226
|
+
createdAt: new Date(r.createdAt),
|
|
227
|
+
processedAt: r.processedAt ? new Date(r.processedAt) : undefined,
|
|
228
|
+
applicantName: r.applicant?.username ?? undefined,
|
|
229
|
+
}))
|
|
230
|
+
|
|
191
231
|
const mappedMembers: TeamMember[] = members.map((m) => ({
|
|
192
232
|
id: m.member.id,
|
|
193
233
|
name: m.member.username ?? "",
|
|
@@ -195,6 +235,9 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
195
235
|
role: seatTypeToRole(m.seatType as "member" | "guest" | "owner"),
|
|
196
236
|
status: "active",
|
|
197
237
|
joinedAt: new Date(m.joinedAt),
|
|
238
|
+
avatarConfig: m.member.avatarConfig as TeamMember["avatarConfig"],
|
|
239
|
+
bgShape: m.member.bgShape as TeamMember["bgShape"],
|
|
240
|
+
bgColor: m.member.bgColor as TeamMember["bgColor"],
|
|
198
241
|
}))
|
|
199
242
|
|
|
200
243
|
const handleCreate = async (data: {
|
|
@@ -209,7 +252,10 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
209
252
|
seatType,
|
|
210
253
|
channel,
|
|
211
254
|
email: data.email,
|
|
255
|
+
expiresInDays: data.expiresInDays,
|
|
256
|
+
message: data.message,
|
|
212
257
|
})
|
|
258
|
+
const expiresDays = data.expiresInDays ?? 7
|
|
213
259
|
return {
|
|
214
260
|
id: result.invitationId,
|
|
215
261
|
email: data.email,
|
|
@@ -217,7 +263,7 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
217
263
|
status: "pending",
|
|
218
264
|
invitedBy: { name: user.name ?? t("team.you"), email: user.email ?? "" },
|
|
219
265
|
createdAt: new Date(),
|
|
220
|
-
expiresAt: new Date(Date.now() +
|
|
266
|
+
expiresAt: new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000),
|
|
221
267
|
} as TeamInvitation
|
|
222
268
|
}
|
|
223
269
|
|
|
@@ -229,9 +275,60 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
229
275
|
await resendMutation.mutateAsync(invitationId)
|
|
230
276
|
}
|
|
231
277
|
|
|
232
|
-
const
|
|
233
|
-
await
|
|
234
|
-
|
|
278
|
+
const handleClearExpired = async () => {
|
|
279
|
+
await clearExpiredInvitationsFn({ data: {} })
|
|
280
|
+
invalidate()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const handleRefreshLink = async (expiresInDays?: number) => {
|
|
284
|
+
const days = expiresInDays ?? 7
|
|
285
|
+
const result = await refreshTeamInvitationLinkFn({ data: { expiresInDays: days } })
|
|
286
|
+
void qc.invalidateQueries({ queryKey: ["teamInvitations"] })
|
|
287
|
+
void qc.invalidateQueries({ queryKey: ["teamMembers"] })
|
|
288
|
+
return {
|
|
289
|
+
id: result.invitationId,
|
|
290
|
+
email: undefined,
|
|
291
|
+
link: `${APP_URL}/invite/${result.token}`,
|
|
292
|
+
role: "member",
|
|
293
|
+
status: "pending",
|
|
294
|
+
invitedBy: { name: user.name ?? t("team.you"), email: user.email ?? "" },
|
|
295
|
+
createdAt: new Date(),
|
|
296
|
+
expiresAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
|
297
|
+
} as TeamInvitation
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const approveRequestMutation = useMutation({
|
|
301
|
+
mutationFn: ({ requestId, seatType }: { requestId: string; seatType: "member" | "guest" }) =>
|
|
302
|
+
approveTeamJoinRequestFn({ data: { requestId, seatType } }),
|
|
303
|
+
onSuccess: () => {
|
|
304
|
+
toast.success(t("team.inviteApproved"))
|
|
305
|
+
invalidate()
|
|
306
|
+
},
|
|
307
|
+
onError: (err) => {
|
|
308
|
+
toast.error(err instanceof Error ? err.message : t("team.inviteCreateFailed"))
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const rejectRequestMutation = useMutation({
|
|
313
|
+
mutationFn: (requestId: string) => rejectTeamJoinRequestFn({ data: { requestId } }),
|
|
314
|
+
onSuccess: () => {
|
|
315
|
+
toast.success(t("team.joinRequests.requestRejected"))
|
|
316
|
+
invalidate()
|
|
317
|
+
},
|
|
318
|
+
onError: (err) => {
|
|
319
|
+
toast.error(err instanceof Error ? err.message : t("team.inviteCreateFailed"))
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const handleApproveRequest = async (requestId: string, role: "admin" | "member" | "viewer") => {
|
|
324
|
+
await approveRequestMutation.mutateAsync({
|
|
325
|
+
requestId,
|
|
326
|
+
seatType: role === "viewer" ? "guest" : "member",
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const handleRejectRequest = async (requestId: string) => {
|
|
331
|
+
await rejectRequestMutation.mutateAsync(requestId)
|
|
235
332
|
}
|
|
236
333
|
|
|
237
334
|
const handleRemove = async (memberId: string) => {
|
|
@@ -313,9 +410,10 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
313
410
|
<p className="text-sm text-muted-foreground">{t("team.subtitle")}</p>
|
|
314
411
|
</div>
|
|
315
412
|
|
|
316
|
-
<div className="grid gap-4
|
|
413
|
+
<div className="grid gap-4 xl:grid-cols-2 border rounded-lg p-4">
|
|
317
414
|
<SeatCard
|
|
318
415
|
title={t("team.seatMembers")}
|
|
416
|
+
description={t("team.seatMembersHint")}
|
|
319
417
|
usage={seatUsage}
|
|
320
418
|
limit={seatLimit}
|
|
321
419
|
/>
|
|
@@ -329,10 +427,15 @@ export default function TeamClient({ user }: TeamClientProps) {
|
|
|
329
427
|
|
|
330
428
|
<TeamInvitations
|
|
331
429
|
invitations={mappedInvitations}
|
|
430
|
+
joinRequests={mappedJoinRequests}
|
|
332
431
|
onCreate={handleCreate}
|
|
432
|
+
onRefreshLink={handleRefreshLink}
|
|
333
433
|
onRevoke={handleRevoke}
|
|
334
434
|
onResend={handleResend}
|
|
335
|
-
|
|
435
|
+
onApproveLink={handleApproveLink}
|
|
436
|
+
onApproveRequest={handleApproveRequest}
|
|
437
|
+
onRejectRequest={handleRejectRequest}
|
|
438
|
+
onClearExpired={handleClearExpired}
|
|
336
439
|
/>
|
|
337
440
|
|
|
338
441
|
<TeamMemberList
|