Remove tenant UI; restrict machine links to non-admin users; polish Users/Machines UX

This commit is contained in:
codex-bot 2025-10-21 11:55:05 -03:00
parent 4a30a1b564
commit 347609a186
3 changed files with 54 additions and 87 deletions

View file

@ -1088,6 +1088,10 @@ export const linkUser = mutation({
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalized)) .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalized))
.first() .first()
if (!user) throw new ConvexError("Usuário não encontrado") if (!user) throw new ConvexError("Usuário não encontrado")
const role = (user.role ?? "").toUpperCase()
if (role === 'ADMIN' || role === 'AGENT') {
throw new ConvexError('Usuários administrativos não podem ser vinculados a máquinas')
}
const current = new Set<Id<"users">>(machine.linkedUserIds ?? []) const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
current.add(user._id) current.add(user._id)

View file

@ -103,11 +103,7 @@ function machinePersonaBadgeVariant(persona: string | null | undefined) {
return "outline" as const return "outline" as const
} }
function formatTenantLabel(tenantId: string, defaultTenantId: string) { // Tenant removido da UI (sem exibição)
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
return tenantId
}
function formatDate(dateIso: string) { function formatDate(dateIso: string) {
const date = new Date(dateIso) const date = new Date(dateIso)
@ -242,7 +238,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [teamSearch, setTeamSearch] = useState("") const [teamSearch, setTeamSearch] = useState("")
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all") const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
const [teamCompanyFilter, setTeamCompanyFilter] = useState<string>("all") const [teamCompanyFilter, setTeamCompanyFilter] = useState<string>("all")
const [teamTenantFilter, setTeamTenantFilter] = useState<string>("all") // Removido: filtro por espaço (tenant)
const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set()) const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false) const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false) const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
@ -251,7 +247,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [usersSearch, setUsersSearch] = useState("") const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people") const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all") const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
const [usersTenantFilter, setUsersTenantFilter] = useState<string>("all") // Removido: filtro por espaço (tenant)
const [usersSelection, setUsersSelection] = useState<Set<string>>(new Set()) const [usersSelection, setUsersSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingUsersCombined, setIsBulkDeletingUsersCombined] = useState(false) const [isBulkDeletingUsersCombined, setIsBulkDeletingUsersCombined] = useState(false)
const [bulkDeleteUsersCombinedOpen, setBulkDeleteUsersCombinedOpen] = useState(false) const [bulkDeleteUsersCombinedOpen, setBulkDeleteUsersCombinedOpen] = useState(false)
@ -307,19 +303,13 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
return map return map
}, [machinesList]) }, [machinesList])
// Options of tenants present in dataset for filtering // Tenant removido da UI
const tenantOptions = useMemo(() => {
const set = new Set<string>()
users.forEach((u) => u.tenantId && set.add(u.tenantId))
const list = Array.from(set)
return list.length > 0 ? list : [defaultTenantId]
}, [users, defaultTenantId])
const filteredTeamUsers = useMemo(() => { const filteredTeamUsers = useMemo(() => {
const term = teamSearch.trim().toLowerCase() const term = teamSearch.trim().toLowerCase()
return teamUsers.filter((user) => { return teamUsers.filter((user) => {
if (teamCompanyFilter !== "all" && user.companyId !== teamCompanyFilter) return false if (teamCompanyFilter !== "all" && user.companyId !== teamCompanyFilter) return false
if (teamTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== teamTenantFilter) return false // filtro por espaço removido
if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false
if (!term) return true if (!term) return true
const haystack = [ const haystack = [
@ -332,7 +322,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
.toLowerCase() .toLowerCase()
return haystack.includes(term) return haystack.includes(term)
}) })
}, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter, teamTenantFilter, defaultTenantId]) }, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter])
// Removido: lista específica de Pessoas (uso substituído pelo unificado) // Removido: lista específica de Pessoas (uso substituído pelo unificado)
@ -348,7 +338,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const term = usersSearch.trim().toLowerCase() const term = usersSearch.trim().toLowerCase()
return combinedBaseUsers.filter((user) => { return combinedBaseUsers.filter((user) => {
if (usersCompanyFilter !== "all" && user.companyId !== usersCompanyFilter) return false if (usersCompanyFilter !== "all" && user.companyId !== usersCompanyFilter) return false
if (usersTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== usersTenantFilter) return false // filtro por espaço removido
if (!term) return true if (!term) return true
const persona = (user.machinePersona ?? "").toLowerCase() const persona = (user.machinePersona ?? "").toLowerCase()
const machineId = extractMachineId(user.email) ?? "" const machineId = extractMachineId(user.email) ?? ""
@ -364,7 +354,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
.toLowerCase() .toLowerCase()
return haystack.includes(term) return haystack.includes(term)
}) })
}, [combinedBaseUsers, usersSearch, usersCompanyFilter, usersTenantFilter, defaultTenantId]) }, [combinedBaseUsers, usersSearch, usersCompanyFilter])
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@ -987,17 +977,7 @@ async function handleDeleteUser() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={teamTenantFilter} onValueChange={setTeamTenantFilter}> {/* Filtro por espaço removido */}
<SelectTrigger className="h-9 w-full sm:w-40">
<SelectValue placeholder="Espaço" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os espaços</SelectItem>
{tenantOptions.map((t) => (
<SelectItem key={`team-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))}
</SelectContent>
</Select>
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? ( {(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button <Button
variant="ghost" variant="ghost"
@ -1006,7 +986,7 @@ async function handleDeleteUser() {
setTeamSearch("") setTeamSearch("")
setTeamRoleFilter("all") setTeamRoleFilter("all")
setTeamCompanyFilter("all") setTeamCompanyFilter("all")
setTeamTenantFilter("all") // filtro por espaço removido
}} }}
> >
Limpar filtros Limpar filtros
@ -1046,7 +1026,7 @@ async function handleDeleteUser() {
<th className="py-3 pr-4 font-medium">Papel</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">Empresa</th>
<th className="py-3 pr-4 font-medium">Máquinas</th> <th className="py-3 pr-4 font-medium">Máquinas</th>
<th className="py-3 pr-4 font-medium">Espaço</th> {/* Espaço removido */}
<th className="py-3 pr-4 font-medium">Criado em</th> <th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th> <th className="py-3 font-medium">Ações</th>
</tr> </tr>
@ -1077,12 +1057,14 @@ async function handleDeleteUser() {
<td className="py-3 pr-4 text-neutral-600"> <td className="py-3 pr-4 text-neutral-600">
{(() => { {(() => {
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? [] const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
return list.length > 0 ? ( if (list.length === 0) return '—'
<span className="text-xs font-medium">{list.length} {list.length === 1 ? 'máquina' : 'máquinas'}</span> const names = list.map((m) => m.hostname || m.id)
) : '—' const head = names.slice(0, 2).join(', ')
const extra = names.length > 2 ? ` +${names.length - 2}` : ''
return <span className="text-xs font-medium" title={names.join(', ')}>{head}{extra}</span>
})()} })()}
</td> </td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td> {/* Espaço removido */}
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td> <td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3"> <td className="py-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -1213,18 +1195,8 @@ async function handleDeleteUser() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={usersTenantFilter} onValueChange={setUsersTenantFilter}> {/* Filtro por espaço removido */}
<SelectTrigger className="h-9 w-full sm:w-40"> {(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
<SelectValue placeholder="Espaço" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os espaços</SelectItem>
{tenantOptions.map((t) => (
<SelectItem key={`users-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))}
</SelectContent>
</Select>
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all" || usersTenantFilter !== "all") ? (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -1232,7 +1204,7 @@ async function handleDeleteUser() {
setUsersSearch("") setUsersSearch("")
setUsersTypeFilter("all") setUsersTypeFilter("all")
setUsersCompanyFilter("all") setUsersCompanyFilter("all")
setUsersTenantFilter("all") // filtro por espaço removido
}} }}
> >
Limpar filtros Limpar filtros
@ -1272,7 +1244,7 @@ async function handleDeleteUser() {
<th className="py-3 pr-4 font-medium">Tipo</th> <th className="py-3 pr-4 font-medium">Tipo</th>
<th className="py-3 pr-4 font-medium">Perfil</th> <th className="py-3 pr-4 font-medium">Perfil</th>
<th className="py-3 pr-4 font-medium">Empresa</th> <th className="py-3 pr-4 font-medium">Empresa</th>
<th className="py-3 pr-4 font-medium">Espaço</th> {/* Espaço removido */}
<th className="py-3 pr-4 font-medium">Criado em</th> <th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th> <th className="py-3 font-medium">Ações</th>
</tr> </tr>
@ -1313,7 +1285,7 @@ async function handleDeleteUser() {
)} )}
</td> </td>
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td> <td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td> {/* Espaço removido */}
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td> <td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3"> <td className="py-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -1465,7 +1437,7 @@ async function handleDeleteUser() {
</th> </th>
<th className="py-3 pr-4 font-medium">Colaborador</th> <th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th> <th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th> {/* Espaço removido */}
<th className="py-3 pr-4 font-medium">Expira em</th> <th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th> <th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th> <th className="py-3 font-medium">Ações</th>
@ -1497,7 +1469,7 @@ async function handleDeleteUser() {
</div> </div>
</td> </td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td> <td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td> {/* Espaço removido */}
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td> <td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<Badge <Badge
@ -1620,15 +1592,7 @@ async function handleDeleteUser() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2"> {/* Campo de espaço (tenant) removido */}
<Label htmlFor="create-tenant">Espaço (tenant)</Label>
<Input
id="create-tenant"
placeholder="tenant-atlas"
value={createForm.tenantId}
onChange={(event) => setCreateForm((prev) => ({ ...prev, tenantId: event.target.value }))}
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="create-company">Empresa vinculada</Label> <Label htmlFor="create-company">Empresa vinculada</Label>
<Select <Select
@ -1778,15 +1742,7 @@ async function handleDeleteUser() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2"> {/* Campo de espaço (tenant) removido */}
<Label>Espaço (tenant)</Label>
<Input
value={editForm.tenantId}
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
placeholder="tenant-atlas"
disabled={isSavingUser || isMachineEditing || editingRestricted}
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Empresa vinculada</Label> <Label>Empresa vinculada</Label>
<Select <Select
@ -1809,6 +1765,10 @@ async function handleDeleteUser() {
</Select> </Select>
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p> <p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
</div> </div>
{(() => {
const r = coerceRole(editUser.role)
if (r === 'admin' || r === 'agent') return null
return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Máquinas vinculadas</Label> <Label>Máquinas vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? ( {linkedMachinesForEditUser.length > 0 ? (
@ -1826,6 +1786,8 @@ async function handleDeleteUser() {
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p> <p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
)} )}
</div> </div>
)
})()}
{isMachineEditing ? ( {isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600"> <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>. Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.

View file

@ -1934,6 +1934,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
> >
Adicionar vínculo Adicionar vínculo
</Button> </Button>
<span className="text-xs text-neutral-500">Somente colaboradores/gestores.</span>
<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>
</div> </div>