'use client' import { useState, useEffect, useCallback } from 'react' import { useLocale, useTranslations } from 'next-intl' import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' import { SettingsGroup, SettingsInput, SettingsRowNote, SettingsSelect, } from '@/components/settings/SettingsRows' import { parseCompanyMembersPayload } from '@/components/settings/members-payload' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { formatDateLong } from '@/lib/utils' import { Loader2, Plus, Trash2, Mail } from 'lucide-react' interface CompanyMemberItem { id: string user_id: string email: string role: string source: 'direct' | 'team' joined_at: string is_current_user: boolean } interface CompanyInvitation { id: string email: string role: string status: string expires_at: string created_at: string } export function CompanyMembersSection() { const t = useTranslations('settings_company') const errorLocale = useLocale() as ErrorLocale const { toast } = useToast() const { company } = useCompany() const roleLabels: Record = { owner: t('members_role_owner'), admin: t('members_role_admin'), member: t('members_role_member'), viewer: t('members_role_viewer'), } // null = the roster is not known: still loading, or the read failed // (loadError). A failed read must never render an apparently member-less // company: a consultant looking at an empty list re-invites people who are // already members. EmptyState-like rendering is reserved for a confirmed // empty read. const [members, setMembers] = useState(null) const [invitations, setInvitations] = useState(null) // detail === null: transient, so the line carries a retry. A detail sentence // means the user has to act (an expired session) and a retry cannot help. const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null) const [reloadKey, setReloadKey] = useState(0) const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('viewer') const [isSending, setIsSending] = useState(false) const [removingId, setRemovingId] = useState(null) const [revokingId, setRevokingId] = useState(null) const [canInvite, setCanInvite] = useState(false) const fetchMembers = useCallback(async () => { setLoadError(null) try { const res = await fetch('/api/company/members') if (!res.ok) { // Not-JSON bodies (an HTML error page, an empty 502) leave null, and // getErrorMessage falls back to the status map. const body = await res.json().catch(() => null) const sessionGone = res.status === 401 || res.status === 403 setMembers(null) setInvitations(null) setCanInvite(false) setLoadError({ detail: sessionGone ? getErrorMessage(body, { statusCode: res.status, locale: errorLocale }) : null, }) return } // A 200 whose body will not parse throws into the catch below; a 200 // without the roster lists is a failed read too. Neither may become a // fabricated empty member list. const parsed = parseCompanyMembersPayload( await res.json(), ) if (parsed === null) { setMembers(null) setInvitations(null) setCanInvite(false) setLoadError({ detail: null }) return } setMembers(parsed.members) setInvitations(parsed.invitations) setCanInvite(parsed.canInvite) } catch { setMembers(null) setInvitations(null) setCanInvite(false) setLoadError({ detail: null }) } }, [errorLocale]) useEffect(() => { void fetchMembers() }, [fetchMembers, reloadKey]) const handleInvite = async (e: React.FormEvent) => { e.preventDefault() const email = inviteEmail.trim().toLowerCase() if (!email) return setIsSending(true) try { const res = await fetch('/api/company/members/invite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, role: inviteRole }), }) const data = await res.json() if (!res.ok) { toast({ title: data.error, variant: 'destructive' }) return } if (data.data.inviteUrl) { console.log('[DEV] Company invite URL:', data.data.inviteUrl) } toast({ title: t('members_invite_sent_title'), description: data.data.inviteUrl ? t('members_invite_sent_dev_url') : t('members_invite_sent_description', { email }), }) setInviteEmail('') setInviteRole('viewer') fetchMembers() } catch { toast({ title: t('members_invite_failed'), variant: 'destructive' }) } finally { setIsSending(false) } } const handleRemoveMember = async (memberId: string) => { setRemovingId(memberId) try { const res = await fetch(`/api/company/members/${memberId}`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { toast({ title: data.error, variant: 'destructive' }) return } toast({ title: t('members_removed') }) fetchMembers() } catch { toast({ title: t('members_remove_failed'), variant: 'destructive' }) } finally { setRemovingId(null) } } const handleRevokeInvite = async (inviteId: string) => { setRevokingId(inviteId) try { const res = await fetch(`/api/company/members/invite/${inviteId}`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { toast({ title: data.error, variant: 'destructive' }) return } toast({ title: t('members_invite_revoked') }) fetchMembers() } catch { toast({ title: t('members_invite_revoke_failed'), variant: 'destructive' }) } finally { setRevokingId(null) } } if (members === null || invitations === null) { return (
{/* Live region always mounted while the roster is unknown, so the failure is announced when it appears, not merely inserted. */}
{loadError && ( setReloadKey((k) => k + 1) } } > {loadError.detail ? `${t('members_load_failed')} ${loadError.detail}` : t('members_load_failed')} )}
{!loadError && (
)}
) } return ( {/* Member rows: flat hairline list, no cards. */} {members.map((member) => (
{member.email.charAt(0).toUpperCase()}

{member.email} {member.is_current_user && ( {t('members_you')} )}

{roleLabels[member.role] || member.role} {member.source === 'team' && <> · {t('members_team_badge')}} {canInvite && !member.is_current_user && member.role !== 'owner' && member.source !== 'team' && ( )}
))} {/* Pending invitations continue the same list, visually quieter. */} {invitations.map((inv) => (

{inv.email} · {t('invitations_expires', { date: formatDateLong(inv.expires_at) })}

{roleLabels[inv.role] || inv.role} {canInvite && ( )}
))} {/* Inline invite: the list's own last row instead of a separate card. */} {canInvite && (
setInviteEmail(e.target.value)} disabled={isSending} required className="border-border sm:flex-1" /> setInviteRole(e.target.value)} aria-label={t('members_role_label')} > )}

{t('members_count', { count: members.length, companyName: company?.name ?? '' })}

) }