@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.
Files changed (67) 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 +1 -1
  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/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. 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 { Search } from "lucide-react"
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 { useLiveQuery } from "@tanstack/react-db"
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 } = useLiveQuery((q) =>
46
- q.from({ p: aiProvidersCollection })
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: invitations = [] } = useQuery({
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: { seatType: "member" | "guest"; channel: "email" | "link"; email?: string }) =>
118
- createTeamInvitationFn({ data }),
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
- status: inv.status as "pending" | "accepted" | "expired" | "revoked",
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() + 7 * 24 * 60 * 60 * 1000),
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 handleCopyLink = async (link: string) => {
233
- await navigator.clipboard.writeText(link)
234
- toast.success(t("team.linkCopied", { link: link.slice(0, 30) }))
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 md:grid-cols-2">
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
- onCopyLink={handleCopyLink}
435
+ onApproveLink={handleApproveLink}
436
+ onApproveRequest={handleApproveRequest}
437
+ onRejectRequest={handleRejectRequest}
438
+ onClearExpired={handleClearExpired}
336
439
  />
337
440
 
338
441
  <TeamMemberList