archetecture security fix
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendInvitedMember,
|
||||
applyActivationStats,
|
||||
applyMemberActivation,
|
||||
applyMemberRoleUpdate,
|
||||
incrementInvitedStats,
|
||||
removeTeamMemberState,
|
||||
type TeamMember,
|
||||
type TeamStats,
|
||||
} from './useTeam'
|
||||
|
||||
function member(overrides: Partial<TeamMember>): TeamMember {
|
||||
return {
|
||||
id: 'member-1',
|
||||
firstName: 'Amina',
|
||||
lastName: 'Bennis',
|
||||
email: 'amina@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'accepted',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const stats: TeamStats = { total: 3, active: 2, pending: 1, inactive: 0 }
|
||||
|
||||
describe('useTeam state helpers', () => {
|
||||
it('appends invited employees and increments the pending counters immutably', () => {
|
||||
const existing = [member({ id: 'owner', role: 'OWNER' })]
|
||||
const invited = member({ id: 'invite-1', role: 'MANAGER', invitationStatus: 'pending', isActive: false })
|
||||
|
||||
expect(appendInvitedMember(existing, invited)).toEqual([...existing, invited])
|
||||
expect(existing).toHaveLength(1)
|
||||
expect(incrementInvitedStats(stats)).toEqual({ total: 4, active: 2, pending: 2, inactive: 0 })
|
||||
})
|
||||
|
||||
it('updates roles and activation flags only on the targeted member', () => {
|
||||
const members = [member({ id: 'a' }), member({ id: 'b', role: 'MANAGER', isActive: false })]
|
||||
|
||||
expect(applyMemberRoleUpdate(members, 'a', 'MANAGER')).toEqual([
|
||||
{ ...members[0], role: 'MANAGER' },
|
||||
members[1],
|
||||
])
|
||||
expect(applyMemberActivation(members, 'b', true)).toEqual([
|
||||
members[0],
|
||||
{ ...members[1], isActive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('moves counters between active and inactive for deactivate/reactivate actions', () => {
|
||||
expect(applyActivationStats({ total: 5, active: 4, pending: 0, inactive: 1 }, 'deactivate')).toEqual({
|
||||
total: 5,
|
||||
active: 3,
|
||||
pending: 0,
|
||||
inactive: 2,
|
||||
})
|
||||
expect(applyActivationStats({ total: 5, active: 3, pending: 0, inactive: 2 }, 'reactivate')).toEqual({
|
||||
total: 5,
|
||||
active: 4,
|
||||
pending: 0,
|
||||
inactive: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes active, pending, and inactive accepted members with the correct counter impact', () => {
|
||||
const members = [
|
||||
member({ id: 'active', isActive: true, invitationStatus: 'accepted' }),
|
||||
member({ id: 'pending', isActive: false, invitationStatus: 'pending' }),
|
||||
member({ id: 'inactive', isActive: false, invitationStatus: 'accepted' }),
|
||||
]
|
||||
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'active').stats).toEqual({
|
||||
total: 2,
|
||||
active: 0,
|
||||
pending: 1,
|
||||
inactive: 1,
|
||||
})
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'pending').stats).toEqual({
|
||||
total: 2,
|
||||
active: 1,
|
||||
pending: 0,
|
||||
inactive: 1,
|
||||
})
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'inactive').stats).toEqual({
|
||||
total: 2,
|
||||
active: 1,
|
||||
pending: 1,
|
||||
inactive: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves counters untouched when asked to remove an unknown member', () => {
|
||||
const members = [member({ id: 'only' })]
|
||||
const result = removeTeamMemberState(members, { total: 1, active: 1, pending: 0, inactive: 0 }, 'missing')
|
||||
|
||||
expect(result.members).toEqual(members)
|
||||
expect(result.stats).toEqual({ total: 1, active: 1, pending: 0, inactive: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
export function appendInvitedMember(members: TeamMember[], employee: TeamMember): TeamMember[] {
|
||||
return [...members, employee]
|
||||
}
|
||||
|
||||
export function incrementInvitedStats(stats: TeamStats): TeamStats {
|
||||
return { ...stats, total: stats.total + 1, pending: stats.pending + 1 }
|
||||
}
|
||||
|
||||
export function applyMemberRoleUpdate(members: TeamMember[], memberId: string, role: 'MANAGER' | 'AGENT'): TeamMember[] {
|
||||
return members.map((member) => (member.id === memberId ? { ...member, role } : member))
|
||||
}
|
||||
|
||||
export function applyMemberActivation(members: TeamMember[], memberId: string, isActive: boolean): TeamMember[] {
|
||||
return members.map((member) => (member.id === memberId ? { ...member, isActive } : member))
|
||||
}
|
||||
|
||||
export function applyActivationStats(stats: TeamStats, direction: 'deactivate' | 'reactivate'): TeamStats {
|
||||
return direction === 'deactivate'
|
||||
? { ...stats, active: stats.active - 1, inactive: stats.inactive + 1 }
|
||||
: { ...stats, active: stats.active + 1, inactive: stats.inactive - 1 }
|
||||
}
|
||||
|
||||
export function removeTeamMemberState(members: TeamMember[], stats: TeamStats, memberId: string): { members: TeamMember[]; stats: TeamStats } {
|
||||
const target = members.find((member) => member.id === memberId)
|
||||
|
||||
return {
|
||||
members: members.filter((member) => member.id !== memberId),
|
||||
stats: {
|
||||
...stats,
|
||||
total: stats.total - (target ? 1 : 0),
|
||||
active: target?.isActive ? stats.active - 1 : stats.active,
|
||||
pending: target?.invitationStatus === 'pending' ? stats.pending - 1 : stats.pending,
|
||||
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
|
||||
? stats.inactive - 1
|
||||
: stats.inactive,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 refetch = 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(() => { refetch() }, [refetch])
|
||||
|
||||
const invite = useCallback(async (payload: InvitePayload) => {
|
||||
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
|
||||
'/team/invite',
|
||||
{ method: 'POST', body: JSON.stringify(payload) }
|
||||
)
|
||||
setMembers((prev) => appendInvitedMember(prev, result.employee))
|
||||
setStats(incrementInvitedStats)
|
||||
return result
|
||||
}, [])
|
||||
|
||||
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) => applyMemberRoleUpdate(prev, memberId, updated.role as 'MANAGER' | 'AGENT'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const deactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) => applyMemberActivation(prev, memberId, false))
|
||||
setStats((prev) => applyActivationStats(prev, 'deactivate'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const reactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) => applyMemberActivation(prev, memberId, true))
|
||||
setStats((prev) => applyActivationStats(prev, 'reactivate'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (memberId: string) => {
|
||||
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const nextState = removeTeamMemberState(members, stats, memberId)
|
||||
setMembers(nextState.members)
|
||||
setStats(nextState.stats)
|
||||
}, [members, stats])
|
||||
|
||||
return {
|
||||
members,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
invite,
|
||||
updateRole,
|
||||
deactivate,
|
||||
reactivate,
|
||||
remove,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user