Docs: document Users/Machines unification, company filter in Machines, Windows OS label, and identity/email/history guidance in OPERATIONS.md

This commit is contained in:
codex-bot 2025-10-21 10:55:07 -03:00
parent 231310a9fe
commit af0658af26
3 changed files with 95 additions and 0 deletions

View file

@ -142,3 +142,41 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
Última atualização: sincronizado após o deploy bemsucedido do Convex e do Front (20/10/2025).
## 9) Admin ▸ Usuários e Máquinas — Unificação e UX
Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de máquina” e melhorar o filtro em Máquinas:
- Unificação de “Usuários” e “Agentes de máquina”
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de máquina”.
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Máquinas).
- Onde: `src/components/admin/admin-users-manager.tsx:923`, aba `value="users"` em `:1147`.
- Motivo: evitar confusão entre “usuário” e “agente”; agentes são um tipo especial de usuário (role=machine). A unificação torna “Convites e Acessos” mais direta.
- Máquinas ▸ Filtro por Empresa com busca e remoção do filtro de SO
- Adicionado dropdown de “Empresa” com busca (Popover + Input) e removido o filtro por Sistema Operacional.
- Onde: `src/components/admin/machines/admin-machines-overview.tsx:1038` e `:1084`.
- Motivo: fluxo real usa empresas com mais frequência; filtro por SO era menos útil agora.
- Windows ▸ Rótulo do sistema sem duplicidade do “major”
- Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”.
- Onde: `src/components/admin/machines/admin-machines-overview.tsx` (função `formatOsVersionDisplay`).
- Motivo: legibilidade e padronização em chips/cartões.
- Vínculos visuais entre máquinas e pessoas
- Cards de máquinas mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/machines/admin-machines-overview.tsx:3198`.
- Editor de usuário exibe “Máquinas vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Máquinas vinculadas” no sheet de edição).
- Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Máquinas.
### Identidade, email e histórico (reinstalação)
- Identificador imutável: o histórico (tickets, eventos) referencia o `userId` (imutável). O email é um atributo mutável.
- Reinstalação do desktop para o mesmo colaborador: reutilize a mesma conta de usuário (mesmo `userId`); se o email mudou, atualize o email dessa conta no painel. O histórico permanece, pois o `userId` não muda.
- Novo email como nova conta: se criar um usuário novo (novo `userId`), será considerado um colaborador distinto e não herdará o histórico.
- Caso precise migrar histórico entre contas diferentes (merge), recomendamos endpoint/rotina de “fusão de contas” (remapear `userId` antigo → novo). Não é necessário para a troca de email da mesma conta.
### Onde editar
- Usuários (pessoas): editar nome, email, papel, tenant e empresa; redefinir senha pelo painel. Arquivo: `src/components/admin/admin-users-manager.tsx`.
- Agentes (máquinas): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Máquinas. Arquivo: `src/components/admin/machines/admin-machines-overview.tsx`.
> Observação operacional: mantivemos o provisionamento de máquinas inalterado (token/email técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.

View file

@ -22,6 +22,9 @@ import {
} from "@/components/ui/select"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
@ -164,6 +167,7 @@ function isRestrictedRole(role?: string | null) {
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
const { convexUserId } = useAuth()
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [companies, setCompanies] = useState<CompanyOption[]>([])
@ -274,6 +278,12 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [isCreatingUser, setIsCreatingUser] = useState(false)
const [createPassword, setCreatePassword] = useState<string | null>(null)
// Máquinas (para listar vínculos por usuário)
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
// Options of tenants present in dataset for filtering
const tenantOptions = useMemo(() => {
const set = new Set<string>()
@ -400,6 +410,28 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
})
setPasswordPreview(null)
}, [editUser, defaultTenantId])
const linkedMachinesForEditUser = useMemo(() => {
if (!editUser || !machinesList) return [] as Array<{ id: string; hostname?: string }>
const email = (editUser.email ?? "").toLowerCase()
const results: Array<{ id: string; hostname?: string }> = []
machinesList.forEach((m) => {
const assigned = (m.assignedUserEmail ?? "").toLowerCase()
let collaboratorEmail = ""
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") collaboratorEmail = base.email.toLowerCase()
}
}
if (assigned === email || (collaboratorEmail && collaboratorEmail === email)) {
results.push({ id: m.id, hostname: m.hostname })
}
})
return results
}, [editUser, machinesList])
useEffect(() => {
setCreateForm((prev) => ({
...prev,
@ -1783,6 +1815,23 @@ 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>
{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

@ -3200,6 +3200,14 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
</CardTitle>
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
{collaborator?.email ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<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>
</div>
) : null}
{!isActive ? (
<Badge variant="outline" className="mt-2 w-fit border-rose-200 bg-rose-50 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
Desativada