Remove tenant UI; restrict machine links to non-admin users; polish Users/Machines UX
This commit is contained in:
parent
4a30a1b564
commit
347609a186
3 changed files with 54 additions and 87 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue