'use client' import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' 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 } /** * The shareable accept link from the latest invite response. Raw tokens are * never stored server-side (only their hash), so the link exists exactly * once: here, until the next navigation. Kept visible so a failed or absent * mail send (self-hosted without a mail provider, #1710) never dead-ends the * inviter. There is no re-send for company invites: revoke and re-invite. * provisioned = GoTrue created the account and sent its own invite mail * (AUTH_SIGNUPS_DISABLED path), so email_sent=false is not a failure there. */ interface ShareableInvite { email: string url: string sent: boolean provisioned: boolean } 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) // Multi-user seat gate: true when inviting requires the paid plan // (multi_user frozen). The form is swapped for the upsell line; the POST's // own 403 stays the real enforcement. const [inviteRequiresUpgrade, setInviteRequiresUpgrade] = useState(false) const [shareInvite, setShareInvite] = useState(null) 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) setInviteRequiresUpgrade(parsed.inviteRequiresUpgrade) } 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 } const payload = ( data as { data?: { email_sent?: boolean; user_provisioned?: boolean; inviteUrl?: string } } | null )?.data const sent = payload?.email_sent !== false const provisioned = payload?.user_provisioned === true // Persist the shareable link next to the pending list: a failed or // skipped send leaves the invitation valid, and the toast alone is too // easy to miss, so the recovery path stays visible on the page. if (payload?.inviteUrl) { setShareInvite({ email, url: payload.inviteUrl, sent, provisioned }) } // The title must not claim a send that never happened (self-hosted // without a mail provider): 'created' when nothing was mailed. toast({ title: sent || provisioned ? t('members_invite_sent_title') : t('members_invite_created_title'), description: sent || provisioned ? t('members_invite_sent_description', { email }) : t('members_invite_mail_not_sent'), }) setInviteEmail('') setInviteRole('viewer') fetchMembers() } catch { toast({ title: t('members_invite_failed'), variant: 'destructive' }) } finally { setIsSending(false) } } const handleCopyInviteLink = async (url: string) => { try { await navigator.clipboard.writeText(url) toast({ title: t('members_invite_link_copied_toast') }) } catch { toast({ title: t('members_invite_link_copy_failed'), variant: 'destructive' }) } } 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 (invite: CompanyInvitation) => { setRevokingId(invite.id) try { const res = await fetch(`/api/company/members/invite/${invite.id}`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { toast({ title: data.error, variant: 'destructive' }) return } // A revoked invitation's link is dead: never keep offering it. setShareInvite((current) => (current?.email === invite.email ? null : current)) 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 && ( )}
))} {/* Shareable accept link from the latest invite: one sentence, not a banner. A send that failed or was skipped (no mail provider) is the page's single attn line; a sent or GoTrue-provisioned invite is a quiet muted line with the same copy action, since the link is a legitimate share path either way. The live region stays mounted (empty until a link exists) so the line is announced when it appears, not merely inserted; same reason as the roster block. */} {canInvite && (
{shareInvite && (shareInvite.sent || shareInvite.provisioned ? (

{t('members_invite_link_sent', { email: shareInvite.email })}{' '}

) : ( void handleCopyInviteLink(shareInvite.url), }} > {t('members_invite_link_not_sent', { email: shareInvite.email })} ))}
)} {/* Multi-user seat gate: no invite form without the paid plan. One muted sentence with the billing link, not a dead form. */} {canInvite && inviteRequiresUpgrade && (

{t('members_invite_upgrade')}{' '} {t('members_invite_upgrade_cta')}

)} {/* Inline invite: the list's own last row instead of a separate card. */} {canInvite && !inviteRequiresUpgrade && (
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 ?? '' })}

) }