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:
parent
231310a9fe
commit
af0658af26
3 changed files with 95 additions and 0 deletions
|
|
@ -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 bem‑sucedido do Convex e do Front (20/10/2025).
|
Última atualização: sincronizado após o deploy bem‑sucedido 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, e‑mail e histórico (reinstalação)
|
||||||
|
|
||||||
|
- Identificador imutável: o histórico (tickets, eventos) referencia o `userId` (imutável). O e‑mail é um atributo mutável.
|
||||||
|
- Reinstalação do desktop para o mesmo colaborador: reutilize a mesma conta de usuário (mesmo `userId`); se o e‑mail mudou, atualize o e‑mail dessa conta no painel. O histórico permanece, pois o `userId` não muda.
|
||||||
|
- Novo e‑mail 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 e‑mail da mesma conta.
|
||||||
|
|
||||||
|
### Onde editar
|
||||||
|
|
||||||
|
- Usuários (pessoas): editar nome, e‑mail, 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/e‑mail técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import {
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
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 { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||||
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
|
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) {
|
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
|
||||||
|
const { convexUserId } = useAuth()
|
||||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||||
|
|
@ -274,6 +278,12 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
||||||
const [createPassword, setCreatePassword] = useState<string | null>(null)
|
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
|
// Options of tenants present in dataset for filtering
|
||||||
const tenantOptions = useMemo(() => {
|
const tenantOptions = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
|
|
@ -400,6 +410,28 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
})
|
})
|
||||||
setPasswordPreview(null)
|
setPasswordPreview(null)
|
||||||
}, [editUser, defaultTenantId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setCreateForm((prev) => ({
|
setCreateForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -1783,6 +1815,23 @@ 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>
|
||||||
|
<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 ? (
|
{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>.
|
||||||
|
|
|
||||||
|
|
@ -3200,6 +3200,14 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
||||||
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
|
<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 ? (
|
{!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">
|
<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
|
Desativada
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue