@allbluecn/web-app 0.4.16 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
- package/src/components/story/display/display-dropdown.tsx +6 -7
- package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
- package/src/components/story/story-list-view.tsx +13 -2
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/story/story-crud/labels.ts +45 -6
- package/src/server/serverFns/story/story-crud.ts +8 -2
- package/src/server/serverFns/story/story-groups.ts +26 -7
- package/src/server/serverFns/story/story-search.ts +4 -0
- package/src/server/serverFns/story-display-options.ts +15 -4
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- package/vite.shared.ts +13 -0
|
@@ -1,552 +1,1023 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
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
|
-
|
|
74
|
-
} from "@/components/ui/
|
|
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 {
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
169
|
+
onApproveLink,
|
|
170
|
+
onApproveRequest,
|
|
171
|
+
onRejectRequest,
|
|
172
|
+
onClearExpired,
|
|
152
173
|
className,
|
|
153
174
|
allowDirectLinks = true,
|
|
154
175
|
}: TeamInvitationsProps) {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
|
158
|
-
const [
|
|
159
|
-
|
|
160
|
-
const [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const
|
|
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
|
|
176
|
-
setErrors({});
|
|
193
|
+
const [linkExpires, setLinkExpires] = useState(7)
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
297
|
+
setEmailCreating(true)
|
|
192
298
|
try {
|
|
193
299
|
await onCreate?.({
|
|
194
|
-
email:
|
|
195
|
-
role:
|
|
196
|
-
expiresInDays:
|
|
197
|
-
message:
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
352
|
+
<Card className={cn("w-full min-w-100 border shadow-xs", className)}>
|
|
242
353
|
<CardHeader>
|
|
243
|
-
<div className="flex flex-col
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
606
|
+
setEmailRole(value)
|
|
324
607
|
}
|
|
325
|
-
value={
|
|
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">
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
347
|
-
...prev,
|
|
348
|
-
expiresInDays: Number.parseInt(value),
|
|
349
|
-
}))
|
|
632
|
+
setEmailExpires(Number.parseInt(value))
|
|
350
633
|
}
|
|
351
|
-
value={
|
|
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">
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
<SelectItem value="
|
|
361
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
530
|
-
onClick={() =>
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
{
|
|
538
|
-
<
|
|
718
|
+
{clearingExpired ? (
|
|
719
|
+
<Loader2 className="size-4 animate-spin" />
|
|
539
720
|
) : (
|
|
540
|
-
<
|
|
721
|
+
<X className="size-4" />
|
|
541
722
|
)}
|
|
723
|
+
{t("team.invite.clearExpired")}
|
|
542
724
|
</Button>
|
|
543
|
-
|
|
544
|
-
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
545
727
|
</div>
|
|
546
|
-
|
|
547
|
-
|
|
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
|
+
}
|