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))
.first()
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 ?? [])
current.add(user._id)

View file

@ -103,11 +103,7 @@ function machinePersonaBadgeVariant(persona: string | null | undefined) {
return "outline" as const
}
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
return tenantId
}
// Tenant removido da UI (sem exibição)
function formatDate(dateIso: string) {
const date = new Date(dateIso)
@ -242,7 +238,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [teamSearch, setTeamSearch] = useState("")
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("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 [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
@ -251,7 +247,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
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 [isBulkDeletingUsersCombined, setIsBulkDeletingUsersCombined] = useState(false)
const [bulkDeleteUsersCombinedOpen, setBulkDeleteUsersCombinedOpen] = useState(false)
@ -307,19 +303,13 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
return map
}, [machinesList])
// Options of tenants present in dataset for filtering
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])
// Tenant removido da UI
const filteredTeamUsers = useMemo(() => {
const term = teamSearch.trim().toLowerCase()
return teamUsers.filter((user) => {
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 (!term) return true
const haystack = [
@ -332,7 +322,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
.toLowerCase()
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)
@ -348,7 +338,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const term = usersSearch.trim().toLowerCase()
return combinedBaseUsers.filter((user) => {
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
const persona = (user.machinePersona ?? "").toLowerCase()
const machineId = extractMachineId(user.email) ?? ""
@ -364,7 +354,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
.toLowerCase()
return haystack.includes(term)
})
}, [combinedBaseUsers, usersSearch, usersCompanyFilter, usersTenantFilter, defaultTenantId])
}, [combinedBaseUsers, usersSearch, usersCompanyFilter])
useEffect(() => {
void (async () => {
@ -987,17 +977,7 @@ async function handleDeleteUser() {
))}
</SelectContent>
</Select>
<Select value={teamTenantFilter} onValueChange={setTeamTenantFilter}>
<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>
{/* Filtro por espaço removido */}
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button
variant="ghost"
@ -1006,7 +986,7 @@ async function handleDeleteUser() {
setTeamSearch("")
setTeamRoleFilter("all")
setTeamCompanyFilter("all")
setTeamTenantFilter("all")
// filtro por espaço removido
}}
>
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">Empresa</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 font-medium">Ações</th>
</tr>
@ -1077,12 +1057,14 @@ async function handleDeleteUser() {
<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>
) : '—'
if (list.length === 0) return '—'
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 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">
<div className="flex flex-wrap gap-2">
@ -1213,18 +1195,8 @@ async function handleDeleteUser() {
))}
</SelectContent>
</Select>
<Select value={usersTenantFilter} onValueChange={setUsersTenantFilter}>
<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={`users-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))}
</SelectContent>
</Select>
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all" || usersTenantFilter !== "all") ? (
{/* Filtro por espaço removido */}
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
@ -1232,7 +1204,7 @@ async function handleDeleteUser() {
setUsersSearch("")
setUsersTypeFilter("all")
setUsersCompanyFilter("all")
setUsersTenantFilter("all")
// filtro por espaço removido
}}
>
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">Perfil</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 font-medium">Ações</th>
</tr>
@ -1313,7 +1285,7 @@ async function handleDeleteUser() {
)}
</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">
<div className="flex flex-wrap gap-2">
@ -1465,7 +1437,7 @@ async function handleDeleteUser() {
</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">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">Status</th>
<th className="py-3 font-medium">Ações</th>
@ -1497,7 +1469,7 @@ async function handleDeleteUser() {
</div>
</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">
<Badge
@ -1620,15 +1592,7 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<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>
{/* Campo de espaço (tenant) removido */}
<div className="grid gap-2">
<Label htmlFor="create-company">Empresa vinculada</Label>
<Select
@ -1778,15 +1742,7 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<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>
{/* Campo de espaço (tenant) removido */}
<div className="grid gap-2">
<Label>Empresa vinculada</Label>
<Select
@ -1809,23 +1765,29 @@ async function handleDeleteUser() {
</Select>
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
</div>
<div className="grid gap-2">
<Label>Máquinas vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
)}
</div>
{(() => {
const r = coerceRole(editUser.role)
if (r === 'admin' || r === 'agent') return null
return (
<div className="grid gap-2">
<Label>Máquinas vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
)}
</div>
)
})()}
{isMachineEditing ? (
<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>.

View file

@ -1934,6 +1934,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
>
Adicionar vínculo
</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>
</div>
</div>