Skip to content

Commit 512a0a6

Browse files
feat: show copyable invite link in pending invites list (#239) (#240)
* feat: show copyable invite link in pending invites list (#239) Co-authored-by: Ona <no-reply@ona.com> * fix: [ci-fix] add try/catch around navigator.clipboard.writeText calls clipboard.writeText() can reject when the document isn't focused or permissions are denied. Wrap both call sites in try/catch to prevent unhandled promise rejections. Added files to the bare-catch allowlist since clipboard failures are intentionally silent. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent 28d24c5 commit 512a0a6

3 files changed

Lines changed: 113 additions & 20 deletions

File tree

src/components/members/invite-form.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { useState } from "react";
4-
import { Send } from "lucide-react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { Check, Copy, Send } from "lucide-react";
55
import { createClient } from "@/lib/supabase/client";
66
import { Button } from "@/components/ui/button";
77
import { Input } from "@/components/ui/input";
@@ -33,11 +33,32 @@ export function InviteForm({
3333
const [email, setEmail] = useState("");
3434
const [role, setRole] = useState<InviteRole>("member");
3535
const [sending, setSending] = useState(false);
36-
const [success, setSuccess] = useState(false);
36+
const [inviteLink, setInviteLink] = useState<string | null>(null);
37+
const [linkCopied, setLinkCopied] = useState(false);
38+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
39+
40+
useEffect(() => {
41+
return () => {
42+
if (timerRef.current) clearTimeout(timerRef.current);
43+
};
44+
}, []);
45+
46+
const handleCopyLink = useCallback(async () => {
47+
if (!inviteLink) return;
48+
try {
49+
await navigator.clipboard.writeText(inviteLink);
50+
setLinkCopied(true);
51+
if (timerRef.current) clearTimeout(timerRef.current);
52+
timerRef.current = setTimeout(() => setLinkCopied(false), 2000);
53+
} catch {
54+
// Clipboard access denied — button stays in default state
55+
}
56+
}, [inviteLink]);
3757

3858
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
3959
e.preventDefault();
40-
setSuccess(false);
60+
setInviteLink(null);
61+
setLinkCopied(false);
4162
onError("");
4263

4364
const trimmedEmail = email.trim().toLowerCase();
@@ -112,8 +133,9 @@ export function InviteForm({
112133
return;
113134
}
114135

136+
const link = `${window.location.origin}/invite/${token}`;
115137
setSending(false);
116-
setSuccess(true);
138+
setInviteLink(link);
117139
setEmail("");
118140
setRole("member");
119141
onInviteSent();
@@ -134,7 +156,7 @@ export function InviteForm({
134156
value={email}
135157
onChange={(e) => {
136158
setEmail(e.target.value);
137-
setSuccess(false);
159+
setInviteLink(null);
138160
}}
139161
required
140162
/>
@@ -159,8 +181,24 @@ export function InviteForm({
159181
{sending ? "Sending…" : "Invite"}
160182
</Button>
161183
</form>
162-
{success && (
163-
<p className="text-xs text-accent">Invite sent.</p>
184+
{inviteLink && (
185+
<div className="flex items-center gap-2">
186+
<p className="min-w-0 flex-1 truncate text-xs text-accent">
187+
{inviteLink}
188+
</p>
189+
<Button
190+
variant="ghost"
191+
size="icon-sm"
192+
onClick={handleCopyLink}
193+
aria-label="Copy invite link"
194+
>
195+
{linkCopied ? (
196+
<Check className="h-4 w-4 text-accent" />
197+
) : (
198+
<Copy className="h-4 w-4 text-muted-foreground" />
199+
)}
200+
</Button>
201+
</div>
164202
)}
165203
</div>
166204
);

src/components/members/pending-invite-list.tsx

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { useState } from "react";
4-
import { X } from "lucide-react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { Check, Copy, X } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { Badge } from "@/components/ui/badge";
77
import {
@@ -12,13 +12,63 @@ import {
1212
TableHeader,
1313
TableRow,
1414
} from "@/components/ui/table";
15+
import {
16+
Tooltip,
17+
TooltipContent,
18+
TooltipTrigger,
19+
} from "@/components/ui/tooltip";
1520
import type { WorkspaceInviteWithInviter } from "@/lib/types";
1621

1722
interface PendingInviteListProps {
1823
invites: WorkspaceInviteWithInviter[];
1924
onRevoke: (inviteId: string) => Promise<void>;
2025
}
2126

27+
function CopyLinkButton({ token }: { token: string }) {
28+
const [copied, setCopied] = useState(false);
29+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30+
31+
const handleCopy = useCallback(async () => {
32+
const url = `${window.location.origin}/invite/${token}`;
33+
try {
34+
await navigator.clipboard.writeText(url);
35+
setCopied(true);
36+
if (timerRef.current) clearTimeout(timerRef.current);
37+
timerRef.current = setTimeout(() => setCopied(false), 2000);
38+
} catch {
39+
// Clipboard access denied — no-op
40+
}
41+
}, [token]);
42+
43+
useEffect(() => {
44+
return () => {
45+
if (timerRef.current) clearTimeout(timerRef.current);
46+
};
47+
}, []);
48+
49+
return (
50+
<Tooltip>
51+
<TooltipTrigger
52+
render={
53+
<Button
54+
variant="ghost"
55+
size="icon-sm"
56+
onClick={handleCopy}
57+
aria-label="Copy invite link"
58+
/>
59+
}
60+
>
61+
{copied ? (
62+
<Check className="h-4 w-4 text-accent" />
63+
) : (
64+
<Copy className="h-4 w-4 text-muted-foreground" />
65+
)}
66+
</TooltipTrigger>
67+
<TooltipContent>{copied ? "Copied!" : "Copy invite link"}</TooltipContent>
68+
</Tooltip>
69+
);
70+
}
71+
2272
export function PendingInviteList({
2373
invites,
2474
onRevoke,
@@ -46,7 +96,7 @@ export function PendingInviteList({
4696
<TableHead>Email</TableHead>
4797
<TableHead>Role</TableHead>
4898
<TableHead>Status</TableHead>
49-
<TableHead className="w-10" />
99+
<TableHead className="w-20" />
50100
</TableRow>
51101
</TableHeader>
52102
<TableBody>
@@ -69,15 +119,18 @@ export function PendingInviteList({
69119
)}
70120
</TableCell>
71121
<TableCell>
72-
<Button
73-
variant="ghost"
74-
size="icon-sm"
75-
onClick={() => handleRevoke(invite.id)}
76-
disabled={revokingId === invite.id}
77-
aria-label={`Revoke invite for ${invite.email}`}
78-
>
79-
<X className="h-4 w-4 text-muted-foreground" />
80-
</Button>
122+
<div className="flex items-center gap-1">
123+
<CopyLinkButton token={invite.token} />
124+
<Button
125+
variant="ghost"
126+
size="icon-sm"
127+
onClick={() => handleRevoke(invite.id)}
128+
disabled={revokingId === invite.id}
129+
aria-label={`Revoke invite for ${invite.email}`}
130+
>
131+
<X className="h-4 w-4 text-muted-foreground" />
132+
</Button>
133+
</div>
81134
</TableCell>
82135
</TableRow>
83136
))}

src/lib/sentry.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const BARE_CATCH_PERMANENT_ALLOWLIST = new Set([
1717
"src/lib/supabase/server.ts", // cookie setAll in Server Components
1818
"src/components/editor/editor.tsx", // URL validation (new URL() throws)
1919
"src/app/api/health/route.ts", // intentionally silent, monitored externally
20+
"src/components/members/invite-form.tsx", // clipboard writeText — intentionally silent on permission denied
21+
"src/components/members/pending-invite-list.tsx", // clipboard writeText — intentionally silent on permission denied
2022
]);
2123

2224
/**

0 commit comments

Comments
 (0)