// Copyright: © 2026 TWWIM UG. All rights reserved. (www.twwim.com) /** * MembersPage — ADMIN-only dashboard view for managing company members. * * Orchestrator only: composes InviteMemberForm + ActiveMembersList + * PendingInvitationsList, owns the data hook + action handlers, and renders * inline error/info banners. Role gating is enforced by the route's * beforeLoad zone guard + server-side requireCompanyAdmin middleware. */ import { useEffect, useState, useCallback } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { Users } from 'lucide-react'; import { PageLayout } from '@/components/shared'; import { membersApi, type MemberRole } from '@/infrastructure/http/api/members/api'; import { useAuthenticatedUser } from '@/features/auth/hooks/useAuthenticatedUser'; import { useConfigurableCaps } from '@/features/subscription/hooks/useConfigurableCaps'; import { useTranslation } from '@/i18n/TranslationProvider'; import type { CompanyMemberResponse, MemberInvitationResponse } from '@archer/api-interface'; import { InviteMemberForm } from './components/InviteMemberForm'; import { ActiveMembersList } from './components/ActiveMembersList'; import { PendingInvitationsList } from './components/PendingInvitationsList'; export function MembersPage() { const { t } = useTranslation(); const navigate = useNavigate(); const user = useAuthenticatedUser(); const companyId = user?.companyId ?? ''; const inviterAuthId = user?.id ?? ''; const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionError, setActionError] = useState(null); const [actionInfo, setActionInfo] = useState(null); // Seat-cost disclosure: shown only when MEMBERS_MAX is a paid, configurable cap. const { data: caps } = useConfigurableCaps(); const seatCap = caps?.items.find((item) => item.capabilityKey === 'members_max'); // Team is a gated plan feature: no MEMBERS_MAX cap on the plan → team disabled. const teamDisabled = caps !== undefined && !seatCap; const seatNotice = seatCap && seatCap.unitPrice != null ? t('members.invite.seatNotice', { used: members.length, limit: seatCap.currentTotal, price: `${seatCap.unitPrice} ${caps?.currency ?? ''}`.trim(), }) : null; const refresh = useCallback(async () => { if (!companyId) return; setLoading(true); setError(null); try { const [m, inv] = await Promise.all([ membersApi.listMembers(companyId), membersApi.listInvitations(companyId), ]); setMembers(m.members ?? []); setInvitations(inv.invitations ?? []); } catch (err) { setError(err instanceof Error ? err.message : t('members.loadFailed')); } finally { setLoading(false); } }, [companyId, t]); useEffect(() => { void refresh(); }, [refresh]); const handleInvite = async (email: string, role: MemberRole) => { if (!companyId || !inviterAuthId) return; setActionError(null); setActionInfo(null); try { // Pay-first: when the company is at its paid-seat capacity, route to the // SAME checkout the commands purchase uses — carrying only the non-secret // descriptor (invitee/role/price). The checkout creates the PaymentIntent on // Pay and the webhook creates the invitation after payment. Otherwise (free // capacity) the invitation is created now. const occupied = members.length + invitations.length; const capacity = seatCap?.currentTotal ?? null; const unitPrice = seatCap?.unitPrice ?? null; if (capacity != null && unitPrice != null && occupied >= capacity) { navigate({ to: '/dashboard/checkout', search: { seatEmail: email, seatRole: role, seatAmount: unitPrice, seatCurrency: caps?.currency ?? 'EUR' }, }); return; } await membersApi.seatInvite(companyId, { email, role }); setActionInfo(t('members.invite.sent', { email })); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.invite.failed')); } }; const handleUpdateRole = async (member: CompanyMemberResponse, nextRole: MemberRole) => { setActionError(null); setActionInfo(null); try { await membersApi.updateMemberRole(companyId, member.id, nextRole); const name = `${member.profile.firstName} ${member.profile.lastName}`.trim(); setActionInfo(t('members.active.roleUpdated', { name })); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.active.roleUpdateFailed')); } }; const handleRemoveMember = async (member: CompanyMemberResponse) => { const name = `${member.profile.firstName} ${member.profile.lastName}`.trim(); if (!confirm(t('members.active.confirmRemove', { name }))) return; setActionError(null); setActionInfo(null); try { await membersApi.removeMember(companyId, member.id); setActionInfo(t('members.active.removed')); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.active.removeFailed')); } }; const handleTransferSeat = async (member: CompanyMemberResponse, toEmail: string) => { setActionError(null); setActionInfo(null); try { await membersApi.transferSeat(companyId, { fromMembershipId: member.id, toEmail, toRole: member.role as MemberRole, }); setActionInfo(t('members.active.transferred')); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.active.transferFailed')); } }; const handleRevokeInvitation = async (inv: MemberInvitationResponse) => { if (!confirm(t('members.pending.confirmRevoke', { email: inv.email }))) return; setActionError(null); setActionInfo(null); try { await membersApi.revokeInvitation(companyId, inv.id); setActionInfo(t('members.pending.revoked')); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.pending.revokeFailed')); } }; const handleResendInvitation = async (inv: MemberInvitationResponse) => { setActionError(null); setActionInfo(null); try { await membersApi.resendInvitation(companyId, inv.id); setActionInfo(t('members.pending.resent', { email: inv.email })); await refresh(); } catch (err) { setActionError(err instanceof Error ? err.message : t('members.pending.resendFailed')); } }; return ( } gradient >
{error && (
{error}
)} {actionError && (
{actionError}
)} {actionInfo && (
{actionInfo}
)} {teamDisabled ? (

{t('members.upgrade.title')}

{t('members.upgrade.body')}

{t('members.upgrade.cta')}
) : ( )}
); }