'use client'; // ============================================================ // InviteMemberDialog // // Two-step modal: // 1. Form — role + expiry + optional label → POST creates the invite. // 2. Result — the share URL, returned ONCE. Copy-to-clipboard, plus a // "Send via WhatsApp" deep link that pre-fills wa.me with // a friendly message containing the URL. // // The plaintext token is server-stored only as a SHA-256 hash, so once // the result step is dismissed the link is gone forever — the dialog // shouts this in copy. // ============================================================ import { useState } from 'react'; import { toast } from 'sonner'; import { Copy, Loader2, MessageCircle, Sparkles } from 'lucide-react'; import { Button, buttonVariants } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useAuth } from '@/hooks/use-auth'; type InviteRole = 'admin' | 'agent' | 'viewer'; interface InviteMemberDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Called after a successful create so the parent re-fetches the * pending-invitations list. */ onCreated: () => void; } const EXPIRY_OPTIONS: { value: string; label: string }[] = [ { value: '1', label: '1 day' }, { value: '7', label: '7 days' }, { value: '30', label: '30 days' }, ]; const ROLE_DESCRIPTIONS: Record = { admin: 'Can invite teammates, manage settings, send messages, and edit data.', agent: 'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.', viewer: 'Read-only access across every page. Cannot send or edit anything.', }; // Server caps label at 80 chars (see src/app/api/account/invitations/route.ts). // Mirror it on the client so we short-circuit before the round-trip // rather than letting the user submit and bounce off a 400. const MAX_LABEL_LEN = 80; interface CreatedInvite { url: string; role: InviteRole; expiresInDays: number; /** Snapshotted at creation time so a later account rename can't * retroactively change the wa.me message text on the result step. */ accountName: string; } export function InviteMemberDialog({ open, onOpenChange, onCreated, }: InviteMemberDialogProps) { const { account } = useAuth(); const [role, setRole] = useState('agent'); const [expiry, setExpiry] = useState('7'); const [label, setLabel] = useState(''); const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); function reset() { setRole('agent'); setExpiry('7'); setLabel(''); setResult(null); setSubmitting(false); } async function handleCreate() { // Mirror the server's max-length check so we don't ship an // obviously-too-long label across the wire just to bounce off // a 400. The Input also has a `maxLength={MAX_LABEL_LEN}` cap // but a paste can land an over-limit string into state before // the limit kicks in on the next keystroke — this is the safety // net for that path. const trimmedLabel = label.trim(); if (trimmedLabel.length > MAX_LABEL_LEN) { toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`); return; } setSubmitting(true); try { const res = await fetch('/api/account/invitations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, expiresInDays: Number(expiry), label: trimmedLabel || undefined, }), }); if (!res.ok) { const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to create invitation'); return; } const data = (await res.json()) as { url: string; expiresInDays: number; }; setResult({ url: data.url, role, expiresInDays: data.expiresInDays, // Snapshot the account name into the result so the wa.me // share message has team context. Falls back to a generic // string if `account` hasn't loaded yet (shouldn't happen // — the dialog requires admin+ which requires a loaded // profile — but stay safe). accountName: account?.name ?? 'our wacrm account', }); onCreated(); } catch (err) { console.error('[InviteMemberDialog] create error:', err); toast.error('Could not reach the server. Try again?'); } finally { setSubmitting(false); } } async function copyToClipboard() { if (!result) return; try { await navigator.clipboard.writeText(result.url); toast.success('Invite link copied'); } catch { // Most likely "not in a secure context" — happens on http:// // local IPs. Surface the link in the toast so the admin can // hand-copy it. toast.error('Clipboard blocked — copy the link manually'); } } function whatsappShareUrl(url: string): string { // Include the account name so the recipient knows which team // they're being invited to before clicking through. This matters // for users in multi-team contexts where "our wacrm account" // wouldn't be enough to disambiguate. const accountName = result?.accountName ?? 'our wacrm account'; const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`; return `https://wa.me/?text=${encodeURIComponent(message)}`; } return ( { // Reset state when the dialog closes — both for cancel and // for dismissal after a successful create. The plaintext URL // is intentionally NOT preserved across opens. if (!next) reset(); onOpenChange(next); }} > {result ? ( <> Invite created Share this link with your new teammate. They'll be able to sign up (or sign in) and join the account as{' '} {result.role} . The link is valid for{' '} {result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'} .
e.currentTarget.select()} />
{/* Higher-contrast amber than the original 10% / amber-200. Reviewed against slate-900 to meet WCAG AAA for body text (target ratio 7:1). Border bumped to /50, bg to /15, foreground promoted to amber-100 for the strong intro, amber-200 for the body. */}
Save this link now. {' '} We never store the plaintext — once you close this dialog the URL is gone. To re-share, revoke this invite and create a new one.
{/* Anchor styled with `buttonVariants` rather than wrapping in
) : ( <> Invite a teammate Generate a one-time invite link. Share it via WhatsApp, Slack, or any channel you like — no email service required.

{ROLE_DESCRIPTIONS[role]}

setLabel(e.target.value)} maxLength={MAX_LABEL_LEN} className="bg-muted border-border text-foreground placeholder:text-muted-foreground" />

Helps you remember who you sent the link to in the pending list below.

)}
); }