archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
+268
View File
@@ -0,0 +1,268 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '../../hooks/useTeam'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
</span>
)}
</div>
</div>
{/* Confirm overlay */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
)}
</button>
</div>
</div>
)}
{/* Role selector (hidden for pending invites — role locked until accepted) */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{/* Danger zone */}
<div className="flex gap-2 mr-auto">
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
</button>
)}
</div>
</div>
</div>
)
}
+205
View File
@@ -0,0 +1,205 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '../../hooks/useTeam'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
]
export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when modal closes
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They'll receive an email invitation to join your dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
</span>
)}
</div>
</button>
))}
</div>
</div>
{/* Error */}
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending' : 'Send invite '}
</button>
</div>
</form>
</div>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
'use client'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
</div>
)
}
export default function PermissionsMatrix() {
return (
<div>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
</div>
</div>
{/* Rows */}
{FEATURES.map((feature, i) => (
<div
key={feature.label}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
</div>
</div>
))}
</div>
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@prisma/client'
// Role hierarchy: OWNER > MANAGER > AGENT
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
MANAGER: 2,
AGENT: 1,
}
/**
* requireRole(minimumRole)
* Middleware that blocks requests from employees whose role rank
* is below the required minimum.
*
* Usage:
* router.post('/invite', requireRole('OWNER'), handler)
* router.patch('/something', requireRole('MANAGER'), handler)
*/
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
const employeeRank = ROLE_RANK[employee.role as EmployeeRole] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
requiredRole: minimumRole,
yourRole: employee.role,
})
}
next()
}
}
+169
View File
@@ -0,0 +1,169 @@
import { Router, Request, Response, NextFunction } from 'express'
import { z } from 'zod'
import {
listEmployees,
inviteEmployee,
updateEmployeeRole,
deactivateEmployee,
reactivateEmployee,
removeEmployee,
} from '../services/teamService'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
// All team routes require a valid company employee session + active subscription
router.use(requireCompanyAuth, requireTenant, requireSubscription)
// ─── Validation schemas ───────────────────────────────────────
const inviteSchema = z.object({
firstName: z.string().min(1).max(64),
lastName: z.string().min(1).max(64),
email: z.string().email(),
role: z.enum(['MANAGER', 'AGENT']), // OWNER cannot be invited
})
const updateRoleSchema = z.object({
role: z.enum(['MANAGER', 'AGENT']),
})
// ─── GET /team ────────────────────────────────────────────────
// List all employees for this company.
// All roles can view the team list.
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
res.json({ data: members })
} catch (err) {
next(err)
}
})
// ─── GET /team/stats ──────────────────────────────────────────
// Quick counts for dashboard stat cards.
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
const stats = {
total: members.length,
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
pending: members.filter((m) => m.invitationStatus === 'pending').length,
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
}
res.json({ data: stats })
} catch (err) {
next(err)
}
})
// ─── POST /team/invite ────────────────────────────────────────
// Invite a new employee by email. OWNER only.
router.post(
'/invite',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = inviteSchema.parse(req.body)
const result = await inviteEmployee(
req.companyId,
req.employee.id,
body
)
res.status(201).json({ data: result })
} catch (err) {
next(err)
}
}
)
// ─── PATCH /team/:id/role ─────────────────────────────────────
// Change an employee's role. OWNER only.
router.patch(
'/:id/role',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = updateRoleSchema.parse(req.body)
const updated = await updateEmployeeRole(
req.companyId,
req.employee.id,
req.employee.role,
req.params.id,
body
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/deactivate ────────────────────────────────
// Suspend access without deleting the record. OWNER only.
router.post(
'/:id/deactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await deactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/reactivate ────────────────────────────────
// Restore a deactivated employee's access. OWNER only.
router.post(
'/:id/reactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await reactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── DELETE /team/:id ─────────────────────────────────────────
// Permanently remove an employee + revoke their Clerk session/invite. OWNER only.
router.delete(
'/:id',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await removeEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: result })
} catch (err) {
next(err)
}
}
)
export default router
+337
View File
@@ -0,0 +1,337 @@
'use client'
import { useState, useMemo } from 'react'
import { useTeam, TeamMember } from '../hooks/useTeam'
import InviteModal from '../components/team/InviteModal'
import EditMemberModal from '../components/team/EditMemberModal'
import PermissionsMatrix from '../components/team/PermissionsMatrix'
import { useEmployee } from '@/hooks/useEmployee' // your existing auth hook
// ─── Helpers ─────────────────────────────────────────────────
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return 'Never'
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{role.charAt(0) + role.slice(1).toLowerCase()}
</span>
)
}
function StatusBadge({ member }: { member: TeamMember }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active
</span>
)
}
// ─── Main page ────────────────────────────────────────────────
export default function TeamPage() {
const { employee: currentEmployee } = useEmployee()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
// ── Handlers with toasts ───────────────────────────────────
const handleInvite: typeof invite = async (payload) => {
await invite(payload)
showToast(`Invite sent to ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast('Role updated')
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast('Member deactivated')
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast('Member reactivated')
}
const handleRemove = async (id: string) => {
await remove(id)
showToast('Member removed')
}
// ── Render ─────────────────────────────────────────────────
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Invite member
</button>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Total members', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Pending invite', value: stats.pending },
{ label: 'Deactivated', value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All roles</option>
<option value="OWNER">Owner</option>
<option value="MANAGER">Manager</option>
<option value="AGENT">Agent</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Deactivated</option>
</select>
</div>
{/* Table */}
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member, i) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
{/* Member */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployee?.id && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
{/* Role */}
<td className="px-4 py-3">
<RoleBadge role={member.role} />
</td>
{/* Status */}
<td className="px-4 py-3">
<StatusBadge member={member} />
</td>
{/* Last active */}
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Manage
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Permissions matrix */}
<PermissionsMatrix />
{/* Modals */}
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{/* Toast */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { PrismaClient, EmployeeRole } from '@prisma/client'
import { clerkClient } from '@clerk/clerk-sdk-node'
const prisma = new PrismaClient()
// ─── Types ────────────────────────────────────────────────────
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: EmployeeRole
}
export interface UpdateRolePayload {
role: EmployeeRole
}
export interface TeamMember {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: Date
updatedAt: Date
// hydrated from Clerk
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
// ─── List employees ───────────────────────────────────────────
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
// Hydrate last-active timestamps from Clerk in parallel
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return {
...emp,
lastActiveAt: clerkUser.lastActiveAt
? new Date(clerkUser.lastActiveAt)
: null,
invitationStatus: 'accepted' as const,
}
} catch {
// Clerk user not yet accepted invitation
return {
...emp,
lastActiveAt: null,
invitationStatus: 'pending' as const,
}
}
})
)
return hydrated.map((r) =>
r.status === 'fulfilled' ? r.value : (r as any).value
)
}
// ─── Get single employee (scoped to company) ──────────────────
export async function getEmployee(companyId: string, employeeId: string) {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, companyId },
})
if (!employee) {
throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' })
}
return employee
}
// ─── Invite ───────────────────────────────────────────────────
// 1. Send a Clerk invitation (creates the Clerk user + sends email)
// 2. Create the Employee row immediately so the company sees it in the list
// with isActive=false until they accept and log in (Clerk webhook sets isActive=true)
export async function inviteEmployee(
companyId: string,
inviterId: string,
payload: InvitePayload
) {
const { firstName, lastName, email, role } = payload
// Owners cannot be invited — must be the account creator
if (role === 'OWNER') {
throw Object.assign(
new Error('Cannot invite a member with the OWNER role'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Check for existing active employee with that email in this company
const existing = await prisma.employee.findFirst({
where: { companyId, email },
})
if (existing) {
throw Object.assign(
new Error('An employee with this email already exists in your team'),
{ statusCode: 409, code: 'employee_already_exists' }
)
}
// Get the company's subdomain/name for the invitation email redirect URL
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
// Send Clerk invitation
// Clerk will create the user and email them a magic link
let clerkInvitation: Awaited<ReturnType<typeof clerkClient.invitations.createInvitation>>
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: {
companyId,
companyName: company.brand?.displayName ?? company.name,
role,
invitedBy: inviterId,
},
})
} catch (err: any) {
// Clerk throws if email already has a Clerk account with a pending invite
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(
new Error('This email already has a pending invitation'),
{ statusCode: 409, code: 'duplicate_invitation' }
)
}
throw err
}
// Create the Employee record with a placeholder clerkUserId
// It will be updated when the user accepts the invite (via Clerk webhook)
const employee = await prisma.employee.create({
data: {
companyId,
clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook
firstName,
lastName,
email,
role,
isActive: false, // activated on invite acceptance
},
})
return {
employee,
invitationId: clerkInvitation.id,
}
}
// ─── Update role ──────────────────────────────────────────────
export async function updateEmployeeRole(
companyId: string,
requesterId: string,
requesterRole: EmployeeRole,
employeeId: string,
payload: UpdateRolePayload
) {
const target = await getEmployee(companyId, employeeId)
// Only OWNER can change roles
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can change team member roles'),
{ statusCode: 403, code: 'forbidden' }
)
}
// Cannot change an OWNER's role (there must always be one owner)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot change the role of the account owner'),
{ statusCode: 400, code: 'cannot_change_owner_role' }
)
}
// Cannot assign OWNER role via this endpoint
if (payload.role === 'OWNER') {
throw Object.assign(
new Error('Cannot assign the OWNER role via this endpoint'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Prevent self-role-change (edge case)
if (target.id === requesterId) {
throw Object.assign(
new Error('You cannot change your own role'),
{ statusCode: 400, code: 'cannot_change_own_role' }
)
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { role: payload.role },
})
return updated
}
// ─── Deactivate ───────────────────────────────────────────────
export async function deactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
const target = await getEmployee(companyId, employeeId)
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can deactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot deactivate the account owner'),
{ statusCode: 400, code: 'cannot_deactivate_owner' }
)
}
// Revoke Clerk session so they are immediately signed out
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal — still deactivate in DB
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: false },
})
return updated
}
// ─── Reactivate ───────────────────────────────────────────────
export async function reactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can reactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.unbanUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: true },
})
return updated
}
// ─── Remove (hard delete) ─────────────────────────────────────
// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending.
export async function removeEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can remove team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot remove the account owner'),
{ statusCode: 400, code: 'cannot_remove_owner' }
)
}
// Revoke pending Clerk invitation
if (target.clerkUserId.startsWith('pending_')) {
const inviteId = target.clerkUserId.replace('pending_', '')
try {
await clerkClient.invitations.revokeInvitation(inviteId)
} catch {
// Already accepted or expired — safe to ignore
}
} else {
// Ban the Clerk user so they can't log in
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
// ─── Clerk webhook handler ────────────────────────────────────
// Called when a user accepts their invitation. Updates the Employee row
// with the real Clerk user ID and activates the account.
export async function handleInvitationAccepted(
clerkUserId: string,
email: string,
companyId: string,
role: EmployeeRole
) {
const pendingEmployee = await prisma.employee.findFirst({
where: {
companyId,
email,
clerkUserId: { startsWith: 'pending_' },
},
})
if (!pendingEmployee) return null
const activated = await prisma.employee.update({
where: { id: pendingEmployee.id },
data: {
clerkUserId,
isActive: true,
role, // in case it was updated between invite and acceptance
},
})
return activated
}
+172
View File
@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.RentalDriveGo.com/api/v1'
async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
...options,
})
const json = await res.json()
if (!res.ok) {
const err = new Error(json.message ?? 'Request failed') as any
err.code = json.error
err.statusCode = res.status
throw err
}
return json.data as T
}
// ─── useTeam ─────────────────────────────────────────────────
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchMembers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchMembers() }, [fetchMembers])
// ── Invite ─────────────────────────────────────────────────
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
// Optimistically add the pending member
setMembers((prev) => [...prev, result.employee])
setStats((prev) => ({
...prev,
total: prev.total + 1,
pending: prev.pending + 1,
}))
return result
}, [])
// ── Update role ────────────────────────────────────────────
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
)
return updated
}, [])
// ── Deactivate ─────────────────────────────────────────────
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
)
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
return updated
}, [])
// ── Reactivate ─────────────────────────────────────────────
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
)
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
return updated
}, [])
// ── Remove ─────────────────────────────────────────────────
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const target = members.find((m) => m.id === memberId)
setMembers((prev) => prev.filter((m) => m.id !== memberId))
setStats((prev) => ({
...prev,
total: prev.total - 1,
active: target?.isActive ? prev.active - 1 : prev.active,
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? prev.inactive - 1
: prev.inactive,
}))
}, [members])
return {
members,
stats,
loading,
error,
refetch: fetchMembers,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Router, Request, Response } from 'express'
import { Webhook } from 'svix'
import { handleInvitationAccepted } from '../services/teamService'
import { EmployeeRole } from '@prisma/client'
const router = Router()
/**
* POST /webhooks/clerk
*
* Handles Clerk webhook events. Registered in the Clerk dashboard.
* Uses svix signature verification — raw body must be preserved
* (use express.raw() before this route, not express.json()).
*
* Events handled:
* - user.created → when an invited employee accepts and creates their account
*/
router.post(
'/clerk',
async (req: Request, res: Response) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('CLERK_WEBHOOK_SECRET is not set')
return res.status(500).json({ error: 'Webhook secret not configured' })
}
// Verify signature
const wh = new Webhook(webhookSecret)
let event: any
try {
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch (err) {
console.error('Clerk webhook signature verification failed:', err)
return res.status(400).json({ error: 'Invalid webhook signature' })
}
const { type, data } = event
// ── user.created ──────────────────────────────────────────
// Fired when an invited employee accepts their invite and creates
// their Clerk account. publicMetadata contains companyId + role
// that we embedded when creating the invitation.
if (type === 'user.created') {
const clerkUserId: string = data.id
const email: string = data.email_addresses?.[0]?.email_address ?? ''
const meta = data.public_metadata ?? {}
const companyId: string | undefined = meta.companyId
const role: EmployeeRole | undefined = meta.role
if (companyId && role && email) {
try {
const employee = await handleInvitationAccepted(
clerkUserId,
email,
companyId,
role
)
if (employee) {
console.log(
`Employee activated: ${employee.firstName} ${employee.lastName} (${employee.id})`
)
}
} catch (err) {
// Log but do not return 500 — Clerk will retry on 5xx
console.error('Failed to activate employee from Clerk webhook:', err)
}
}
}
res.json({ received: true })
}
)
export default router