'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 && (
{
setInviteOpen(true);
setLastInviteInfo(null);
}}
disabled={atSeatLimit}
className="shrink-0"
>
Invite member
)}
{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 && (
{
setLastInviteInfo(null);
}}
>
{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}
{
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true);
setTimeout(() => setCopiedInviteLink(false), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedInviteLink ? : }
{copiedInviteLink ? 'Copied' : 'Copy link'}
Share this link manually via SMS or email. They must set password first.
)}
)}
{loading ? (
Loading team…
) : (
Name
Email
Role
Status
Access
{canEdit && Actions }
{members.map((m) => (
{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)}
)}
{canEdit && (
{!m.isOwner && (
openEdit(m)}
>
void removeMember(m)}
>
)}
)}
))}
)}
{inviteOpen && (
)}
{editing && (
Edit member
{editing.email}
setEditName(e.target.value)} />
setEditing(null)}>
Cancel
void submitEdit()}>
Save
)}
);
}