@allbluecn/web-app 0.4.17 → 0.4.19

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 (56) hide show
  1. package/package.json +11 -11
  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/user-menu.tsx +1 -1
  5. package/src/components/notifications/notification-list.tsx +232 -0
  6. package/src/components/notifications/notification-preferences.tsx +156 -0
  7. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  8. package/src/components/settings/ai/provider-grid.tsx +46 -25
  9. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  10. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  11. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  12. package/src/config/sidebar-routes.tsx +16 -0
  13. package/src/hooks/use-notifications.ts +119 -0
  14. package/src/lib/app-version.ts +4 -1
  15. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  16. package/src/lib/changelog/sdk-versions.ts +29 -19
  17. package/src/lib/collections/ai/index.ts +0 -1
  18. package/src/lib/collections/index.ts +0 -1
  19. package/src/plugins/.gen-manifest.json +13 -0
  20. package/src/plugins/modules.gen.ts +20 -0
  21. package/src/plugins/plugins.gen.ts +20 -0
  22. package/src/plugins/ui.gen.ts +8 -0
  23. package/src/routeTree.gen.ts +47 -0
  24. package/src/routes/_login/login/route.tsx +10 -1
  25. package/src/routes/_login/register/route.lazy.tsx +3 -2
  26. package/src/routes/_login/register/route.tsx +5 -0
  27. package/src/routes/_nav/inspiration/route.tsx +8 -0
  28. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  29. package/src/routes/_nav/prd/index.tsx +9 -0
  30. package/src/routes/_nav/prd/route.tsx +8 -0
  31. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  32. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  33. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  34. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  35. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  36. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  37. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  38. package/src/routes/_nav/tags/index.tsx +8 -0
  39. package/src/routes/_nav/tags/route.tsx +8 -0
  40. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  41. package/src/routes/api/notifications/stream.ts +88 -0
  42. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  43. package/src/routes/invite/$token/route.tsx +16 -8
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/notifications/event-bus.ts +16 -0
  46. package/src/server/notifications/notification-service.ts +86 -0
  47. package/src/server/resources.ts +10 -2
  48. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  49. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  50. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  51. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  52. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  53. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  54. package/src/server/serverFns/notifications.ts +155 -0
  55. package/src/server/serverFns/team.ts +521 -53
  56. package/vite.shared.ts +13 -0
@@ -1,552 +1,1023 @@
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
- "use client";
1
+ "use client"
19
2
 
20
3
  import {
21
4
  Check,
5
+ Send,
22
6
  Copy,
7
+ Link,
8
+ Link2Off,
9
+ Link2,
23
10
  Loader2,
24
11
  Mail,
25
- Plus,
26
12
  RefreshCw,
27
13
  Trash2,
28
14
  X,
29
- } from "lucide-react";
30
- import { useState } from "react";
31
- import { useTranslation } from "react-i18next";
32
- import { cn } from "@/lib/utils";
33
- import { formatDate } from "@/lib/datetime";
34
- import { useTimezone } from "@/providers/timezone-provider";
35
- import { Badge } from "@/components/ui/badge";
36
- import { Button } from "@/components/ui/button";
15
+ UserPlus,
16
+ } from "lucide-react"
17
+ import { useMemo, useEffect, useState } from "react"
18
+ import { useTranslation } from "react-i18next"
19
+ import { cn } from "@/lib/utils"
20
+ import { formatDate } from "@/lib/datetime"
21
+ import { useTimezone } from "@/providers/timezone-provider"
22
+ import { Badge } from "@/components/ui/badge"
23
+ import { Button } from "@/components/ui/button"
37
24
  import {
38
25
  Card,
39
26
  CardContent,
40
27
  CardDescription,
41
28
  CardHeader,
42
29
  CardTitle,
43
- } from "@/components/ui/card";
44
- import { Checkbox } from "@/components/ui/checkbox";
45
- import {
46
- Dialog,
47
- DialogContent,
48
- DialogDescription,
49
- DialogFooter,
50
- DialogHeader,
51
- DialogTitle,
52
- DialogTrigger,
53
- } from "@/components/ui/dialog";
30
+ } from "@/components/ui/card"
54
31
  import {
55
32
  DropdownMenu,
56
33
  DropdownMenuContent,
57
34
  DropdownMenuItem,
58
35
  DropdownMenuSeparator,
59
36
  DropdownMenuTrigger,
60
- } from "@/components/ui/dropdown-menu";
37
+ } from "@/components/ui/dropdown-menu"
61
38
  import {
62
39
  Empty,
63
40
  EmptyDescription,
64
41
  EmptyHeader,
65
42
  EmptyMedia,
66
43
  EmptyTitle,
67
- } from "@/components/ui/empty";
44
+ } from "@/components/ui/empty"
68
45
  import {
69
46
  Field,
70
47
  FieldContent,
71
- FieldDescription,
72
48
  FieldError,
73
- FieldLabel,
74
- } from "@/components/ui/field";
49
+ } from "@/components/ui/field"
50
+ import { Input } from "@/components/ui/input"
75
51
  import {
76
52
  InputGroup,
77
53
  InputGroupAddon,
78
54
  InputGroupInput,
79
- } from "@/components/ui/input-group";
55
+ } from "@/components/ui/input-group"
80
56
  import {
81
57
  Select,
82
58
  SelectContent,
83
59
  SelectItem,
84
60
  SelectTrigger,
85
61
  SelectValue,
86
- } from "@/components/ui/select";
87
- import { Textarea } from "@/components/ui/textarea";
62
+ } from "@/components/ui/select"
63
+ import { Separator } from "@/components/ui/separator"
88
64
 
89
65
  export interface TeamInvitation {
90
- id: string;
91
- email?: string;
92
- link?: string;
93
- role: "admin" | "member" | "viewer" | "guest";
94
- status: "pending" | "accepted" | "expired" | "revoked";
66
+ id: string
67
+ email?: string
68
+ link?: string
69
+ role?: "admin" | "member" | "viewer" | "guest"
70
+ channel: "email" | "link"
71
+ status:
72
+ | "pending"
73
+ | "pending_approval"
74
+ | "accepted"
75
+ | "expired"
76
+ | "revoked"
77
+ applicantEmail?: string
95
78
  invitedBy: {
96
- name: string;
97
- email: string;
98
- };
99
- expiresAt?: Date;
100
- createdAt: Date;
101
- acceptedAt?: Date;
79
+ name: string
80
+ email: string
81
+ }
82
+ expiresAt?: Date
83
+ createdAt: Date
84
+ acceptedAt?: Date
85
+ }
86
+
87
+ export interface TeamJoinRequestItem {
88
+ id: string
89
+ email: string
90
+ status: 'pending' | 'approved' | 'rejected' | 'invalidated'
91
+ seatType?: 'member' | 'guest'
92
+ createdAt: Date
93
+ processedAt?: Date
94
+ applicantName?: string
102
95
  }
103
96
 
104
97
  export interface TeamInvitationsProps {
105
- invitations?: TeamInvitation[];
98
+ invitations?: TeamInvitation[]
99
+ joinRequests?: TeamJoinRequestItem[]
106
100
  onCreate?: (data: {
107
- email?: string;
108
- role: "admin" | "member" | "viewer";
109
- expiresInDays?: number;
110
- message?: string;
111
- }) => Promise<TeamInvitation>;
112
- onRevoke?: (invitationId: string) => Promise<void>;
113
- onResend?: (invitationId: string) => Promise<void>;
114
- onCopyLink?: (link: string) => Promise<void>;
115
- className?: string;
116
- allowDirectLinks?: boolean;
101
+ email?: string
102
+ role: "admin" | "member" | "viewer"
103
+ expiresInDays?: number
104
+ message?: string
105
+ }) => Promise<TeamInvitation>
106
+ onRefreshLink?: (expiresInDays?: number) => Promise<TeamInvitation>
107
+ onRevoke?: (invitationId: string) => Promise<void>
108
+ onResend?: (invitationId: string) => Promise<void>
109
+ onApproveLink?: (invitationId: string, role: "admin" | "member" | "viewer") => Promise<void>
110
+ onApproveRequest?: (requestId: string, role: "admin" | "member" | "viewer") => Promise<void>
111
+ onRejectRequest?: (requestId: string) => Promise<void>
112
+ onClearExpired?: () => Promise<void>
113
+ className?: string
114
+ allowDirectLinks?: boolean
117
115
  }
118
116
 
119
- function formatTimeUntil(date: Date, t: (key: string, params?: Record<string, unknown>) => string): string {
120
- const now = Date.now();
121
- const diff = date.getTime() - now;
117
+ function formatTimeUntil(
118
+ date: Date,
119
+ t: (key: string, params?: Record<string, unknown>) => string,
120
+ ): string {
121
+ const now = Date.now()
122
+ const diff = date.getTime() - now
122
123
 
123
- if (diff <= 0) return t("team.expired");
124
+ if (diff <= 0) return t("team.expired")
124
125
 
125
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
126
- const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
126
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24))
127
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
127
128
 
128
- if (days > 0) return t("team.days", { count: days });
129
- if (hours > 0) return t("team.hours", { count: hours });
130
- return t("team.lessThanHour");
129
+ if (days > 0) return t("team.days", { count: days })
130
+ if (hours > 0) return t("team.hours", { count: hours })
131
+ return t("team.lessThanHour")
131
132
  }
132
133
 
133
- function getStatusBadge(status: TeamInvitation["status"], t: (key: string) => string) {
134
- switch (status) {
134
+ function getInvitationBadge(
135
+ invitation: TeamInvitation,
136
+ t: (key: string) => string,
137
+ ) {
138
+ switch (invitation.status) {
135
139
  case "pending":
136
- return <Badge variant="secondary">{t("team.status.pending")}</Badge>;
140
+ return <Badge variant="secondary">{t("team.status.pending")}</Badge>
141
+ case "pending_approval":
142
+ return <Badge variant="default">{t("team.status.pendingApproval")}</Badge>
137
143
  case "accepted":
138
- return <Badge variant="default">{t("team.status.accepted")}</Badge>;
144
+ return <Badge variant="default">{t("team.status.accepted")}</Badge>
139
145
  case "expired":
140
- return <Badge variant="outline">{t("team.status.expired")}</Badge>;
146
+ return <Badge variant="outline">{t("team.status.expired")}</Badge>
141
147
  case "revoked":
142
- return <Badge variant="destructive">{t("team.status.revoked")}</Badge>;
148
+ return <Badge variant="destructive">{t("team.status.revoked")}</Badge>
149
+ }
150
+ }
151
+
152
+ function getChannelBadge(
153
+ channel: "email" | "link",
154
+ t: (key: string) => string,
155
+ ) {
156
+ if (channel === "link") {
157
+ return <Badge variant="outline" className="text-xs">{t("team.channel.link")}</Badge>
143
158
  }
159
+ return <Badge variant="outline" className="text-xs">{t("team.channel.email")}</Badge>
144
160
  }
145
161
 
146
162
  export default function TeamInvitations({
147
163
  invitations = [],
164
+ joinRequests = [],
148
165
  onCreate,
166
+ onRefreshLink,
149
167
  onRevoke,
150
168
  onResend,
151
- onCopyLink,
169
+ onApproveLink,
170
+ onApproveRequest,
171
+ onRejectRequest,
172
+ onClearExpired,
152
173
  className,
153
174
  allowDirectLinks = true,
154
175
  }: TeamInvitationsProps) {
155
- const [createDialogOpen, setCreateDialogOpen] = useState(false);
156
- const [isCreating, setIsCreating] = useState(false);
157
- const [actionLoading, setActionLoading] = useState<string | null>(null);
158
- const [copiedLink, setCopiedLink] = useState<string | null>(null);
159
- const [errors, setErrors] = useState<Record<string, string>>({});
160
- const [inviteData, setInviteData] = useState({
161
- email: "",
162
- role: "member" as "admin" | "member" | "viewer",
163
- expiresInDays: 7,
164
- message: "",
165
- useDirectLink: false,
166
- });
167
- const timezone = useTimezone();
168
- const { t } = useTranslation("settings");
169
-
170
- const pendingInvitations = invitations.filter((i) => i.status === "pending");
171
- const acceptedInvitations = invitations.filter(
172
- (i) => i.status === "accepted"
173
- );
176
+ const { t } = useTranslation("settings")
177
+ const timezone = useTimezone()
178
+ const [actionLoading, setActionLoading] = useState<string | null>(null)
179
+ const [clearingExpired, setClearingExpired] = useState(false)
180
+
181
+ const [linkCopied, setLinkCopied] = useState(false)
182
+ const [linkRefreshing, setLinkRefreshing] = useState(false)
183
+
184
+ const [emailAddress, setEmailAddress] = useState("")
185
+ const [emailRole, setEmailRole] = useState<
186
+ "admin" | "member" | "viewer"
187
+ >("member")
188
+ const [emailExpires, setEmailExpires] = useState(7)
189
+ const [emailMessage, setEmailMessage] = useState("")
190
+ const [emailCreating, setEmailCreating] = useState(false)
191
+ const [errors, setErrors] = useState<Record<string, string>>({})
174
192
 
175
- const handleCreate = async () => {
176
- setErrors({});
193
+ const [linkExpires, setLinkExpires] = useState(7)
177
194
 
178
- if (!(inviteData.useDirectLink || inviteData.email.trim())) {
179
- setErrors({ email: t("team.enterEmail") });
180
- return;
195
+ const [approveRoles, setApproveRoles] = useState<
196
+ Record<string, "admin" | "member" | "viewer">
197
+ >({})
198
+
199
+ const [freshLink, setFreshLink] = useState<TeamInvitation | null>(null)
200
+
201
+ const activeLinkInvitation = useMemo(
202
+ () =>
203
+ freshLink ??
204
+ invitations.find(
205
+ (inv) => inv.link && (inv.status === "pending" || inv.status === "pending_approval" || inv.status === "expired"),
206
+ ),
207
+ [freshLink, invitations],
208
+ )
209
+
210
+ const isLinkExpired =
211
+ !!activeLinkInvitation && (
212
+ activeLinkInvitation.status === "expired" ||
213
+ (!!activeLinkInvitation.expiresAt && new Date(activeLinkInvitation.expiresAt) < new Date())
214
+ )
215
+
216
+ const historyInvitations = invitations.filter(
217
+ (inv) =>
218
+ !inv.link ||
219
+ ["accepted", "pending_approval", "revoked", "pending"].includes(inv.status),
220
+ )
221
+
222
+ useEffect(() => {
223
+ if (freshLink && activeLinkInvitation?.id === freshLink.id && activeLinkInvitation !== freshLink) {
224
+ setFreshLink(null)
181
225
  }
226
+ }, [activeLinkInvitation, freshLink])
182
227
 
183
- if (!inviteData.useDirectLink) {
184
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
185
- if (!emailRegex.test(inviteData.email)) {
186
- setErrors({ email: t("team.invalidEmail") });
187
- return;
228
+ const pendingInvitations = invitations.filter(
229
+ (i) => i.status === "pending" || i.status === "pending_approval",
230
+ )
231
+ const approvalPendingInvitations = invitations.filter(
232
+ (i) => i.status === "pending_approval",
233
+ )
234
+ const acceptedInvitations = invitations.filter(
235
+ (i) => i.status === "accepted",
236
+ )
237
+
238
+ const handleRefreshLink = async () => {
239
+ setLinkRefreshing(true)
240
+ try {
241
+ const newInvitation = await onRefreshLink?.(linkExpires)
242
+ if (newInvitation?.link) {
243
+ setFreshLink(newInvitation)
244
+ const expiresDays = newInvitation.expiresAt
245
+ ? Math.max(1, Math.ceil((newInvitation.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
246
+ : linkExpires
247
+ const message = t("team.linkCopiedMessage", {
248
+ inviteLink: newInvitation.link,
249
+ days: expiresDays,
250
+ })
251
+ navigator.clipboard.writeText(message).catch(() => {})
252
+ setLinkCopied(true)
253
+ setTimeout(() => setLinkCopied(false), 3000)
188
254
  }
255
+ } catch (error) {
256
+ setErrors({
257
+ _general:
258
+ error instanceof Error
259
+ ? error.message
260
+ : t("team.inviteCreateFailed"),
261
+ })
262
+ } finally {
263
+ setLinkRefreshing(false)
264
+ }
265
+ }
266
+
267
+ const handleCopyActiveLink = async () => {
268
+ if (!activeLinkInvitation?.link) return
269
+ try {
270
+ const days = activeLinkInvitation.expiresAt
271
+ ? Math.max(1, Math.ceil((activeLinkInvitation.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
272
+ : linkExpires
273
+ const message = t("team.linkCopiedMessage", {
274
+ inviteLink: activeLinkInvitation.link,
275
+ days,
276
+ })
277
+ await navigator.clipboard.writeText(message)
278
+ setLinkCopied(true)
279
+ setTimeout(() => setLinkCopied(false), 3000)
280
+ } catch {}
281
+ }
282
+
283
+ const handleCreateEmail = async () => {
284
+ setErrors({})
285
+
286
+ if (!emailAddress.trim()) {
287
+ setErrors({ email: t("team.emailRequired") })
288
+ return
289
+ }
290
+
291
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
292
+ if (!emailRegex.test(emailAddress)) {
293
+ setErrors({ email: t("team.invalidEmail") })
294
+ return
189
295
  }
190
296
 
191
- setIsCreating(true);
297
+ setEmailCreating(true)
192
298
  try {
193
299
  await onCreate?.({
194
- email: inviteData.useDirectLink ? undefined : inviteData.email,
195
- role: inviteData.role,
196
- expiresInDays: inviteData.expiresInDays,
197
- message: inviteData.message,
198
- });
199
- setInviteData({
200
- email: "",
201
- role: "member",
202
- expiresInDays: 7,
203
- message: "",
204
- useDirectLink: false,
205
- });
206
- setCreateDialogOpen(false);
300
+ email: emailAddress,
301
+ role: emailRole,
302
+ expiresInDays: emailExpires,
303
+ message: emailMessage,
304
+ })
305
+ setEmailAddress("")
306
+ setEmailMessage("")
207
307
  } catch (error) {
208
308
  setErrors({
209
309
  _general:
210
310
  error instanceof Error
211
311
  ? error.message
212
312
  : t("team.inviteCreateFailed"),
213
- });
313
+ })
214
314
  } finally {
215
- setIsCreating(false);
315
+ setEmailCreating(false)
216
316
  }
217
- };
317
+ }
318
+
319
+ const handleApprove = async (
320
+ invitationId: string,
321
+ role: "admin" | "member" | "viewer",
322
+ ) => {
323
+ setActionLoading(invitationId)
324
+ try {
325
+ await onApproveLink?.(invitationId, role)
326
+ } finally {
327
+ setActionLoading(null)
328
+ }
329
+ }
218
330
 
219
331
  const handleAction = async (
220
332
  action: () => Promise<void>,
221
- invitationId: string
333
+ invitationId: string,
222
334
  ) => {
223
- setActionLoading(invitationId);
335
+ setActionLoading(invitationId)
224
336
  try {
225
- await action();
337
+ await action()
226
338
  } finally {
227
- setActionLoading(null);
339
+ setActionLoading(null)
228
340
  }
229
- };
341
+ }
230
342
 
231
- const handleCopyLink = async (link: string, invitationId: string) => {
232
- try {
233
- await onCopyLink?.(link);
234
- await navigator.clipboard.writeText(link);
235
- setCopiedLink(invitationId);
236
- setTimeout(() => setCopiedLink(null), 2000);
237
- } catch {}
238
- };
343
+ const linkExpiryDays = activeLinkInvitation?.expiresAt
344
+ ? Math.max(1, Math.ceil((activeLinkInvitation.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
345
+ : null
346
+
347
+ const linkExpiryText = activeLinkInvitation?.expiresAt
348
+ ? `${formatDate(activeLinkInvitation.expiresAt, timezone, "MMM d, yyyy")}`
349
+ : undefined
239
350
 
240
351
  return (
241
- <Card className={cn("w-full shadow-xs border", className)}>
352
+ <Card className={cn("w-full min-w-100 border shadow-xs", className)}>
242
353
  <CardHeader>
243
- <div className="flex flex-col flex-wrap gap-3 md:flex-row md:items-start md:justify-between">
244
- <div className="flex min-w-0 flex-1 flex-col gap-2">
245
- <CardTitle>{t("team.invite.title")}</CardTitle>
246
- <CardDescription>
247
- {t("team.invite.description", { pending: pendingInvitations.length, accepted: acceptedInvitations.length })}
248
- </CardDescription>
249
- </div>
250
- <Dialog onOpenChange={setCreateDialogOpen} open={createDialogOpen}>
251
- <DialogTrigger asChild>
252
- <Button className="w-full shrink-0 md:w-auto" type="button">
253
- <Plus className="size-4" />
254
- {t("team.invite.createInvite")}
255
- </Button>
256
- </DialogTrigger>
257
- <DialogContent className="md:max-w-md">
258
- <DialogHeader>
259
- <DialogTitle>{t("team.invite.createInvite")}</DialogTitle>
260
- <DialogDescription>
261
- {t("team.invite.dialogDescription")}
262
- </DialogDescription>
263
- </DialogHeader>
264
- <div className="flex flex-col gap-4">
265
- {errors._general && (
266
- <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
267
- <p className="text-destructive text-sm">
268
- {errors._general}
269
- </p>
270
- </div>
271
- )}
272
- {allowDirectLinks && (
273
- <Field>
354
+ <div className="flex flex-col gap-2">
355
+ <CardTitle>{t("team.invite.title")}</CardTitle>
356
+ <CardDescription>
357
+ {t("team.invite.description", {
358
+ pending: pendingInvitations.length,
359
+ approvalPending: approvalPendingInvitations.length,
360
+ accepted: acceptedInvitations.length,
361
+ })}
362
+ </CardDescription>
363
+ </div>
364
+ </CardHeader>
365
+ <CardContent>
366
+ <div className="flex flex-col gap-4">
367
+ {errors._general && (
368
+ <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
369
+ <p className="text-destructive text-sm">{errors._general}</p>
370
+ </div>
371
+ )}
372
+
373
+ {allowDirectLinks && (
374
+ <div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
375
+ <div className="flex items-start justify-between gap-4">
376
+ <div className="flex flex-col gap-1 min-w-0 flex-1">
274
377
  <div className="flex items-center gap-2">
275
- <Checkbox
276
- checked={inviteData.useDirectLink}
277
- id="use-direct-link"
278
- onCheckedChange={(checked: boolean) =>
279
- setInviteData((prev) => ({
280
- ...prev,
281
- useDirectLink: checked === true,
282
- }))
283
- }
284
- />
285
- <FieldLabel htmlFor="use-direct-link">
286
- {t("team.invite.directLink")}
287
- </FieldLabel>
378
+ <Link2 className="size-4" />
379
+ <h4 className="font-medium text-sm">
380
+ {t("team.invite.linkTitle")}
381
+ </h4>
288
382
  </div>
289
- </Field>
290
- )}
291
- {!inviteData.useDirectLink && (
292
- <Field>
293
- <FieldLabel htmlFor="email">
294
- {t("team.invite.email")} <span className="text-destructive">*</span>
295
- </FieldLabel>
296
- <FieldContent>
297
- <InputGroup>
298
- <InputGroupAddon>
299
- <Mail className="size-4" />
300
- </InputGroupAddon>
301
- <InputGroupInput
302
- id="email"
303
- onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
304
- setInviteData((prev) => ({
305
- ...prev,
306
- email: e.target.value,
307
- }))
308
- }
309
- placeholder="colleague@example.com"
310
- type="email"
311
- value={inviteData.email}
312
- />
313
- </InputGroup>
314
- </FieldContent>
315
- {errors.email && <FieldError>{errors.email}</FieldError>}
316
- </Field>
317
- )}
318
- <Field>
319
- <FieldLabel htmlFor="role">{t("team.invite.role")}</FieldLabel>
383
+ <span className="text-muted-foreground text-xs">
384
+ {t("team.invite.linkDescription")}
385
+ </span>
386
+ </div>
387
+ <div className="flex items-center gap-2">
388
+ <Select
389
+ onValueChange={(value: string) =>
390
+ setLinkExpires(Number.parseInt(value))
391
+ }
392
+ value={linkExpires.toString()}
393
+ >
394
+ <SelectTrigger className="h-8 w-18" size="sm">
395
+ <SelectValue />
396
+ </SelectTrigger>
397
+ <SelectContent>
398
+ <SelectItem value="1">
399
+ {t("team.days", { count: 1 })}
400
+ </SelectItem>
401
+ <SelectItem value="3">
402
+ {t("team.days", { count: 3 })}
403
+ </SelectItem>
404
+ <SelectItem value="7">
405
+ {t("team.days", { count: 7 })}
406
+ </SelectItem>
407
+ <SelectItem value="14">
408
+ {t("team.days", { count: 14 })}
409
+ </SelectItem>
410
+ <SelectItem value="30">
411
+ {t("team.days", { count: 30 })}
412
+ </SelectItem>
413
+ </SelectContent>
414
+ </Select>
415
+ <Button
416
+ aria-busy={linkRefreshing}
417
+ data-loading={linkRefreshing}
418
+ disabled={linkRefreshing}
419
+ onClick={handleRefreshLink}
420
+ size="sm"
421
+ type="button"
422
+ variant="outline"
423
+ >
424
+ {linkRefreshing ? (
425
+ <>
426
+ <Loader2 className="size-4 animate-spin" />
427
+ {t("team.invite.linkRefreshing")}
428
+ </>
429
+ ) : (
430
+ <>
431
+ <RefreshCw className="size-4" />
432
+ {t("team.invite.refreshLink")}
433
+ </>
434
+ )}
435
+ </Button>
436
+ </div>
437
+ </div>
438
+
439
+ {activeLinkInvitation ? (
440
+ <div className="flex items-center justify-between gap-2 rounded-md border bg-muted/50 p-3">
441
+ <code className="min-w-0 flex-1 break-all text-wrap text-xs">
442
+ {activeLinkInvitation.link}
443
+ </code>
444
+ {isLinkExpired && (
445
+ <Badge variant="destructive">{t("team.status.expired")}</Badge>
446
+ )}
447
+ {activeLinkInvitation.expiresAt && (
448
+ <span className="text-muted-foreground text-xs whitespace-nowrap shrink-0">
449
+ {isLinkExpired
450
+ ? t("team.invite.linkExpired")
451
+ : `${t("team.invite.linkExpiry")} ${linkExpiryText}`}
452
+ </span>
453
+ )}
454
+ <Button
455
+ aria-label={t("team.copyLink")}
456
+ onClick={handleCopyActiveLink}
457
+ size="icon-sm"
458
+ type="button"
459
+ variant="ghost"
460
+ >
461
+ {linkCopied ? (
462
+ <Check className="size-4" />
463
+ ) : (
464
+ <Copy className="size-4" />
465
+ )}
466
+ </Button>
467
+ </div>
468
+ ) : (
469
+ <div className="flex items-center justify-center gap-2 rounded-md border border-dashed bg-muted/20 p-3">
470
+ <Link2Off className="size-4 shrink-0 text-muted-foreground" />
471
+ <p className="text-muted-foreground text-xs">
472
+ {t("team.invite.noActiveLink")}
473
+ </p>
474
+ </div>
475
+ )}
476
+ </div>
477
+ )}
478
+
479
+ {joinRequests.length > 0 && (
480
+ <div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
481
+ <div className="flex items-center gap-2">
482
+ <UserPlus className="size-4" />
483
+ <h4 className="font-medium text-sm">{t("team.joinRequests.title")}</h4>
484
+ <span className="text-muted-foreground text-xs">
485
+ {t("team.joinRequests.description")}
486
+ </span>
487
+ </div>
488
+ <div className="flex flex-col gap-3">
489
+ {joinRequests.map((req) => (
490
+ <div className="flex flex-col gap-2 rounded-lg border bg-card p-3" key={req.id}>
491
+ <div className="flex items-center justify-between gap-3">
492
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
493
+ <span className="font-medium text-sm">{req.applicantName || req.email}</span>
494
+ <Badge variant="outline" className="text-xs">
495
+ {t("team.joinRequests.sourceLink")}
496
+ </Badge>
497
+ {req.status === "pending" && (
498
+ <Badge variant="default">{t("team.status.pendingApproval")}</Badge>
499
+ )}
500
+ {req.status === "approved" && (
501
+ <Badge variant="default">{t("team.joinRequests.requestApproved")}</Badge>
502
+ )}
503
+ {req.status === "rejected" && (
504
+ <Badge variant="destructive">{t("team.joinRequests.requestRejected")}</Badge>
505
+ )}
506
+ {req.status === "invalidated" && (
507
+ <Badge variant="outline">{t("team.joinRequests.requestInvalidated")}</Badge>
508
+ )}
509
+ </div>
510
+ {req.status === "pending" && onApproveRequest && (
511
+ <div className="flex items-center gap-2">
512
+ <Select
513
+ value={approveRoles[req.id] ?? "member"}
514
+ onValueChange={(value: "admin" | "member" | "viewer") =>
515
+ setApproveRoles((prev) => ({ ...prev, [req.id]: value }))
516
+ }
517
+ >
518
+ <SelectTrigger className="h-8 w-24" size="sm">
519
+ <SelectValue />
520
+ </SelectTrigger>
521
+ <SelectContent>
522
+ <SelectItem value="member">{t("team.role.member")}</SelectItem>
523
+ <SelectItem value="admin">{t("team.role.admin")}</SelectItem>
524
+ <SelectItem value="viewer">{t("team.role.viewer")}</SelectItem>
525
+ </SelectContent>
526
+ </Select>
527
+ <Button
528
+ disabled={actionLoading === req.id}
529
+ onClick={() =>
530
+ handleAction(async () => {
531
+ await onApproveRequest(req.id, approveRoles[req.id] ?? "member")
532
+ }, req.id)
533
+ }
534
+ size="sm"
535
+ type="button"
536
+ >
537
+ {actionLoading === req.id ? (
538
+ <Loader2 className="size-4 animate-spin" />
539
+ ) : (
540
+ <Check className="size-4" />
541
+ )}
542
+ {t("team.invite.approve")}
543
+ </Button>
544
+ <Button
545
+ disabled={actionLoading === req.id}
546
+ onClick={() =>
547
+ handleAction(async () => {
548
+ await onRejectRequest?.(req.id)
549
+ }, req.id)
550
+ }
551
+ size="sm"
552
+ type="button"
553
+ variant="ghost"
554
+ >
555
+ <X className="size-4" />
556
+ {t("team.joinRequests.reject")}
557
+ </Button>
558
+ </div>
559
+ )}
560
+ </div>
561
+ <div className="text-muted-foreground text-xs">
562
+ {t("team.joinRequests.appliedAt")}{" "}
563
+ {formatDate(req.createdAt, timezone, "MMM d, yyyy")}
564
+ </div>
565
+ </div>
566
+ ))}
567
+ </div>
568
+ </div>
569
+ )}
570
+
571
+ <div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
572
+ <div className="flex items-center gap-2">
573
+ <Mail className="size-4" />
574
+ <h4 className="font-medium text-sm">
575
+ {t("team.invite.emailTitle")}
576
+ </h4>
577
+ <span className="text-muted-foreground text-xs">
578
+ {t("team.invite.emailDescription")}
579
+ </span>
580
+ </div>
581
+
582
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-center">
583
+ <Field className="min-w-0 flex-1 xl:flex-[2]">
584
+ <FieldContent>
585
+ <InputGroup>
586
+ <InputGroupAddon>
587
+ <Mail className="size-4" />
588
+ </InputGroupAddon>
589
+ <InputGroupInput
590
+ id="email-invite-email"
591
+ onChange={(e) => setEmailAddress(e.target.value)}
592
+ placeholder={t("team.invite.emailPlaceholder")}
593
+ type="email"
594
+ value={emailAddress}
595
+ />
596
+ </InputGroup>
597
+ </FieldContent>
598
+ {errors.email && <FieldError>{errors.email}</FieldError>}
599
+ </Field>
600
+
601
+ <div className="min-w-0 flex flex-row gap-3 xl:flex-[2]">
602
+ <Field className="w-full flex-1 xl:w-28 xl:flex-[1] shrink-0">
320
603
  <FieldContent>
321
604
  <Select
322
605
  onValueChange={(value: "admin" | "member" | "viewer") =>
323
- setInviteData((prev) => ({ ...prev, role: value }))
606
+ setEmailRole(value)
324
607
  }
325
- value={inviteData.role}
608
+ value={emailRole}
326
609
  >
327
- <SelectTrigger id="role">
610
+ <SelectTrigger id="email-invite-role" className="w-full">
328
611
  <SelectValue />
329
612
  </SelectTrigger>
330
613
  <SelectContent>
331
- <SelectItem value="member">{t("team.role.member")}</SelectItem>
332
- <SelectItem value="admin">{t("team.role.admin")}</SelectItem>
333
- <SelectItem value="viewer">{t("team.role.viewer")}</SelectItem>
614
+ <SelectItem value="member">
615
+ {t("team.role.member")}
616
+ </SelectItem>
617
+ <SelectItem value="admin">
618
+ {t("team.role.admin")}
619
+ </SelectItem>
620
+ <SelectItem value="viewer">
621
+ {t("team.role.viewer")}
622
+ </SelectItem>
334
623
  </SelectContent>
335
624
  </Select>
336
625
  </FieldContent>
337
- <FieldDescription>
338
- {t("team.invite.roleDescription")}
339
- </FieldDescription>
340
626
  </Field>
341
- <Field>
342
- <FieldLabel htmlFor="expires">{t("team.invite.expires")}</FieldLabel>
627
+
628
+ <Field className="w-full flex-1 xl:w-28 xl:flex-[1] shrink-0">
343
629
  <FieldContent>
344
630
  <Select
345
631
  onValueChange={(value: string) =>
346
- setInviteData((prev) => ({
347
- ...prev,
348
- expiresInDays: Number.parseInt(value),
349
- }))
632
+ setEmailExpires(Number.parseInt(value))
350
633
  }
351
- value={inviteData.expiresInDays.toString()}
634
+ value={emailExpires.toString()}
352
635
  >
353
- <SelectTrigger id="expires">
636
+ <SelectTrigger id="email-invite-expires" className="w-full">
354
637
  <SelectValue />
355
638
  </SelectTrigger>
356
639
  <SelectContent>
357
- <SelectItem value="1">{t("team.days", { count: 1 })}</SelectItem>
358
- <SelectItem value="3">{t("team.days", { count: 3 })}</SelectItem>
359
- <SelectItem value="7">{t("team.days", { count: 7 })}</SelectItem>
360
- <SelectItem value="14">{t("team.days", { count: 14 })}</SelectItem>
361
- <SelectItem value="30">{t("team.days", { count: 30 })}</SelectItem>
640
+ <SelectItem value="1">
641
+ {t("team.days", { count: 1 })}
642
+ </SelectItem>
643
+ <SelectItem value="3">
644
+ {t("team.days", { count: 3 })}
645
+ </SelectItem>
646
+ <SelectItem value="7">
647
+ {t("team.days", { count: 7 })}
648
+ </SelectItem>
649
+ <SelectItem value="14">
650
+ {t("team.days", { count: 14 })}
651
+ </SelectItem>
652
+ <SelectItem value="30">
653
+ {t("team.days", { count: 30 })}
654
+ </SelectItem>
362
655
  </SelectContent>
363
656
  </Select>
364
657
  </FieldContent>
365
658
  </Field>
366
- <Field>
367
- <FieldLabel htmlFor="message">{t("team.invite.message")}</FieldLabel>
368
- <FieldContent>
369
- <Textarea
370
- id="message"
371
- onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
372
- setInviteData((prev) => ({
373
- ...prev,
374
- message: e.target.value,
375
- }))
376
- }
377
- placeholder={t("team.invite.messagePlaceholder")}
378
- rows={3}
379
- value={inviteData.message}
380
- />
381
- </FieldContent>
382
- </Field>
383
659
  </div>
384
- <DialogFooter>
385
- <Button
386
- onClick={() => setCreateDialogOpen(false)}
387
- type="button"
388
- variant="outline"
389
- >
390
- {t("common.cancel")}
391
- </Button>
392
- <Button
393
- aria-busy={isCreating}
394
- data-loading={isCreating}
395
- onClick={handleCreate}
396
- type="button"
397
- >
398
- {isCreating ? (
399
- <>
400
- <Loader2 className="size-4 animate-spin" />
401
- {t("team.invite.creating")}
402
- </>
403
- ) : (
404
- <>
405
- <Mail className="size-4" />
406
- {t("team.invite.sendInvite")}
407
- </>
408
- )}
409
- </Button>
410
- </DialogFooter>
411
- </DialogContent>
412
- </Dialog>
413
- </div>
414
- </CardHeader>
415
- <CardContent>
416
- {invitations.length === 0 ? (
417
- <Empty>
418
- <EmptyHeader>
419
- <EmptyMedia variant="icon">
420
- <Mail className="size-6" />
421
- </EmptyMedia>
422
- <EmptyTitle>{t("team.invite.noInvitations")}</EmptyTitle>
423
- <EmptyDescription>{t("team.invite.createFirst")}</EmptyDescription>
424
- </EmptyHeader>
425
- </Empty>
426
- ) : (
427
- <div className="flex flex-col gap-4">
428
- {invitations.map((invitation) => (
429
- <div
430
- className="flex flex-col gap-3 rounded-lg border bg-card p-4"
431
- key={invitation.id}
660
+
661
+ <Field className="min-w-0 flex-1 xl:flex-[2]">
662
+ <FieldContent>
663
+ <Input
664
+ id="email-invite-message"
665
+ onChange={(e) => setEmailMessage(e.target.value)}
666
+ placeholder={t("team.invite.messagePlaceholder")}
667
+ value={emailMessage}
668
+ />
669
+ </FieldContent>
670
+ </Field>
671
+
672
+ <Button
673
+ aria-busy={emailCreating}
674
+ data-loading={emailCreating}
675
+ disabled={emailCreating}
676
+ className="w-full xl:w-auto shrink-0"
677
+ onClick={handleCreateEmail}
678
+ type="button"
432
679
  >
433
- <div className="flex items-start justify-between gap-4">
434
- <div className="flex min-w-0 flex-1 flex-col gap-2">
435
- <div className="flex flex-wrap items-center gap-2">
436
- {invitation.email ? (
437
- <span className="font-medium text-sm">
438
- {invitation.email}
439
- </span>
440
- ) : (
441
- <span className="font-medium text-sm">{t("team.directLink")}</span>
442
- )}
443
- {getStatusBadge(invitation.status, t)}
444
- <Badge variant="outline">{t(`team.role.${invitation.role}`)}</Badge>
445
- </div>
446
- <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
447
- <span>
448
- {invitation.invitedBy.name} {t("team.invite.invitedAt")} {formatDate(invitation.createdAt, timezone, 'MMM d, yyyy')}
449
- </span>
450
- {invitation.expiresAt && (
451
- <>
452
- <span aria-hidden="true">•</span>
453
- <span>
454
- {formatTimeUntil(invitation.expiresAt, t)}{t("team.expiresIn")}
455
- </span>
456
- </>
457
- )}
458
- {invitation.acceptedAt && (
459
- <>
460
- <span aria-hidden="true">•</span>
461
- <span>
462
- {t("team.invite.acceptedAt")} {formatDate(invitation.acceptedAt, timezone, 'MMM d, yyyy')}
463
- </span>
464
- </>
465
- )}
466
- </div>
467
- </div>
468
- {invitation.status === "pending" && (
469
- <DropdownMenu>
470
- <DropdownMenuTrigger asChild>
471
- <Button
472
- aria-label={t("team.moreActions")}
473
- size="icon"
474
- type="button"
475
- variant="ghost"
476
- >
477
- {actionLoading === invitation.id ? (
478
- <Loader2 className="size-4 animate-spin" />
479
- ) : (
480
- <X className="size-4" />
481
- )}
482
- </Button>
483
- </DropdownMenuTrigger>
484
- <DropdownMenuContent
485
- align="end"
486
- collisionPadding={8}
487
- sideOffset={4}
488
- >
489
- {onResend && (
490
- <DropdownMenuItem
491
- onSelect={() =>
492
- handleAction(
493
- () => onResend(invitation.id),
494
- invitation.id
495
- )
496
- }
497
- >
498
- <RefreshCw className="size-4" />
499
- {t("team.resend")}
500
- </DropdownMenuItem>
501
- )}
502
- {onRevoke && (
503
- <>
504
- <DropdownMenuSeparator />
505
- <DropdownMenuItem
506
- onSelect={() =>
507
- handleAction(
508
- () => onRevoke(invitation.id),
509
- invitation.id
510
- )
511
- }
512
- variant="destructive"
513
- >
514
- <Trash2 className="size-4" />
515
- {t("team.revoke")}
516
- </DropdownMenuItem>
517
- </>
518
- )}
519
- </DropdownMenuContent>
520
- </DropdownMenu>
521
- )}
522
- </div>
523
- {invitation.link && (
524
- <div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
525
- <code className="wrap-break-word truncate text-wrap break-all text-xs">
526
- {invitation.link}
527
- </code>
680
+ {emailCreating ? (
681
+ <>
682
+ <Loader2 className="size-4 animate-spin" />
683
+ {t("team.invite.creating")}
684
+ </>
685
+ ) : (
686
+ <>
687
+ <Send className="size-4" />
688
+ {t("team.invite.sendInvite")}
689
+ </>
690
+ )}
691
+ </Button>
692
+ </div>
693
+ </div>
694
+
695
+ {historyInvitations.length > 0 && (
696
+ <>
697
+ <Separator />
698
+ <div className="flex flex-col gap-1">
699
+ <div className="flex items-center justify-between gap-2">
700
+ <h4 className="font-medium text-sm">
701
+ {t("team.invite.historyTitle")}
702
+ </h4>
703
+ {onClearExpired && (
528
704
  <Button
529
- aria-label={t("team.copyLink")}
530
- onClick={() =>
531
- handleCopyLink(invitation.link!, invitation.id)
532
- }
533
- size="icon-sm"
705
+ disabled={clearingExpired}
706
+ onClick={async () => {
707
+ setClearingExpired(true)
708
+ try {
709
+ await onClearExpired()
710
+ } finally {
711
+ setClearingExpired(false)
712
+ }
713
+ }}
714
+ size="sm"
534
715
  type="button"
535
716
  variant="ghost"
536
717
  >
537
- {copiedLink === invitation.id ? (
538
- <Check className="size-4" />
718
+ {clearingExpired ? (
719
+ <Loader2 className="size-4 animate-spin" />
539
720
  ) : (
540
- <Copy className="size-4" />
721
+ <X className="size-4" />
541
722
  )}
723
+ {t("team.invite.clearExpired")}
542
724
  </Button>
543
- </div>
544
- )}
725
+ )}
726
+ </div>
545
727
  </div>
546
- ))}
547
- </div>
548
- )}
728
+ <div className="flex flex-col gap-3">
729
+ {historyInvitations.map((invitation) => (
730
+ <div
731
+ className="flex flex-col gap-3 rounded-lg border bg-card p-4"
732
+ key={invitation.id}
733
+ >
734
+ <div className="flex items-center justify-between gap-4">
735
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
736
+ <div className="flex flex-wrap items-center gap-2">
737
+ {(invitation.channel === "link" ? invitation.applicantEmail : invitation.email) && (
738
+ <span className="font-medium text-sm">
739
+ {invitation.channel === "link"
740
+ ? invitation.applicantEmail
741
+ : invitation.email}
742
+ </span>
743
+ )}
744
+ {getChannelBadge(invitation.channel, t)}
745
+ {getInvitationBadge(invitation, t)}
746
+ {invitation.status === "pending_approval" && invitation.channel === "link" && (
747
+ <Badge variant="secondary" className="text-xs">{t("team.unregistered")}</Badge>
748
+ )}
749
+ {invitation.role && (
750
+ <Badge variant="outline">
751
+ {t(`team.role.${invitation.role}`)}
752
+ </Badge>
753
+ )}
754
+ </div>
755
+ <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
756
+ <span>
757
+ {t("team.invite.invitedAt")}{" "}
758
+ {invitation.invitedBy.name}{" "}
759
+ {formatDate(
760
+ invitation.createdAt,
761
+ timezone,
762
+ "MMM d, yyyy",
763
+ )}
764
+ </span>
765
+ {invitation.expiresAt && (
766
+ <>
767
+ <span aria-hidden="true">•</span>
768
+ <span>
769
+ {(() => {
770
+ const timeStr = formatTimeUntil(invitation.expiresAt, t)
771
+ if (timeStr === t("team.expired")) return timeStr
772
+ return t("team.expiresIn", { time: timeStr })
773
+ })()}
774
+ </span>
775
+ </>
776
+ )}
777
+ {invitation.acceptedAt && (
778
+ <>
779
+ <span aria-hidden="true">•</span>
780
+ <span>
781
+ {t("team.invite.acceptedAt")}{" "}
782
+ {formatDate(
783
+ invitation.acceptedAt,
784
+ timezone,
785
+ "MMM d, yyyy",
786
+ )}
787
+ </span>
788
+ </>
789
+ )}
790
+ </div>
791
+ </div>
792
+ {invitation.status === "pending_approval" && onApproveLink && (
793
+ <div className="hidden xl:flex items-center gap-2">
794
+ <Select
795
+ value={approveRoles[invitation.id] ?? "member"}
796
+ onValueChange={(value: "admin" | "member" | "viewer") =>
797
+ setApproveRoles((prev) => ({ ...prev, [invitation.id]: value }))
798
+ }
799
+ >
800
+ <SelectTrigger className="h-8 w-24" size="sm">
801
+ <SelectValue />
802
+ </SelectTrigger>
803
+ <SelectContent>
804
+ <SelectItem value="member">
805
+ {t("team.role.member")}
806
+ </SelectItem>
807
+ <SelectItem value="admin">
808
+ {t("team.role.admin")}
809
+ </SelectItem>
810
+ <SelectItem value="viewer">
811
+ {t("team.role.viewer")}
812
+ </SelectItem>
813
+ </SelectContent>
814
+ </Select>
815
+ <Button
816
+ disabled={actionLoading === invitation.id}
817
+ onClick={() =>
818
+ handleApprove(invitation.id, approveRoles[invitation.id] ?? "member")
819
+ }
820
+ size="sm"
821
+ type="button"
822
+ >
823
+ {actionLoading === invitation.id ? (
824
+ <Loader2 className="size-4 animate-spin" />
825
+ ) : (
826
+ <Check className="size-4" />
827
+ )}
828
+ {t("team.invite.approve")}
829
+ </Button>
830
+ <DropdownMenu>
831
+ <DropdownMenuTrigger asChild>
832
+ <Button
833
+ aria-label="More options"
834
+ size="icon"
835
+ type="button"
836
+ variant="ghost"
837
+ >
838
+ <X className="size-4" />
839
+ </Button>
840
+ </DropdownMenuTrigger>
841
+ <DropdownMenuContent
842
+ align="end"
843
+ collisionPadding={8}
844
+ sideOffset={4}
845
+ >
846
+ <DropdownMenuItem
847
+ onSelect={() =>
848
+ handleAction(
849
+ () => onRevoke!(invitation.id),
850
+ invitation.id,
851
+ )
852
+ }
853
+ variant="destructive"
854
+ >
855
+ <Trash2 className="size-4" />
856
+ {t("team.revoke")}
857
+ </DropdownMenuItem>
858
+ </DropdownMenuContent>
859
+ </DropdownMenu>
860
+ </div>
861
+ )}
862
+ {!(invitation.status === "pending_approval" && onApproveLink) && (() => {
863
+ const canResend = ["pending", "expired"].includes(invitation.status) && onResend
864
+ const canRevoke = onRevoke
865
+ return (
866
+ <DropdownMenu>
867
+ <DropdownMenuTrigger asChild>
868
+ <Button
869
+ aria-label="More options"
870
+ size="icon"
871
+ type="button"
872
+ variant="ghost"
873
+ >
874
+ {actionLoading === invitation.id ? (
875
+ <Loader2 className="size-4 animate-spin" />
876
+ ) : (
877
+ <X className="size-4" />
878
+ )}
879
+ </Button>
880
+ </DropdownMenuTrigger>
881
+ <DropdownMenuContent
882
+ align="end"
883
+ collisionPadding={8}
884
+ sideOffset={4}
885
+ >
886
+ {canResend && (
887
+ <DropdownMenuItem
888
+ onSelect={() =>
889
+ handleAction(
890
+ () => onResend!(invitation.id),
891
+ invitation.id,
892
+ )
893
+ }
894
+ >
895
+ <RefreshCw className="size-4" />
896
+ {t("team.resend")}
897
+ </DropdownMenuItem>
898
+ )}
899
+ {canRevoke && (
900
+ <>
901
+ <DropdownMenuSeparator />
902
+ <DropdownMenuItem
903
+ onSelect={() =>
904
+ handleAction(
905
+ () => onRevoke!(invitation.id),
906
+ invitation.id,
907
+ )
908
+ }
909
+ variant="destructive"
910
+ >
911
+ <Trash2 className="size-4" />
912
+ {t("team.revoke")}
913
+ </DropdownMenuItem>
914
+ </>
915
+ )}
916
+ </DropdownMenuContent>
917
+ </DropdownMenu>
918
+ )
919
+ })()}
920
+ </div>
921
+ {invitation.link &&
922
+ (invitation.status === "expired" ||
923
+ invitation.status === "revoked") && (
924
+ <div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
925
+ <code className="wrap-break-word truncate text-wrap text-xs">
926
+ {invitation.link}
927
+ </code>
928
+ </div>
929
+ )}
930
+ {invitation.status === "pending_approval" && onApproveLink && (
931
+ <div className="xl:hidden flex justify-end items-center gap-2">
932
+ <Select
933
+ value={approveRoles[invitation.id] ?? "member"}
934
+ onValueChange={(value: "admin" | "member" | "viewer") =>
935
+ setApproveRoles((prev) => ({ ...prev, [invitation.id]: value }))
936
+ }
937
+ >
938
+ <SelectTrigger className="h-8 w-24" size="sm">
939
+ <SelectValue />
940
+ </SelectTrigger>
941
+ <SelectContent>
942
+ <SelectItem value="member">
943
+ {t("team.role.member")}
944
+ </SelectItem>
945
+ <SelectItem value="admin">
946
+ {t("team.role.admin")}
947
+ </SelectItem>
948
+ <SelectItem value="viewer">
949
+ {t("team.role.viewer")}
950
+ </SelectItem>
951
+ </SelectContent>
952
+ </Select>
953
+ <Button
954
+ disabled={actionLoading === invitation.id}
955
+ onClick={() =>
956
+ handleApprove(invitation.id, approveRoles[invitation.id] ?? "member")
957
+ }
958
+ size="sm"
959
+ type="button"
960
+ >
961
+ {actionLoading === invitation.id ? (
962
+ <Loader2 className="size-4 animate-spin" />
963
+ ) : (
964
+ <Check className="size-4" />
965
+ )}
966
+ {t("team.invite.approve")}
967
+ </Button>
968
+ <DropdownMenu>
969
+ <DropdownMenuTrigger asChild>
970
+ <Button
971
+ aria-label="More options"
972
+ size="icon"
973
+ type="button"
974
+ variant="ghost"
975
+ >
976
+ <X className="size-4" />
977
+ </Button>
978
+ </DropdownMenuTrigger>
979
+ <DropdownMenuContent
980
+ align="end"
981
+ collisionPadding={8}
982
+ sideOffset={4}
983
+ >
984
+ <DropdownMenuItem
985
+ onSelect={() =>
986
+ handleAction(
987
+ () => onRevoke!(invitation.id),
988
+ invitation.id,
989
+ )
990
+ }
991
+ variant="destructive"
992
+ >
993
+ <Trash2 className="size-4" />
994
+ {t("team.revoke")}
995
+ </DropdownMenuItem>
996
+ </DropdownMenuContent>
997
+ </DropdownMenu>
998
+ </div>
999
+ )}
1000
+ </div>
1001
+ ))}
1002
+ </div>
1003
+ </>
1004
+ )}
1005
+
1006
+ {invitations.length === 0 && (
1007
+ <Empty>
1008
+ <EmptyHeader>
1009
+ <EmptyMedia variant="icon">
1010
+ <Mail className="size-6" />
1011
+ </EmptyMedia>
1012
+ <EmptyTitle>{t("team.invite.noInvitations")}</EmptyTitle>
1013
+ <EmptyDescription>
1014
+ {t("team.invite.createFirst")}
1015
+ </EmptyDescription>
1016
+ </EmptyHeader>
1017
+ </Empty>
1018
+ )}
1019
+ </div>
549
1020
  </CardContent>
550
1021
  </Card>
551
- );
552
- }
1022
+ )
1023
+ }