"use client"; import { ArrowDownIcon, ArrowUpIcon, BuildingIcon, CheckIcon, ChevronDownIcon, CreditCardIcon, EllipsisIcon, FilterIcon, KeyRoundIcon, LaptopIcon, LogInIcon, MailIcon, MenuIcon, ScrollTextIcon, SearchIcon, SendIcon, ShieldAlertIcon, ShieldCheckIcon, ShieldIcon, SmartphoneIcon, UserCogIcon, UserMinusIcon, UserPlusIcon, UserRoundCheckIcon, UserRoundXIcon, UsersIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { Menu, MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "@/components/ui/menu"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } export type AdminRole = "owner" | "admin" | "member" | "viewer" | "billing"; export type AdminStatus = "active" | "invited" | "suspended"; export interface AdminActivity { kind: "signin" | "role" | "key" | "join" | "invite" | "suspend" | "2fa"; text: string; when: string; } export interface AdminUser { id: string; name: string; email: string; role: AdminRole; status: AdminStatus; twoFactor: boolean; teams: string[]; minutesSinceActive: number | null; joined: string; location?: string; activity?: AdminActivity[]; } export interface AdminUsersProps { className?: string; orgName?: string; seats?: number; users?: AdminUser[]; currentUserId?: string; } const spring = { type: "spring", stiffness: 520, damping: 46, mass: 1 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale] duration-150 ease-out active:scale-[0.97]"; const ring = "outline-none focus-visible:ring-2 focus-visible:ring-ring"; const hit = "relative after:absolute after:-inset-3 after:content-['']"; const menuPop = "transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:duration-100"; const barSurface = "rounded-full bg-primary text-primary-foreground shadow-lg/15 dark:border dark:bg-popover dark:text-popover-foreground dark:shadow-lg/40"; const barButton = cn( "flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-3 outline-none hover:bg-primary-foreground/12 focus-visible:ring-2 focus-visible:ring-primary-foreground/40 data-popup-open:bg-primary-foreground/12 dark:data-popup-open:bg-accent dark:focus-visible:ring-ring dark:hover:bg-accent", press, ); export const adminRoles: { value: AdminRole; label: string; description: string; pill: string }[] = [ { value: "owner", label: "Owner", description: "Full control, including billing and deleting the org.", pill: "bg-violet-500/10 text-violet-700 dark:bg-violet-400/16 dark:text-violet-300" }, { value: "admin", label: "Admin", description: "Manage members, settings and integrations.", pill: "bg-sky-500/10 text-sky-700 dark:bg-sky-400/16 dark:text-sky-300" }, { value: "member", label: "Member", description: "Create and edit projects they're added to.", pill: "bg-muted text-foreground/80" }, { value: "viewer", label: "Viewer", description: "Read-only access. Free, doesn't use a seat.", pill: "bg-transparent text-muted-foreground ring-1 ring-border ring-inset" }, { value: "billing", label: "Billing", description: "Invoices and payment methods only.", pill: "bg-amber-500/12 text-amber-800 dark:bg-amber-400/16 dark:text-amber-300" }, ]; const statusMeta: Record = { active: { label: "Active", dot: "bg-success", text: "text-foreground" }, invited: { label: "Invited", dot: "bg-info", text: "text-info-foreground" }, suspended: { label: "Suspended", dot: "bg-destructive", text: "text-destructive-foreground" }, }; const avatarTones = [ "bg-orange-500/16 text-orange-800 dark:bg-orange-400/18 dark:text-orange-200", "bg-teal-500/16 text-teal-800 dark:bg-teal-400/18 dark:text-teal-200", "bg-violet-500/16 text-violet-800 dark:bg-violet-400/18 dark:text-violet-200", "bg-sky-500/16 text-sky-800 dark:bg-sky-400/18 dark:text-sky-200", "bg-rose-500/16 text-rose-800 dark:bg-rose-400/18 dark:text-rose-200", "bg-amber-500/20 text-amber-900 dark:bg-amber-400/18 dark:text-amber-200", "bg-emerald-500/16 text-emerald-800 dark:bg-emerald-400/18 dark:text-emerald-200", ]; const defaultUsers: AdminUser[] = [ { id: "u1", name: "Maya Lindgren", email: "maya@larkfield.studio", role: "owner", status: "active", twoFactor: true, teams: ["Leadership", "Design"], minutesSinceActive: 3, joined: "Mar 2, 2024", location: "Stockholm, SE", }, { id: "u2", name: "Owen Adeyemi", email: "owen@larkfield.studio", role: "admin", status: "active", twoFactor: true, teams: ["Engineering", "Platform"], minutesSinceActive: 42, joined: "Mar 9, 2024", location: "Lagos, NG", activity: [ { kind: "signin", text: "Signed in from Lagos, NG on Chrome", when: "42 min ago" }, { kind: "key", text: "Created API key “CI deploy”", when: "Oct 21" }, { kind: "role", text: "Role changed from Member to Admin by Maya Lindgren", when: "Oct 2" }, { kind: "2fa", text: "Turned on two-factor authentication", when: "Mar 10, 2024" }, { kind: "join", text: "Joined from an invite by Maya Lindgren", when: "Mar 9, 2024" }, ], }, { id: "u3", name: "Priya Natarajan", email: "priya@larkfield.studio", role: "admin", status: "active", twoFactor: true, teams: ["Design"], minutesSinceActive: 180, joined: "Apr 14, 2024", location: "Bengaluru, IN" }, { id: "u4", name: "Jonas Weber", email: "jonas@larkfield.studio", role: "member", status: "active", twoFactor: false, teams: ["Engineering"], minutesSinceActive: 1440, joined: "May 3, 2024", location: "Berlin, DE" }, { id: "u5", name: "Camila Reyes", email: "camila@larkfield.studio", role: "member", status: "active", twoFactor: true, teams: ["Marketing", "Content", "Events"], minutesSinceActive: 300, joined: "Jun 20, 2024", location: "Mexico City, MX" }, { id: "u6", name: "Theo Laurent", email: "theo@larkfield.studio", role: "billing", status: "active", twoFactor: true, teams: ["Finance"], minutesSinceActive: 4320, joined: "Jul 1, 2024", location: "Lyon, FR" }, { id: "u7", name: "Hana Sato", email: "hana@larkfield.studio", role: "member", status: "active", twoFactor: true, teams: ["Engineering", "Platform"], minutesSinceActive: 15, joined: "Aug 12, 2024", location: "Osaka, JP" }, { id: "u8", name: "Dev Malhotra", email: "dev@larkfield.studio", role: "member", status: "suspended", twoFactor: false, teams: ["Engineering"], minutesSinceActive: 64800, joined: "Sep 5, 2024", location: "Toronto, CA" }, { id: "u9", name: "Rosa Ferreira", email: "rosa.ferreira@gmail.com", role: "viewer", status: "active", twoFactor: false, teams: ["Clients"], minutesSinceActive: 10080, joined: "Jan 18, 2025", location: "Porto, PT" }, { id: "u10", name: "Sam Okoro", email: "sam@larkfield.studio", role: "member", status: "invited", twoFactor: false, teams: ["Design"], minutesSinceActive: null, joined: "Invited Oct 22" }, { id: "u11", name: "", email: "lena@northpine.co", role: "viewer", status: "invited", twoFactor: false, teams: [], minutesSinceActive: null, joined: "Invited Oct 15" }, { id: "u12", name: "Felix Brandt", email: "felix@larkfield.studio", role: "member", status: "active", twoFactor: true, teams: ["Support"], minutesSinceActive: 95, joined: "Feb 7, 2025", location: "Hamburg, DE" }, { id: "u13", name: "Ama Mensah", email: "ama@larkfield.studio", role: "member", status: "active", twoFactor: false, teams: ["Content"], minutesSinceActive: 2880, joined: "Apr 30, 2025", location: "Accra, GH" }, ]; function initials(u: AdminUser) { const src = u.name || u.email; return src .replace(/@.*/, "") .split(/[\s._-]+/) .filter(Boolean) .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); } function hashIndex(s: string, n: number) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % n; } function ago(m: number | null) { if (m === null) return "Never"; if (m < 5) return "Just now"; if (m < 60) return `${m} min ago`; if (m < 1440) return `${Math.floor(m / 60)}h ago`; if (m < 2880) return "Yesterday"; return `${Math.floor(m / 1440)}d ago`; } const roleMeta = (r: AdminRole) => adminRoles.find((x) => x.value === r) ?? adminRoles[2]; function UserAvatar({ user, size = 8 }: { user: AdminUser; size?: 7 | 8 | 14 }) { const sizes = { 7: "[--avatar-size:--spacing(7)]", 8: "[--avatar-size:--spacing(8)]", 14: "[--avatar-size:--spacing(14)]" }; return ( {initials(user)} ); } function RolePill({ role }: { role: AdminRole }) { const m = roleMeta(role); const reduce = useReducedMotionAfterMount(); return ( {m.label} ); } function StatusTag({ user }: { user: AdminUser }) { const m = statusMeta[user.status]; return ( {m.label} ); } type Tab = "all" | AdminStatus; type SortKey = "name" | "minutesSinceActive"; const activityIcon: Record = { signin: , role: , key: , join: , invite: , suspend: , "2fa": , }; function activityFor(u: AdminUser): AdminActivity[] { if (u.activity) return u.activity; if (u.status === "invited") return [{ kind: "invite", text: "Invite sent by Maya Lindgren", when: u.joined.replace("Invited ", "") }]; const list: AdminActivity[] = []; if (u.minutesSinceActive !== null) list.push({ kind: "signin", text: `Signed in from ${u.location ?? "an unknown location"}`, when: ago(u.minutesSinceActive) }); if (u.status === "suspended") list.push({ kind: "suspend", text: "Suspended by Owen Adeyemi", when: "Sep 30" }); if (u.twoFactor) list.push({ kind: "2fa", text: "Turned on two-factor authentication", when: u.joined }); list.push({ kind: "join", text: "Joined from an invite by Maya Lindgren", when: u.joined }); return list; } const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; export function AdminUsers({ className, orgName = "Larkfield Studio", seats = 15, users: initialUsers = defaultUsers, currentUserId = "u1", }: AdminUsersProps) { const reduce = useReducedMotionAfterMount(); const [users, setUsers] = useState(initialUsers); const [tab, setTab] = useState("all"); const [query, setQuery] = useState(""); const [roleFilter, setRoleFilter] = useState([]); const [no2fa, setNo2fa] = useState(false); const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: "name", dir: 1 }); const [selected, setSelected] = useState([]); const [detailId, setDetailId] = useState(null); const [invite, setInvite] = useState(false); const [confirm, setConfirm] = useState(null); const [toast, setToast] = useState<{ id: number; text: string } | null>(null); const [nav, setNav] = useState(false); const [freshIds, setFreshIds] = useState([]); const counter = useRef(0); const drawerRef = useRef(null); const say = (text: string) => { counter.current += 1; setToast({ id: counter.current, text }); }; useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 3200); return () => clearTimeout(t); }, [toast]); useEffect(() => { if (detailId) drawerRef.current?.focus(); }, [detailId]); const counts: Record = { all: users.length, active: users.filter((u) => u.status === "active").length, invited: users.filter((u) => u.status === "invited").length, suspended: users.filter((u) => u.status === "suspended").length, }; const seatsUsed = users.filter((u) => u.status !== "suspended" && u.role !== "viewer").length; const activeUsers = users.filter((u) => u.status === "active"); const twoFaPct = activeUsers.length ? Math.round((activeUsers.filter((u) => u.twoFactor).length / activeUsers.length) * 100) : 0; const q = query.trim().toLowerCase(); const rows = users .filter( (u) => (tab === "all" || u.status === tab) && (roleFilter.length === 0 || roleFilter.includes(u.role)) && (!no2fa || !u.twoFactor) && (!q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) || u.teams.some((t) => t.toLowerCase().includes(q))), ) .sort((a, b) => { if (sort.key === "name") return (a.name || a.email).localeCompare(b.name || b.email) * sort.dir; const av = a.minutesSinceActive ?? Number.POSITIVE_INFINITY; const bv = b.minutesSinceActive ?? Number.POSITIVE_INFINITY; return (av - bv) * sort.dir; }); const visibleIds = rows.filter((r) => r.id !== currentUserId).map((r) => r.id); const selectedVisible = selected.filter((id) => visibleIds.includes(id)); const allChecked = visibleIds.length > 0 && selectedVisible.length === visibleIds.length; const someChecked = selectedVisible.length > 0 && !allChecked; const selectedInvited = users.filter((u) => selectedVisible.includes(u.id) && u.status === "invited"); const filterCount = roleFilter.length + (no2fa ? 1 : 0); const detail = users.find((u) => u.id === detailId) ?? null; const me = users.find((u) => u.id === currentUserId); const updateMany = (ids: string[], patch: Partial) => setUsers((all) => all.map((u) => (ids.includes(u.id) ? { ...u, ...patch } : u))); const label = (u: AdminUser) => u.name || u.email; const remove = (ids: string[]) => { const n = ids.length; setUsers((all) => all.filter((u) => !ids.includes(u.id))); setSelected((s) => s.filter((id) => !ids.includes(id))); if (detailId && ids.includes(detailId)) setDetailId(null); setConfirm(null); say(n === 1 ? "Member removed. Their work stays in the projects." : `${n} members removed`); }; const toggleSort = (key: SortKey) => setSort((s) => (s.key === key ? { key, dir: s.dir === 1 ? -1 : 1 } : { key, dir: 1 })); const sortHeader = (key: SortKey, text: string, className?: string) => ( ); const roleMenu = (u: AdminUser, align: "start" | "end" = "start") => u.role === "owner" || u.id === currentUserId ? ( ) : ( e.stopPropagation()} > { updateMany([u.id], { role: v as AdminRole }); say(`${label(u)} is now ${roleMeta(v as AdminRole).label === "Admin" ? "an" : "a"} ${roleMeta(v as AdminRole).label.toLowerCase()}`); }} value={u.role} > {adminRoles .filter((r) => r.value !== "owner") .map((r) => ( {r.label} {r.description} ))} ); const rowMenu = (u: AdminUser) => u.id === currentUserId ? ( ) : ( e.stopPropagation()} render={ ); const teamChips = (u: AdminUser, all = false) => u.teams.length === 0 ? ( No teams ) : ( {(all ? u.teams : u.teams.slice(0, 2)).map((t) => ( {t} ))} {!all && u.teams.length > 2 && +{u.teams.length - 2}} ); const twoFa = (u: AdminUser) => u.status === "invited" ? ( — ) : u.twoFactor ? ( On ) : ( Off ); const rowMotion = (u: AdminUser) => ({ animate: { opacity: 1, y: 0 }, exit: reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, x: -16, transition: { duration: 0.16, ease: easeOut } }, initial: reduce ? false : freshIds.includes(u.id) ? { opacity: 0, y: -6 } : false, layout: reduce ? false : ("position" as const), transition: spring, }); const listKey = [tab, roleFilter.join(","), no2fa, q, sort.key, sort.dir].join("|"); const MotionRow = MotionTableRow; const navItems = [ { label: "General", icon: }, { label: "Members", icon: , active: true, badge: counts.invited }, { label: "Teams", icon: }, { label: "Roles", icon: }, { label: "Security & SSO", icon: }, { label: "Audit log", icon: }, { label: "Billing", icon: }, ]; const sidebar = (
{orgName .split(" ") .map((w) => w[0]) .join("") .slice(0, 2)}
{orgName} Organization settings
{me && (
{me.name} {roleMeta(me.role).label}
)}
); const tabs: { value: Tab; label: string }[] = [ { value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "invited", label: "Invited" }, { value: "suspended", label: "Suspended" }, ]; const empty = (
{q ? : }
{q ? `Nobody matches “${query.trim()}”` : tab === "suspended" ? "No suspended members" : "No members match these filters"} {q ? "Search works on names, emails and teams." : "Try removing a filter."}
); return (
{ if (e.key !== "Escape") return; if (confirm) setConfirm(null); else if (invite) setInvite(false); else if (detailId) setDetailId(null); else if (nav) setNav(false); }} > {nav && ( <> setNav(false)} transition={{ duration: 0.2, ease: easeOut }} /> {sidebar} )}
{orgName} / Members

Members

Manage who can access {orgName} and what they can do.

Seats {Math.max(0, seats - seatsUsed)} left
{seatsUsed} of {seats}
{Array.from({ length: seats }, (_, i) => ( 0.9 ? "bg-warning" : "bg-primary") : "bg-muted")} initial={false} key={i} /> ))}
{tabs.map((t) => ( ))}
setQuery(e.target.value)} placeholder="Name, email or team" type="search" value={query} /> }> Filter {filterCount > 0 && ( {filterCount} )} Role {adminRoles.map((r) => ( setRoleFilter((f) => (on ? [...f, r.value] : f.filter((x) => x !== r.value)))} > {r.label} ))} Security setNo2fa(on)}> Two-factor off
{filterCount > 0 && (
{roleFilter.map((r) => ( ))} {no2fa && ( )}
)}
setSelected(on ? visibleIds : [])} /> {rows.length} members {sortHeader("minutesSinceActive", "Last active")}
    {rows.map((u) => { const isSel = selected.includes(u.id); return ( {u.id === currentUserId ? ( ) : ( setSelected((s) => (on ? [...s, u.id] : s.filter((x) => x !== u.id)))} /> )} ); })}
}>
setSelected(on ? visibleIds : [])} /> {sortHeader("name", "Member")}
Role Status 2FA Teams {sortHeader("minutesSinceActive", "Last active")}
{rows.map((u) => { const isSel = selected.includes(u.id); return ( { if ((e.target as HTMLElement).closest("button, [role=checkbox], [role=menuitem]")) return; setDetailId(u.id); }} >
{u.id === currentUserId ? ( ) : ( setSelected((s) => (on ? [...s, u.id] : s.filter((x) => x !== u.id)))} /> )}
{u.name ? u.email : "Hasn't joined yet"}
{roleMenu(u)}
{u.status === "invited" && {u.joined.replace("Invited ", "Sent ")}}
{twoFa(u)} {teamChips(u)} {ago(u.minutesSinceActive)} {rowMenu(u)}
); })}
{rows.length === 0 && empty}
{selectedVisible.length > 0 && !detail && !invite && !confirm ? ( {selectedVisible.length} selected Role Change role to {adminRoles .filter((r) => r.value !== "owner") .map((r) => ( { const ids = users.filter((u) => selectedVisible.includes(u.id) && u.role !== "owner").map((u) => u.id); updateMany(ids, { role: r.value }); say(`${ids.length} ${ids.length === 1 ? "member is" : "members are"} now ${r.label.toLowerCase()}${ids.length === 1 ? "" : "s"}`); }} > {r.label} ))} {selectedInvited.length > 0 && ( )} ) : toast ? ( {toast.text} ) : null}
{detail && ( <> setDetailId(null)} transition={{ duration: 0.2, ease: easeOut }} />
Member details

{detail.name || detail.email}

{detail.name ? detail.email : "Hasn't joined yet"} {roleMenu(detail)}
{detail.status === "invited" ? "Invited" : "Joined"}
{detail.joined.replace("Invited ", "")}
Last active
{ago(detail.minutesSinceActive)}
Two-factor
{twoFa(detail)}
Location
{detail.location ?? "—"}
Teams
{teamChips(detail, true)}
{detail.status === "active" && (

Signed-in devices

    {[ { icon: , name: "MacBook Pro · Chrome", meta: `${detail.location ?? "Unknown"} · ${ago(detail.minutesSinceActive)}`, current: true }, { icon: , name: "iPhone · App", meta: `${detail.location ?? "Unknown"} · 3d ago` }, ].map((d) => (
  • {d.icon} {d.name} {d.meta} {d.current && Active now}
  • ))}
)}

Activity

    {activityFor(detail).map((a, i) => ( {activityIcon[a.kind]} {a.text} {a.when} ))}
{detail.role !== "owner" && detail.id !== currentUserId && (
{detail.status === "invited" ? ( ) : ( )}
)}
)}
{confirm && ( confirm.includes(u.id)).map(label)} onCancel={() => setConfirm(null)} onConfirm={() => remove(confirm)} /> )} {invite && ( setInvite(false)} onSend={(emails, role) => { const added: AdminUser[] = emails.map((email, i) => ({ id: `inv-${Date.now()}-${i}`, name: "", email, role, status: "invited", twoFactor: false, teams: [], minutesSinceActive: null, joined: "Invited just now", })); setUsers((all) => [...added, ...all]); setFreshIds(added.map((a) => a.id)); setTab("all"); setQuery(""); setRoleFilter([]); setNo2fa(false); setSort({ key: "minutesSinceActive", dir: -1 }); setInvite(false); say(`${emails.length} ${emails.length === 1 ? "invite" : "invites"} sent`); }} existing={users.map((u) => u.email.toLowerCase())} seatsLeft={seats - seatsUsed} /> )}
); } const MotionTableRow = motion.create(TableRow); function ConfirmRemove({ names, onCancel, onConfirm }: { names: string[]; onCancel: () => void; onConfirm: () => void }) { const reduce = useReducedMotionAfterMount(); const cancelRef = useRef(null); useEffect(() => cancelRef.current?.focus(), []); const n = names.length; return ( <>

Remove {n === 1 ? names[0] : `${n} members`}?

{n === 1 ? "They" : "They all"} lose access right away and {n === 1 ? "their seat is" : "their seats are"} freed. Projects and comments they made stay put.

); } function InviteDialog({ onClose, onSend, seatsLeft, existing, }: { onClose: () => void; onSend: (emails: string[], role: AdminRole) => void; seatsLeft: number; existing: string[]; }) { const reduce = useReducedMotionAfterMount(); const [emails, setEmails] = useState([]); const [draft, setDraft] = useState(""); const [role, setRole] = useState("member"); const inputRef = useRef(null); useEffect(() => inputRef.current?.focus(), []); const commit = (raw: string) => { const parts = raw.split(/[\s,;]+/).map((p) => p.trim().toLowerCase()).filter(Boolean); if (!parts.length) return; setEmails((e) => [...e, ...parts.filter((p) => !e.includes(p))]); setDraft(""); }; const problem = (e: string) => (!EMAIL.test(e) ? "format" : existing.includes(e) ? "taken" : null); const invalid = emails.filter((e) => problem(e) === "format"); const taken = emails.filter((e) => problem(e) === "taken"); const blocked = invalid.length + taken.length; const validCount = emails.length - blocked; const usesSeats = role !== "viewer"; const overSeats = usesSeats && validCount > seatsLeft; const canSend = emails.length > 0 && blocked === 0 && !overSeats; return ( <>

Invite members

They'll get an email with a link that works for 14 days.

inputRef.current?.focus()} > {emails.map((e) => { const bad = problem(e) !== null; return ( {e} ); })} { commit(draft); }} onChange={(e) => { const v = e.target.value; if (/[,;\s]$/.test(v)) commit(v); else setDraft(v); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (draft.trim()) commit(draft); else if (canSend) onSend(emails, role); } else if (e.key === "Backspace" && !draft && emails.length) { setEmails((all) => all.slice(0, -1)); } }} onPaste={(e) => { e.preventDefault(); commit(e.clipboardData.getData("text")); }} placeholder={emails.length ? "Add another" : "name@company.com"} ref={inputRef} value={draft} />
{invalid.length ? `${invalid.length === 1 ? `“${invalid[0]}” doesn't look like an email` : `${invalid.length} addresses don't look right`}. Remove or fix ${invalid.length === 1 ? "it" : "them"}.` : taken.length ? `${taken.length === 1 ? `${taken[0]} is` : `${taken.length} addresses are`} already a member or invited.` : "Separate addresses with commas or paste a list."}
Role
{adminRoles .filter((r) => r.value === "admin" || r.value === "member" || r.value === "viewer") .map((r) => { const on = role === r.value; return ( ); })}
{usesSeats ? (overSeats ? `Only ${seatsLeft} seats left. Add seats or invite as viewers.` : validCount ? `Uses ${validCount} of ${seatsLeft} remaining ${seatsLeft === 1 ? "seat" : "seats"}` : `${seatsLeft} ${seatsLeft === 1 ? "seat" : "seats"} left`) : "Viewers are free"}
); }