// V2InviteModal — shared invite UI mounted at V2Layout level so both the // thread starter invite action (V2Thread) and the inspector "+ Invite" button // can open the same modal. Two tabs: shareable links (people) and an agent // install shortcut (browse → install). import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useV2Api } from '../hooks/useV2Api'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; interface V2InviteModalProps { open: boolean; podId: string; podName: string; initialTab?: V2InviteTab; onClose: () => void; } interface ManagedInvite { token: string; createdBy?: string | { _id?: string; username?: string }; createdAt: string; expiresAt?: string | null; maxUses?: number | null; uses: number; } type ExpiryPreset = 'never' | '24' | '168' | '720'; type MaxUsesPreset = 'unlimited' | '1' | '10' | '25'; export type V2InviteTab = 'people' | 'agent'; const CLOSE_MARK = '×'; const inviteUrlFor = (token: string): string => ( `${window.location.origin}/v2/invite/${token}` ); const creatorName = (invite: ManagedInvite, t: TFunction): string => ( typeof invite.createdBy === 'object' && invite.createdBy?.username ? invite.createdBy.username : t('inviteModal.podMember') ); const relativeCreatedAt = (value: string, t: TFunction, locale: string): string => { const created = new Date(value).getTime(); const elapsed = Date.now() - created; if (!Number.isFinite(created) || elapsed < 0) return t('inviteModal.time.recently'); const minutes = Math.floor(elapsed / 60_000); if (minutes < 1) return t('inviteModal.time.justNow'); const format = (count: number) => new Intl.NumberFormat(locale).format(count); if (minutes < 60) return t('inviteModal.time.minutesAgo', { count: minutes, formattedCount: format(minutes) }); const hours = Math.floor(minutes / 60); if (hours < 24) return t('inviteModal.time.hoursAgo', { count: hours, formattedCount: format(hours) }); const days = Math.floor(hours / 24); if (days < 7) return t('inviteModal.time.daysAgo', { count: days, formattedCount: format(days) }); return new Date(value).toLocaleDateString(locale); }; const expiryLabel = (value: string | null | undefined, t: TFunction, locale: string): string => { if (!value) return t('inviteModal.expiry.neverExpires'); const expiry = new Date(value); if (!Number.isFinite(expiry.getTime())) return t('inviteModal.expiry.unavailable'); return t('inviteModal.expiry.expiresOn', { date: expiry.toLocaleDateString(locale) }); }; const V2InviteModal: React.FC = ({ open, podId, podName, initialTab = 'people', onClose, }) => { const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage || i18n.language || 'en'; const numberFormatter = new Intl.NumberFormat(locale); const api = useV2Api(); const navigate = useNavigate(); const [tab, setTab] = useState(initialTab); const [url, setUrl] = useState(''); const [expiry, setExpiry] = useState('168'); const [maxUses, setMaxUses] = useState('unlimited'); const [invites, setInvites] = useState([]); const [busy, setBusy] = useState(false); const [listLoading, setListLoading] = useState(false); const [revokingToken, setRevokingToken] = useState(null); const [error, setError] = useState(null); const [listError, setListError] = useState(null); const [copiedToken, setCopiedToken] = useState(null); const loadInvites = useCallback(async () => { if (!podId) return; setListLoading(true); setListError(null); try { const data = await api.get(`/api/pods/${podId}/invites`); setInvites(Array.isArray(data) ? data : []); } catch (err) { const e = err as { response?: { data?: { msg?: string } }; message?: string }; setListError(e.response?.data?.msg || e.message || t('inviteModal.errors.loadFailed')); } finally { setListLoading(false); } }, [api, podId, t]); // Reset transient state when the modal closes or switches pods — otherwise // an old invite URL can flash before the next pod's links load. useEffect(() => { setTab(initialTab); setUrl(''); setExpiry('168'); setMaxUses('unlimited'); setError(null); setListError(null); setCopiedToken(null); setBusy(false); setRevokingToken(null); if (!open) setInvites([]); }, [open, podId, initialTab]); useEffect(() => { if (!open || !podId) return; void loadInvites(); }, [open, podId, loadInvites]); const handleGenerate = useCallback(async () => { if (!podId) return; setBusy(true); setError(null); setCopiedToken(null); try { const expiresInHours = expiry === 'never' ? null : Number(expiry); const useLimit = maxUses === 'unlimited' ? null : Number(maxUses); const data = await api.post<{ token: string }>(`/api/pods/${podId}/invites`, { expiresInHours, maxUses: useLimit, }); setUrl(inviteUrlFor(data.token)); await loadInvites(); } catch (err) { const e = err as { response?: { data?: { msg?: string } }; message?: string }; setError(e.response?.data?.msg || e.message || t('inviteModal.errors.generateFailed')); } finally { setBusy(false); } }, [api, expiry, loadInvites, maxUses, podId, t]); const handleCopy = useCallback(async (link: string, token: string) => { if (!link) return; try { await navigator.clipboard.writeText(link); setCopiedToken(token); setTimeout(() => setCopiedToken((current) => (current === token ? null : current)), 1500); } catch { // The generated URL and managed-link inputs stay selectable when the // Clipboard API is unavailable, so manual copy remains possible. } }, []); const handleRevoke = useCallback(async (token: string) => { setRevokingToken(token); setListError(null); try { await api.del(`/api/invites/${encodeURIComponent(token)}`); setInvites((current) => current.filter((invite) => invite.token !== token)); if (url === inviteUrlFor(token)) setUrl(''); } catch (err) { const e = err as { response?: { data?: { msg?: string } }; message?: string }; setListError(e.response?.data?.msg || e.message || t('inviteModal.errors.revokeFailed')); } finally { setRevokingToken(null); } }, [api, t, url]); if (!open) return null; return (
e.stopPropagation()}>
{t('inviteModal.title', { podName })}
{tab === 'people' && ( <>

{t('inviteModal.peopleHint')}

{url && ( <>
e.currentTarget.select()} />
)} {error &&
{error}
}
{listLoading &&
{t('inviteModal.loadingLinks')}
} {!listLoading && invites.length === 0 && !listError && (
{t('inviteModal.noActiveLinks')}
)} {!listLoading && invites.length > 0 && (
{invites.map((invite) => { const link = inviteUrlFor(invite.token); const creator = creatorName(invite, t); return (
{creator} {relativeCreatedAt(invite.createdAt, t, locale)}
{invite.maxUses == null ? t('inviteModal.usage.unlimited', { count: invite.uses, formattedCount: numberFormatter.format(invite.uses), }) : t('inviteModal.usage.limited', { uses: numberFormatter.format(invite.uses), maxUses: numberFormatter.format(invite.maxUses), })} {expiryLabel(invite.expiresAt, t, locale)}
event.currentTarget.select()} />
); })}
)} {listError &&
{listError}
}
)} {tab === 'agent' && ( <> {/* Note `?pod=` — V2AgentBYO reads `pod`, not `podId`. */}

{t('inviteModal.agentHint')}

)}
); }; export default V2InviteModal;