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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,221 @@
|
||||
"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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user