Fix types: remove any; clean unused states; add machines summary in unified Users list; capitalize 'Gerenciar usuários'; correct Convex linkUser typing

This commit is contained in:
codex-bot 2025-10-21 11:16:31 -03:00
parent 89c8e0cdb3
commit 8b02b8a564
3 changed files with 58 additions and 153 deletions

View file

@ -1089,9 +1089,9 @@ export const linkUser = mutation({
.first()
if (!user) throw new ConvexError("Usuário não encontrado")
const current = new Set((machine.linkedUserIds ?? []).map((id) => id.id ?? id))
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
current.add(user._id)
await ctx.db.patch(machine._id, { linkedUserIds: Array.from(current) as any, updatedAt: Date.now() })
await ctx.db.patch(machine._id, { linkedUserIds: Array.from(current), updatedAt: Date.now() })
return { ok: true }
},
})

View file

@ -246,19 +246,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
// Usuários
const [peopleSearch, setPeopleSearch] = useState("")
const [peopleRoleFilter, setPeopleRoleFilter] = useState<"all" | "manager" | "collaborator">("all")
const [peopleCompanyFilter, setPeopleCompanyFilter] = useState<string>("all")
const [peopleTenantFilter, setPeopleTenantFilter] = useState<string>("all")
const [peopleSelection, setPeopleSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingPeople, setIsBulkDeletingPeople] = useState(false)
const [bulkDeletePeopleOpen, setBulkDeletePeopleOpen] = useState(false)
const [machineSearch, setMachineSearch] = useState("")
const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all")
const [machineSelection, setMachineSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingMachines, setIsBulkDeletingMachines] = useState(false)
const [bulkDeleteMachinesOpen, setBulkDeleteMachinesOpen] = useState(false)
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado)
// Unificado (pessoas + máquinas)
const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("all")
@ -279,10 +267,45 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [createPassword, setCreatePassword] = useState<string | null>(null)
// Máquinas (para listar vínculos por usuário)
type MachinesListItem = {
id: string
hostname?: string
assignedUserEmail?: string | null
metadata?: unknown
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
const machinesList = useQuery(
convexUserId ? api.machines.listByTenant : "skip",
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : ("skip" as const)
) as Array<{ id: string; hostname?: string; assignedUserEmail?: string | null; metadata?: unknown }> | undefined
) as MachinesListItem[] | undefined
const machinesByUserEmail = useMemo(() => {
const map = new Map<string, Array<{ id: string; hostname?: string }>>()
;(machinesList ?? []).forEach((m) => {
const push = (email?: string | null) => {
const e = (email ?? '').toLowerCase()
if (!e) return
const arr = map.get(e) ?? []
arr.push({ id: m.id, hostname: m.hostname })
map.set(e, arr)
}
push(m.assignedUserEmail)
// metadata collaborator
if (m.metadata && typeof m.metadata === 'object') {
const rec = m.metadata as Record<string, unknown>
const c = rec['collaborator']
if (c && typeof c === 'object') {
const base = c as Record<string, unknown>
if (typeof base.email === 'string') push(base.email)
}
}
// linked users
if (Array.isArray(m.linkedUsers)) {
m.linkedUsers.forEach((lu) => push(lu.email))
}
})
return map
}, [machinesList])
// Options of tenants present in dataset for filtering
const tenantOptions = useMemo(() => {
@ -311,43 +334,9 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
})
}, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter, teamTenantFilter, defaultTenantId])
const filteredPeopleUsers = useMemo(() => {
const term = peopleSearch.trim().toLowerCase()
return peopleUsers.filter((user) => {
const role = coerceRole(user.role)
if (peopleRoleFilter !== "all" && role !== peopleRoleFilter) return false
if (peopleCompanyFilter !== "all" && user.companyId !== peopleCompanyFilter) return false
if (peopleTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== peopleTenantFilter) return false
if (!term) return true
const haystack = [
user.name ?? "",
user.email ?? "",
user.companyName ?? "",
formatRole(user.role),
]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
}, [peopleUsers, peopleSearch, peopleRoleFilter, peopleCompanyFilter, peopleTenantFilter, defaultTenantId])
// Removido: lista específica de Pessoas (uso substituído pelo unificado)
const filteredMachineUsers = useMemo(() => {
const term = machineSearch.trim().toLowerCase()
return machineUsers.filter((user) => {
const persona = (user.machinePersona ?? "unassigned").toLowerCase()
if (machinePersonaFilter !== "all") {
if (machinePersonaFilter === "unassigned" && persona !== "unassigned") return false
if (machinePersonaFilter !== "unassigned" && persona !== machinePersonaFilter) return false
}
if (!term) return true
return (
(user.name ?? "").toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term) ||
persona.includes(term) ||
(extractMachineId(user.email) ?? "").toLowerCase().includes(term)
)
})
}, [machineUsers, machinePersonaFilter, machineSearch])
// Removido: filtro específico de agentes (uso substituído pelo unificado)
const combinedBaseUsers = useMemo(() => {
if (usersTypeFilter === "people") return peopleUsers
@ -426,8 +415,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
if (typeof base.email === "string") collaboratorEmail = base.email.toLowerCase()
}
}
const linked = Array.isArray((m as any).linkedUsers)
? ((m as any).linkedUsers as Array<{ email?: string }>).some((lu) => (lu.email ?? '').toLowerCase() === email)
const linked = Array.isArray(m.linkedUsers)
? m.linkedUsers.some((lu) => (lu.email ?? '').toLowerCase() === email)
: false
if (assigned === email || (collaboratorEmail && collaboratorEmail === email) || linked) {
results.push({ id: m.id, hostname: m.hostname })
@ -714,11 +703,9 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const allTeamSelected = selectedTeamUsers.length > 0 && selectedTeamUsers.length === filteredTeamUsers.length
const someTeamSelected = selectedTeamUsers.length > 0 && !allTeamSelected
const selectedPeopleUsers = useMemo(() => filteredPeopleUsers.filter((u) => peopleSelection.has(u.id)), [filteredPeopleUsers, peopleSelection])
const allPeopleSelected = selectedPeopleUsers.length > 0 && selectedPeopleUsers.length === filteredPeopleUsers.length
const somePeopleSelected = selectedPeopleUsers.length > 0 && !allPeopleSelected
// Removido: seleção específica de Pessoas (uso substituído pelo unificado)
const selectedMachineUsers = useMemo(() => filteredMachineUsers.filter((u) => machineSelection.has(u.id)), [filteredMachineUsers, machineSelection])
// Removido: seleção específica de Máquinas (uso substituído pelo unificado)
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
@ -730,12 +717,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
function toggleTeamSelectAll(checked: boolean) {
setTeamSelection(checked ? new Set(filteredTeamUsers.map((u) => u.id)) : new Set())
}
function togglePeopleSelectAll(checked: boolean) {
setPeopleSelection(checked ? new Set(filteredPeopleUsers.map((u) => u.id)) : new Set())
}
function toggleMachinesSelectAll(checked: boolean) {
setMachineSelection(checked ? new Set(filteredMachineUsers.map((u) => u.id)) : new Set())
}
// Removidos: toggles de seleção específicos (uso substituído pelo unificado)
function toggleInvitesSelectAll(checked: boolean) {
setInviteSelection(checked ? new Set(invites.map((i) => i.id)) : new Set())
}
@ -1063,6 +1045,7 @@ async function handleDeleteUser() {
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Empresa</th>
<th className="py-3 pr-4 font-medium">Máquinas</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th>
@ -1091,6 +1074,14 @@ async function handleDeleteUser() {
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="py-3 pr-4 text-neutral-600">
{(() => {
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
return list.length > 0 ? (
<span className="text-xs font-medium">{list.length} {list.length === 1 ? 'máquina' : 'máquinas'}</span>
) : '—'
})()}
</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
@ -2012,93 +2003,7 @@ async function handleDeleteUser() {
</DialogContent>
</Dialog>
<Dialog open={bulkDeletePeopleOpen} onOpenChange={setBulkDeletePeopleOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle>
<DialogDescription>Os usuários perderão o acesso imediatamente.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedPeopleUsers.slice(0, 5).map((u) => (
<div key={`people-del-${u.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{u.name || u.email} <span className="text-neutral-500"> {u.email}</span>
</div>
))}
{selectedPeopleUsers.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedPeopleUsers.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeletePeopleOpen(false)} disabled={isBulkDeletingPeople}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingPeople}
onClick={async () => {
setIsBulkDeletingPeople(true)
try {
await performBulkDeleteUsers(selectedPeopleUsers.map((u) => u.id))
setPeopleSelection(new Set())
setBulkDeletePeopleOpen(false)
toast.success("Remoção concluída")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingPeople(false)
}
}}
>
Excluir selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={bulkDeleteMachinesOpen} onOpenChange={setBulkDeleteMachinesOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover agentes selecionados</DialogTitle>
<DialogDescription>As máquinas serão desconectadas e precisarão de novo provisionamento.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedMachineUsers.slice(0, 5).map((u) => (
<div key={`machine-del-${u.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{u.name || u.email} <span className="text-neutral-500"> {u.email}</span>
</div>
))}
{selectedMachineUsers.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedMachineUsers.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteMachinesOpen(false)} disabled={isBulkDeletingMachines}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingMachines}
onClick={async () => {
setIsBulkDeletingMachines(true)
try {
await performBulkDeleteMachines(selectedMachineUsers.map((u) => u.id))
setMachineSelection(new Set())
setBulkDeleteMachinesOpen(false)
toast.success("Agentes removidos")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingMachines(false)
}
}}
>
Remover selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialogs antigos removidos: ações em massa agora são unificadas no diálogo abaixo */}
<Dialog open={bulkRevokeInvitesOpen} onOpenChange={setBulkRevokeInvitesOpen}>
<DialogContent>

View file

@ -1934,7 +1934,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
>
Adicionar vínculo
</Button>
<Link href="/admin" className="text-xs underline underline-offset-4">gerenciar usuários</Link>
<Link href="/admin" className="text-xs underline underline-offset-4">Gerenciar usuários</Link>
</div>
</div>
</section>
@ -3287,7 +3287,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
<span className="rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5">
Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</span>
<Link href="/admin" className="underline underline-offset-4">gerenciar usuários</Link>
<Link href="/admin" className="underline underline-offset-4">Gerenciar usuários</Link>
</div>
) : null}
{!isActive ? (