Files
property-management-network/components/dashboard/team-manager.tsx
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

222 lines
7.5 KiB
TypeScript

"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Loader2, Trash2, UserPlus } from "lucide-react"
type MemberRole = "member" | "viewer"
type MemberStatus = "pending" | "active" | "revoked"
export type TeamMember = {
id: string
email: string
role: MemberRole
status: MemberStatus
}
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
export function TeamManager({ initialMembers }: { initialMembers: TeamMember[] }) {
const router = useRouter()
const [members, setMembers] = useState<TeamMember[]>(initialMembers)
const [email, setEmail] = useState("")
const [role, setRole] = useState<MemberRole>("member")
const [inviting, setInviting] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
async function handleInvite(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!email.trim()) return
setInviting(true)
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
})
setInviting(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to send invite")
return
}
toast.success(`Invite sent to ${email.trim()}`)
setEmail("")
setRole("member")
router.refresh()
}
async function handleRoleToggle(member: TeamMember) {
const nextRole: MemberRole = member.role === "member" ? "viewer" : "member"
setBusyId(member.id)
const res = await fetch(`/api/team/${member.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: nextRole }),
})
setBusyId(null)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to update role")
return
}
setMembers((prev) =>
prev.map((m) => (m.id === member.id ? { ...m, role: nextRole } : m))
)
toast.success("Role updated")
router.refresh()
}
async function handleRevoke(member: TeamMember) {
setBusyId(member.id)
const res = await fetch(`/api/team/${member.id}`, { method: "DELETE" })
setBusyId(null)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to revoke access")
return
}
setMembers((prev) => prev.filter((m) => m.id !== member.id))
toast.success(`Removed ${member.email}`)
router.refresh()
}
return (
<div className="space-y-6">
{/* Invite form */}
<form
onSubmit={handleInvite}
className="space-y-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-6"
>
<div>
<h3 className="text-sm font-semibold text-white">Invite a team member</h3>
<p className="mt-0.5 text-xs text-white/40">
They&apos;ll get an email with a link to join your account.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="teammate@example.com"
className={inputClass + " sm:flex-1"}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as MemberRole)}
className={inputClass + " sm:w-40"}
>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={inviting}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50 sm:w-auto"
>
{inviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
Invite
</button>
</div>
<p className="text-xs text-white/30">
Members can view and edit your portfolio. Viewers have read-only access.
</p>
</form>
{/* Members list */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-6 py-4">
<h3 className="text-sm font-semibold text-white">
Team members{" "}
<span className="text-white/30">({members.length})</span>
</h3>
</div>
{members.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-white/40">
No team members yet. Invite someone above to get started.
</div>
) : (
<ul className="divide-y divide-white/[0.06]">
{members.map((member) => {
const busy = busyId === member.id
return (
<li
key={member.id}
className="flex flex-wrap items-center justify-between gap-3 px-6 py-4"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{member.email}</p>
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={member.status} />
<span className="text-xs capitalize text-white/40">{member.role}</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleRoleToggle(member)}
disabled={busy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/5 disabled:opacity-50"
>
Make {member.role === "member" ? "viewer" : "member"}
</button>
<button
type="button"
onClick={() => handleRevoke(member)}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs font-medium text-red-400 transition hover:bg-red-500/10 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Revoke
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
function StatusBadge({ status }: { status: MemberStatus }) {
const styles: Record<MemberStatus, string> = {
active: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
pending: "border-amber-500/20 bg-amber-500/10 text-amber-400",
revoked: "border-white/10 bg-white/5 text-white/40",
}
return (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${styles[status]}`}
>
{status}
</span>
)
}