diff --git a/agents.md b/agents.md index 59abe71..ca81534 100644 --- a/agents.md +++ b/agents.md @@ -9,8 +9,9 @@ | Papel | Usuário | Senha | | --- | --- | --- | | Administrador | `admin@sistema.dev` | `admin123` | -| Agente Demo | `agente.demo@sistema.dev` | `agent123` | -| Cliente Demo | `cliente.demo@sistema.dev` | `cliente123` | +| Painel telão | `suporte@rever.com.br` | `agent123` | + +Os demais colaboradores reais são provisionados via **Convites & acessos**. Caso existam vestígios de dados demo, execute `node scripts/remove-legacy-demo-users.mjs` para limpá-los. > Execute `pnpm auth:seed` após configurar `.env` para (re)criar os usuários acima (campos `SEED_USER_*` podem sobrescrever credenciais). @@ -18,7 +19,7 @@ - Seeds de usuários/tickets demo: `convex/seed.ts`. - Para DEV: rode `pnpm convex:dev` e acesse `/dev/seed` uma vez para popular dados realistas. -## Stack atual (16/10/2025) +## Stack atual (18/10/2025) - **Next.js**: `15.5.5` (Turbopack em produção + cache de filesystem em DEV). - Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`. - **React / React DOM**: `18.2.0`. @@ -48,6 +49,7 @@ ### Banco de dados - Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`). - Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`). +- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.). ### Verificações antes de PR/deploy ```bash @@ -156,4 +158,4 @@ pnpm build - `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente. --- -_Última atualização: 16/10/2025 (Next.js 15.5.5 estável, Turbopack, fluxos desktop + portal documentados)._ +_Última atualização: 18/10/2025 (Next.js 15.5.5 estável, Turbopack, fluxos desktop + portal documentados)._ diff --git a/docs/DEV.md b/docs/DEV.md index c88d099..9e79e79 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -1,4 +1,4 @@ -# Guia de Desenvolvimento — 16/10/2025 +# Guia de Desenvolvimento — 18/10/2025 Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções. @@ -28,6 +28,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve ``` 3. Credenciais padrão (seed): `admin@sistema.dev / admin123`. +4. Herdou dados antigos? Execute `node scripts/remove-legacy-demo-users.mjs` para limpar contas demo legadas. > **Por quê inline?** Evitamos declarar `DATABASE_URL` em `prisma/.env` porque o Prisma lê também o `.env` da raiz (produção). O override inline garante isolamento do banco DEV. @@ -59,6 +60,62 @@ Etapas: O workflow dispara em todo `push`/`pull_request` para `main` e fornece feedback imediato sem depender do pipeline de deploy. +## Testes rápidos via curl (Convites & acessos) + +1. Rode `pnpm dev` e autentique-se em `http://localhost:3000/login` usando `admin@sistema.dev / admin123`. +2. Copie o valor do cookie `BETTER_AUTH_SESSION` e exporte no shell: `export COOKIE="BETTER_AUTH_SESSION="`. + +### Usuários + +```bash +# Listar usuários com acesso web +curl -s http://localhost:3000/api/admin/users \ + -H "Cookie: $COOKIE" \ + -H "Accept: application/json" | jq '.users | map({ id, email, role })' # remova o pipe se não tiver jq + +# Criar usuário gestor (ajuste o e-mail se necessário) +NEW_EMAIL="api.teste.$(date +%s)@sistema.dev" +curl -s -X POST http://localhost:3000/api/admin/users \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"Usuário via curl\",\"email\":\"$NEW_EMAIL\",\"role\":\"manager\",\"tenantId\":\"tenant-atlas\"}" \ + | tee /tmp/user-created.json + +# Remover o usuário recém-criado +USER_ID=$(jq -r '.user.id' /tmp/user-created.json) +curl -i -X DELETE http://localhost:3000/api/admin/users/$USER_ID \ + -H "Cookie: $COOKIE" +``` + +> Os exemplos acima utilizam `jq` para facilitar a leitura. Se não estiver disponível, remova os pipes e leia o JSON bruto. + +### Convites + +```bash +# Criar convite válido por 7 dias +INVITE_EMAIL="convite.$(date +%s)@sistema.dev" +curl -s -X POST http://localhost:3000/api/admin/invites \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$INVITE_EMAIL\",\"name\":\"Convite via curl\",\"role\":\"collaborator\",\"tenantId\":\"tenant-atlas\",\"expiresInDays\":7}" \ + | tee /tmp/invite-created.json + +# Revogar convite pendente +INVITE_ID=$(jq -r '.invite.id' /tmp/invite-created.json) +curl -i -X PATCH http://localhost:3000/api/admin/invites/$INVITE_ID \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d '{"reason":"Revogado via curl"}' + +# Reativar (até 7 dias após a revogação) +curl -i -X PATCH http://localhost:3000/api/admin/invites/$INVITE_ID \ + -H "Cookie: $COOKIE" \ + -H "Content-Type: application/json" \ + -d '{"action":"reactivate"}' +``` + +> Dica: ao receber `409` na criação de convite, há outro convite pendente/aceito para o mesmo e-mail. Revogue ou remova o usuário antes. + ## Desktop (Tauri) - Tabs Radix + estilos shadcn: `apps/desktop/src/components/ui/tabs.tsx`. @@ -102,5 +159,5 @@ Artefatos: `apps/desktop/src-tauri/target/release/bundle/`. - **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. - **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`. -> Última revisão: 16/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. +> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. - **Next.js 16 (beta)**: comportamento sujeito a mudanças. Antes de subir para stable, acompanhe o changelog oficial (quebra: `revalidateTag` com segundo argumento, params assíncronos, etc.). Já estamos compatíveis com os breaking changes atuais. diff --git a/scripts/import-convex-to-prisma.mjs b/scripts/import-convex-to-prisma.mjs index 78f8245..235760f 100644 --- a/scripts/import-convex-to-prisma.mjs +++ b/scripts/import-convex-to-prisma.mjs @@ -15,6 +15,7 @@ const STAFF_ROSTER = [ { email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" }, { email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" }, { email: "renan.pac@paulicon.com.br", name: "Rever", role: "AGENT" }, + { email: "suporte@rever.com.br", name: "Telão", role: "AGENT" }, { email: "thiago.medeiros@rever.com.br", name: "Thiago Medeiros", role: "AGENT" }, { email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" }, ] diff --git a/scripts/remove-legacy-demo-users.mjs b/scripts/remove-legacy-demo-users.mjs new file mode 100644 index 0000000..a24858b --- /dev/null +++ b/scripts/remove-legacy-demo-users.mjs @@ -0,0 +1,49 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +const EMAILS_TO_REMOVE = [ + "cliente.demo@sistema.dev", + "luciana.prado@omnisaude.com.br", + "ricardo.matos@omnisaude.com.br", + "aline.rezende@atlasengenharia.com.br", + "joao.ramos@atlasengenharia.com.br", + "fernanda.lima@omnisaude.com.br", + "mariana.andrade@atlasengenharia.com.br", + "renanzera@gmail.com", +].map((email) => email.toLowerCase()) + +async function deleteAuthUserByEmail(email) { + const authUser = await prisma.authUser.findUnique({ + where: { email }, + select: { id: true, email: true }, + }) + if (!authUser) { + console.log(`ℹ️ Usuário não encontrado, ignorando: ${email}`) + return + } + + await prisma.authSession.deleteMany({ where: { userId: authUser.id } }) + await prisma.authAccount.deleteMany({ where: { userId: authUser.id } }) + await prisma.authInvite.deleteMany({ where: { email } }) + await prisma.user.deleteMany({ where: { email } }) + + await prisma.authUser.delete({ where: { id: authUser.id } }) + console.log(`🧹 Removido: ${email}`) +} + +async function main() { + for (const email of EMAILS_TO_REMOVE) { + await deleteAuthUserByEmail(email) + } +} + +main() + .catch((error) => { + console.error("Falha ao remover usuários legado", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/scripts/seed-auth.mjs b/scripts/seed-auth.mjs index d86789b..f5df9cc 100644 --- a/scripts/seed-auth.mjs +++ b/scripts/seed-auth.mjs @@ -29,55 +29,6 @@ const defaultUsers = singleUserFromEnv ?? [ role: "admin", tenantId, }, - { - email: "cliente.demo@sistema.dev", - password: "cliente123", - name: "Cliente Demo", - role: "manager", - tenantId, - }, - { - email: "mariana.andrade@atlasengenharia.com.br", - password: "manager123", - name: "Mariana Andrade", - role: "manager", - tenantId, - }, - { - email: "fernanda.lima@omnisaude.com.br", - password: "manager123", - name: "Fernanda Lima", - role: "manager", - tenantId, - }, - { - email: "joao.ramos@atlasengenharia.com.br", - password: "cliente123", - name: "João Pedro Ramos", - role: "manager", - tenantId, - }, - { - email: "aline.rezende@atlasengenharia.com.br", - password: "cliente123", - name: "Aline Rezende", - role: "manager", - tenantId, - }, - { - email: "ricardo.matos@omnisaude.com.br", - password: "cliente123", - name: "Ricardo Matos", - role: "manager", - tenantId, - }, - { - email: "luciana.prado@omnisaude.com.br", - password: "cliente123", - name: "Luciana Prado", - role: "manager", - tenantId, - }, { email: "gabriel.oliveira@rever.com.br", password: "agent123", @@ -120,6 +71,13 @@ const defaultUsers = singleUserFromEnv ?? [ role: "agent", tenantId, }, + { + email: "suporte@rever.com.br", + password: "agent123", + name: "Telão", + role: "agent", + tenantId, + }, { email: "thiago.medeiros@rever.com.br", password: "agent123", diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts index c4adc8f..ccf42a8 100644 --- a/src/app/api/admin/invites/[id]/route.ts +++ b/src/app/api/admin/invites/[id]/route.ts @@ -8,6 +8,7 @@ import { assertStaffSession } from "@/lib/auth-server" import { isAdmin } from "@/lib/authz" import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" +import { canReactivateInvite } from "@/lib/invite-policies" import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" type InviteAction = "revoke" | "reactivate" @@ -17,8 +18,6 @@ type InvitePayload = { reason?: string } -const REVOKE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 - async function syncInvite(invite: NormalizedInvite) { const convexUrl = env.NEXT_PUBLIC_CONVEX_URL if (!convexUrl) return @@ -81,8 +80,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s if (!invite.revokedAt) { return NextResponse.json({ error: "Convite revogado sem data. Não é possível reativar." }, { status: 400 }) } - const revokedAtMs = invite.revokedAt.getTime() - if (now.getTime() - revokedAtMs > REVOKE_RETENTION_MS) { + if (!canReactivateInvite({ status, revokedAt: invite.revokedAt }, now)) { return NextResponse.json({ error: "Este convite foi revogado há mais de 7 dias" }, { status: 400 }) } diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 9033a2d..62f5b67 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -22,6 +22,7 @@ import { import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" +import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies" type AdminRole = RoleOption | "machine" const NO_COMPANY_ID = "__none__" @@ -84,6 +85,20 @@ function formatRole(role: string) { return ROLE_LABELS[key] ?? role } +function formatMachinePersona(persona: string | null | undefined) { + const normalized = persona?.toLowerCase?.() ?? "" + if (normalized === "manager") return "Gestor" + if (normalized === "collaborator") return "Colaborador" + return "Sem persona" +} + +function machinePersonaBadgeVariant(persona: string | null | undefined) { + const normalized = persona?.toLowerCase?.() ?? "" + if (normalized === "manager") return "secondary" as const + if (normalized === "collaborator") return "outline" as const + return "outline" as const +} + function formatTenantLabel(tenantId: string, defaultTenantId: string) { if (!tenantId) return "Principal" if (tenantId === defaultTenantId) return "Principal" @@ -147,13 +162,6 @@ function isRestrictedRole(role?: string | null) { return normalized === "admin" || normalized === "agent" } -function canReactivateInvite(invite: AdminInvite): boolean { - if (invite.status !== "revoked" || !invite.revokedAt) return false - const revokedDate = new Date(invite.revokedAt) - const limit = Date.now() - 7 * 24 * 60 * 60 * 1000 - return revokedDate.getTime() > limit -} - export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) { const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) @@ -220,6 +228,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const [teamSearch, setTeamSearch] = useState("") const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all") const [machineSearch, setMachineSearch] = useState("") + const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all") const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createForm, setCreateForm] = useState({ name: "", @@ -250,15 +259,21 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const filteredMachineUsers = useMemo(() => { const term = machineSearch.trim().toLowerCase() - if (!term) return machineUsers return machineUsers.filter((user) => { + const persona = (user.machinePersona ?? "unassigned").toLowerCase() + if (machinePersonaFilter !== "all") { + if (machinePersonaFilter === "unassigned" && persona !== "unassigned") return false + if (machinePersonaFilter !== "unassigned" && persona !== machinePersonaFilter) return false + } + if (!term) return true return ( (user.name ?? "").toLowerCase().includes(term) || user.email.toLowerCase().includes(term) || - (user.machinePersona ?? "").toLowerCase().includes(term) + persona.includes(term) || + (extractMachineId(user.email) ?? "").toLowerCase().includes(term) ) }) - }, [machineUsers, machineSearch]) + }, [machineUsers, machinePersonaFilter, machineSearch]) useEffect(() => { void (async () => { @@ -394,7 +409,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d } async function handleReactivate(invite: AdminInvite) { - if (!canReactivateInvite(invite)) return + if (!canReactivateInvitePolicy(invite)) return if (!canManageInvite(invite.role)) { toast.error("Você não pode reativar convites deste papel") return @@ -883,16 +898,41 @@ async function handleDeleteUser() { -
+
setMachineSearch(event.target.value)} - placeholder="Buscar por hostname ou e-mail técnico" + placeholder="Buscar por hostname, e-mail ou persona" className="h-9 pl-9" />
+
+ + {(machineSearch.trim().length > 0 || machinePersonaFilter !== "all") ? ( + + ) : null} +
@@ -917,7 +957,15 @@ async function handleDeleteUser() { {user.name || "Máquina"} {user.email} - {user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"} + + {user.machinePersona ? ( + + {formatMachinePersona(user.machinePersona)} + + ) : ( + Sem persona + )} + {formatDate(user.createdAt)}
@@ -964,9 +1012,9 @@ async function handleDeleteUser() {
-
+
-
+
-
+
-
+
setTenantId(event.target.value)} placeholder="ex.: principal" + className="w-full" />

Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.

-
+
-
+
@@ -1101,7 +1150,7 @@ async function handleDeleteUser() { {revokingId === invite.id ? "Revogando..." : "Revogar"} ) : null} - {invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? ( + {invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (