'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { firstAccessibleDashboardPath, canEditStaff, canViewStaff, } from '@/shared/permissions'; import { STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, formatAccessSummary, type FeaturePermState, } from './staff-permission-form'; import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; import { Checkbox } from '@/components/ui/common/Checkbox'; import type { ApiError } from '@/types/api'; function formatApiMessage(err: unknown): string { if (!err || typeof err !== 'object') return 'Something went wrong'; const m = (err as ApiError).message; if (Array.isArray(m)) return m.join(', '); if (typeof m === 'string') return m; return 'Something went wrong'; } function PermissionGrid({ state, onChange, disabled, }: { state: FeaturePermState; onChange: (next: FeaturePermState) => void; disabled?: boolean; }) { const setRead = (editKey: string, read: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ ...state, [editKey]: { read, edit: read ? cur.edit : false }, }); }; const setEdit = (editKey: string, edit: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ ...state, [editKey]: { read: edit || cur.read, edit }, }); }; return (
{STAFF_FEATURE_GROUPS.map((g) => { const cell = state[g.edit] ?? { read: false, edit: false }; return (
{g.label}
setRead(g.edit, v)} /> setEdit(g.edit, v)} />
); })}
); } export default function StaffPage() { const router = useRouter(); const { currentOrganization, user } = useAuth(); const [members, setMembers] = useState([]); const [seats, setSeats] = useState<{ used: number; limit: number | null; unlimited: boolean; } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [inviteOpen, setInviteOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteName, setInviteName] = useState(''); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [inviteLoading, setInviteLoading] = useState(false); const [copiedInviteLink, setCopiedInviteLink] = useState(false); const [lastInviteInfo, setLastInviteInfo] = useState<{ name: string; email: string; invitationUrl: string | null; invitationStatus: 'PENDING' | 'ACCEPTED'; } | null>(null); const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(''); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editLoading, setEditLoading] = useState(false); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; if (seats.limit == null) return false; return seats.used >= seats.limit; }, [seats]); const load = useCallback(async () => { setError(''); setLoading(true); try { const res = await staffApi.list(); setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { setError(formatApiMessage(e)); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!currentOrganization) return; if (!canViewStaff(currentOrganization)) { router.replace(firstAccessibleDashboardPath(currentOrganization)); } }, [currentOrganization, router]); useEffect(() => { if (!success) return; const t = setTimeout(() => setSuccess(''), 4000); return () => clearTimeout(t); }, [success]); async function submitInvite() { setInviteLoading(true); setError(''); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); try { const permissionNames = permissionNamesFromFeatureState(invitePerms); const res = await staffApi.invite({ email: displayEmail, name: displayName, permissionNames, }); setLastInviteInfo({ name: displayName, email: res.data.email, invitationUrl: res.data.invitationUrl, invitationStatus: res.data.invitationStatus, }); setSuccess(''); setInviteOpen(false); setInviteEmail(''); setInviteName(''); setInvitePerms(emptyFeaturePermissionState()); await load(); } catch (e) { setError(formatApiMessage(e)); } finally { setInviteLoading(false); } } function openEdit(m: StaffMemberDto) { if (m.isOwner) return; setEditing(m); setEditName(m.name); setEditPerms( featureStateFromPermissionNames(m.permissions ?? []), ); } async function submitEdit() { if (!editing) return; setEditLoading(true); setError(''); try { await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); setSuccess('Member updated'); setEditing(null); await load(); } catch (e) { setError(formatApiMessage(e)); } finally { setEditLoading(false); } } async function removeMember(m: StaffMemberDto) { if (m.isOwner) return; if (m.userId === user?.id) { if (!confirm('Remove yourself from this organization? You will lose access.')) return; } else { if (!confirm(`Remove ${m.name} from this organization?`)) return; } setError(''); try { await staffApi.removeMember(m.id); setSuccess('Member removed'); await load(); } catch (e) { setError(formatApiMessage(e)); } } if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

Redirecting…

); } return (

Staff Management

Invite teammates, set tab access, and stay within your plan seat limit.

{canEdit && ( )}
{seats && (

Seats:{' '} {seats.used} {seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`} {!seats.unlimited && atSeatLimit && ( Limit reached — remove a member or upgrade your plan. )}

)} {error && (
{error}
)} {success && (
{success}
)} {lastInviteInfo && (

{lastInviteInfo.name} ({lastInviteInfo.email}) was invited. {lastInviteInfo.invitationStatus === 'PENDING' ? ' Invitation is pending until they open the link, set a password, and log in.' : ' Invitation was accepted immediately.'}

{lastInviteInfo.invitationUrl && (

Invite link

{lastInviteInfo.invitationUrl}

Share this link manually via SMS or email. They must set password first.

)}
)} {loading ? (

Loading team…

) : (
{canEdit && } {members.map((m) => ( {canEdit && ( )} ))}
Name Email Role Status AccessActions
{m.name} {m.email} {m.isOwner ? ( Owner ) : ( Staff )} {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( Active ) : m.invitationStatus === 'PENDING' ? ( Pending ) : ( Expired )} {m.isOwner ? ( All features ) : ( {formatAccessSummary(m.permissions)} )} {!m.isOwner && (
)}
)} {inviteOpen && (

Invite team member

setInviteEmail(e.target.value)} autoComplete="off" /> setInviteName(e.target.value)} />

Tab access

)} {editing && (

Edit member

{editing.email}

setEditName(e.target.value)} />

Tab access

)}
); }