Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions src/components/members/invite-form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { Send } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy, Send } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -33,11 +33,32 @@ export function InviteForm({
const [email, setEmail] = useState("");
const [role, setRole] = useState<InviteRole>("member");
const [sending, setSending] = useState(false);
const [success, setSuccess] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);

const handleCopyLink = useCallback(async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink);
setLinkCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setLinkCopied(false), 2000);
} catch {
// Clipboard access denied — button stays in default state
}
}, [inviteLink]);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSuccess(false);
setInviteLink(null);
setLinkCopied(false);
onError("");

const trimmedEmail = email.trim().toLowerCase();
Expand Down Expand Up @@ -112,8 +133,9 @@ export function InviteForm({
return;
}

const link = `${window.location.origin}/invite/${token}`;
setSending(false);
setSuccess(true);
setInviteLink(link);
setEmail("");
setRole("member");
onInviteSent();
Expand All @@ -134,7 +156,7 @@ export function InviteForm({
value={email}
onChange={(e) => {
setEmail(e.target.value);
setSuccess(false);
setInviteLink(null);
}}
required
/>
Expand All @@ -159,8 +181,24 @@ export function InviteForm({
{sending ? "Sending…" : "Invite"}
</Button>
</form>
{success && (
<p className="text-xs text-accent">Invite sent.</p>
{inviteLink && (
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 truncate text-xs text-accent">
{inviteLink}
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopyLink}
aria-label="Copy invite link"
>
{linkCopied ? (
<Check className="h-4 w-4 text-accent" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
)}
</div>
);
Expand Down
77 changes: 65 additions & 12 deletions src/components/members/pending-invite-list.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Expand All @@ -12,13 +12,63 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorkspaceInviteWithInviter } from "@/lib/types";

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

function CopyLinkButton({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleCopy = useCallback(async () => {
const url = `${window.location.origin}/invite/${token}`;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access denied — no-op
}
}, [token]);

useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);

return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
aria-label="Copy invite link"
/>
}
>
{copied ? (
<Check className="h-4 w-4 text-accent" />
) : (
<Copy className="h-4 w-4 text-muted-foreground" />
)}
</TooltipTrigger>
<TooltipContent>{copied ? "Copied!" : "Copy invite link"}</TooltipContent>
</Tooltip>
);
}

export function PendingInviteList({
invites,
onRevoke,
Expand Down Expand Up @@ -46,7 +96,7 @@ export function PendingInviteList({
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -69,15 +119,18 @@ export function PendingInviteList({
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
aria-label={`Revoke invite for ${invite.email}`}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="flex items-center gap-1">
<CopyLinkButton token={invite.token} />
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
aria-label={`Revoke invite for ${invite.email}`}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const BARE_CATCH_PERMANENT_ALLOWLIST = new Set([
"src/lib/supabase/server.ts", // cookie setAll in Server Components
"src/components/editor/editor.tsx", // URL validation (new URL() throws)
"src/app/api/health/route.ts", // intentionally silent, monitored externally
"src/components/members/invite-form.tsx", // clipboard writeText — intentionally silent on permission denied
"src/components/members/pending-invite-list.tsx", // clipboard writeText — intentionally silent on permission denied
]);

/**
Expand Down
Loading