feat: refine admin access management

This commit is contained in:
Esdras Renan 2025-10-18 01:32:19 -03:00
parent dded6d1927
commit a69d37a672
9 changed files with 265 additions and 83 deletions

View file

@ -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)._

View file

@ -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=<valor>"`.
### 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.

View file

@ -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" },
]

View file

@ -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()
})

View file

@ -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",

View file

@ -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 })
}

View file

@ -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<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(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() {
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={machineSearch}
onChange={(event) => 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"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={machinePersonaFilter} onValueChange={(value) => setMachinePersonaFilter(value as typeof machinePersonaFilter)}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Todas as personas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as personas</SelectItem>
<SelectItem value="manager">Gestor</SelectItem>
<SelectItem value="collaborator">Colaborador</SelectItem>
<SelectItem value="unassigned">Sem persona</SelectItem>
</SelectContent>
</Select>
{(machineSearch.trim().length > 0 || machinePersonaFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setMachineSearch("")
setMachinePersonaFilter("all")
}}
>
Limpar filtros
</Button>
) : null}
</div>
</div>
<Card>
<CardHeader>
@ -917,7 +957,15 @@ async function handleDeleteUser() {
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
<td className="py-3 pr-4 text-neutral-600">
{user.machinePersona ? (
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)}
</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
@ -964,9 +1012,9 @@ async function handleDeleteUser() {
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
className="grid gap-4 md:grid-cols-2 xl:grid-cols-[minmax(0,2.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.6fr)_minmax(0,1.2fr)_auto]"
>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
@ -979,7 +1027,7 @@ async function handleDeleteUser() {
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
@ -989,10 +1037,10 @@ async function handleDeleteUser() {
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role" className="h-9">
<SelectTrigger id="invite-role" className="h-9 w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
@ -1004,22 +1052,23 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
placeholder="ex.: principal"
className="w-full"
/>
<p className="text-xs text-neutral-500">
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
</p>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectTrigger id="invite-expiration" className="w-full">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
@ -1029,7 +1078,7 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<div className="flex items-end md:col-span-2 xl:col-auto xl:justify-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
@ -1101,7 +1150,7 @@ async function handleDeleteUser() {
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
{invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? (
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
<Button
variant="outline"
size="sm"

View file

@ -0,0 +1,27 @@
export const INVITE_REACTIVATION_WINDOW_DAYS = 7
export const INVITE_REACTIVATION_WINDOW_MS = INVITE_REACTIVATION_WINDOW_DAYS * 24 * 60 * 60 * 1000
function normalizeRevokedAt(value: Date | string | null | undefined) {
if (!value) return null
if (value instanceof Date) return value
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
export function isWithinInviteReactivationWindow(
revokedAt: Date | string | null | undefined,
reference: Date = new Date()
) {
const timestamp = normalizeRevokedAt(revokedAt)
if (!timestamp) return false
return reference.getTime() - timestamp.getTime() <= INVITE_REACTIVATION_WINDOW_MS
}
export function canReactivateInvite(
invite: { status: string | null | undefined; revokedAt: Date | string | null | undefined },
reference: Date = new Date()
) {
if ((invite.status ?? "").toLowerCase() !== "revoked") return false
return isWithinInviteReactivationWindow(invite.revokedAt, reference)
}

View file

@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest"
import {
canReactivateInvite,
isWithinInviteReactivationWindow,
INVITE_REACTIVATION_WINDOW_DAYS,
} from "@/lib/invite-policies"
describe("invite reactivation policies", () => {
test("isWithinInviteReactivationWindow respects configured window", () => {
const now = new Date("2025-10-18T12:00:00Z")
const insideWindow = new Date(now.getTime() - (INVITE_REACTIVATION_WINDOW_DAYS - 1) * 24 * 60 * 60 * 1000)
const outsideWindow = new Date(now.getTime() - (INVITE_REACTIVATION_WINDOW_DAYS + 1) * 24 * 60 * 60 * 1000)
expect(isWithinInviteReactivationWindow(insideWindow, now)).toBe(true)
expect(isWithinInviteReactivationWindow(outsideWindow, now)).toBe(false)
expect(isWithinInviteReactivationWindow(null, now)).toBe(false)
expect(isWithinInviteReactivationWindow("invalid-date", now)).toBe(false)
})
test("canReactivateInvite returns true only for revoked invites within window", () => {
const now = new Date("2025-10-18T12:00:00Z")
const revokedRecently = {
status: "revoked",
revokedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000),
}
const revokedLongAgo = {
status: "revoked",
revokedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000),
}
const pendingInvite = {
status: "pending",
revokedAt: null,
}
expect(canReactivateInvite(revokedRecently, now)).toBe(true)
expect(canReactivateInvite(revokedLongAgo, now)).toBe(false)
expect(canReactivateInvite(pendingInvite, now)).toBe(false)
})
})