feat: refine admin access management
This commit is contained in:
parent
dded6d1927
commit
a69d37a672
9 changed files with 265 additions and 83 deletions
10
agents.md
10
agents.md
|
|
@ -9,8 +9,9 @@
|
||||||
| Papel | Usuário | Senha |
|
| Papel | Usuário | Senha |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Administrador | `admin@sistema.dev` | `admin123` |
|
| Administrador | `admin@sistema.dev` | `admin123` |
|
||||||
| Agente Demo | `agente.demo@sistema.dev` | `agent123` |
|
| Painel telão | `suporte@rever.com.br` | `agent123` |
|
||||||
| Cliente Demo | `cliente.demo@sistema.dev` | `cliente123` |
|
|
||||||
|
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).
|
> 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`.
|
- 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.
|
- 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).
|
- **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`.
|
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
|
||||||
- **React / React DOM**: `18.2.0`.
|
- **React / React DOM**: `18.2.0`.
|
||||||
|
|
@ -48,6 +49,7 @@
|
||||||
### Banco de dados
|
### Banco de dados
|
||||||
- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`).
|
- 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`).
|
- 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
|
### Verificações antes de PR/deploy
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -156,4 +158,4 @@ pnpm build
|
||||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
- `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)._
|
||||||
|
|
|
||||||
61
docs/DEV.md
61
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.
|
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`.
|
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.
|
> **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.
|
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)
|
## Desktop (Tauri)
|
||||||
|
|
||||||
- Tabs Radix + estilos shadcn: `apps/desktop/src/components/ui/tabs.tsx`.
|
- 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`.
|
- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`.
|
||||||
- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const STAFF_ROSTER = [
|
||||||
{ email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" },
|
{ email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" },
|
||||||
{ email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" },
|
{ email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" },
|
||||||
{ email: "renan.pac@paulicon.com.br", name: "Rever", 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: "thiago.medeiros@rever.com.br", name: "Thiago Medeiros", role: "AGENT" },
|
||||||
{ email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" },
|
{ email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
49
scripts/remove-legacy-demo-users.mjs
Normal file
49
scripts/remove-legacy-demo-users.mjs
Normal 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()
|
||||||
|
})
|
||||||
|
|
@ -29,55 +29,6 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
role: "admin",
|
role: "admin",
|
||||||
tenantId,
|
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",
|
email: "gabriel.oliveira@rever.com.br",
|
||||||
password: "agent123",
|
password: "agent123",
|
||||||
|
|
@ -120,6 +71,13 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
role: "agent",
|
role: "agent",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
email: "suporte@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Telão",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
email: "thiago.medeiros@rever.com.br",
|
email: "thiago.medeiros@rever.com.br",
|
||||||
password: "agent123",
|
password: "agent123",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { assertStaffSession } from "@/lib/auth-server"
|
||||||
import { isAdmin } from "@/lib/authz"
|
import { isAdmin } from "@/lib/authz"
|
||||||
import { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { canReactivateInvite } from "@/lib/invite-policies"
|
||||||
import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
|
||||||
type InviteAction = "revoke" | "reactivate"
|
type InviteAction = "revoke" | "reactivate"
|
||||||
|
|
@ -17,8 +18,6 @@ type InvitePayload = {
|
||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const REVOKE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
async function syncInvite(invite: NormalizedInvite) {
|
async function syncInvite(invite: NormalizedInvite) {
|
||||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||||
if (!convexUrl) return
|
if (!convexUrl) return
|
||||||
|
|
@ -81,8 +80,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s
|
||||||
if (!invite.revokedAt) {
|
if (!invite.revokedAt) {
|
||||||
return NextResponse.json({ error: "Convite revogado sem data. Não é possível reativar." }, { status: 400 })
|
return NextResponse.json({ error: "Convite revogado sem data. Não é possível reativar." }, { status: 400 })
|
||||||
}
|
}
|
||||||
const revokedAtMs = invite.revokedAt.getTime()
|
if (!canReactivateInvite({ status, revokedAt: invite.revokedAt }, now)) {
|
||||||
if (now.getTime() - revokedAtMs > REVOKE_RETENTION_MS) {
|
|
||||||
return NextResponse.json({ error: "Este convite foi revogado há mais de 7 dias" }, { status: 400 })
|
return NextResponse.json({ error: "Este convite foi revogado há mais de 7 dias" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
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 { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||||
|
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
|
||||||
|
|
||||||
type AdminRole = RoleOption | "machine"
|
type AdminRole = RoleOption | "machine"
|
||||||
const NO_COMPANY_ID = "__none__"
|
const NO_COMPANY_ID = "__none__"
|
||||||
|
|
@ -84,6 +85,20 @@ function formatRole(role: string) {
|
||||||
return ROLE_LABELS[key] ?? role
|
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) {
|
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
|
||||||
if (!tenantId) return "Principal"
|
if (!tenantId) return "Principal"
|
||||||
if (tenantId === defaultTenantId) return "Principal"
|
if (tenantId === defaultTenantId) return "Principal"
|
||||||
|
|
@ -147,13 +162,6 @@ function isRestrictedRole(role?: string | null) {
|
||||||
return normalized === "admin" || normalized === "agent"
|
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) {
|
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
|
||||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||||
|
|
@ -220,6 +228,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 [machineSearch, setMachineSearch] = useState("")
|
const [machineSearch, setMachineSearch] = useState("")
|
||||||
|
const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all")
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -250,15 +259,21 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
|
|
||||||
const filteredMachineUsers = useMemo(() => {
|
const filteredMachineUsers = useMemo(() => {
|
||||||
const term = machineSearch.trim().toLowerCase()
|
const term = machineSearch.trim().toLowerCase()
|
||||||
if (!term) return machineUsers
|
|
||||||
return machineUsers.filter((user) => {
|
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 (
|
return (
|
||||||
(user.name ?? "").toLowerCase().includes(term) ||
|
(user.name ?? "").toLowerCase().includes(term) ||
|
||||||
user.email.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(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -394,7 +409,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReactivate(invite: AdminInvite) {
|
async function handleReactivate(invite: AdminInvite) {
|
||||||
if (!canReactivateInvite(invite)) return
|
if (!canReactivateInvitePolicy(invite)) return
|
||||||
if (!canManageInvite(invite.role)) {
|
if (!canManageInvite(invite.role)) {
|
||||||
toast.error("Você não pode reativar convites deste papel")
|
toast.error("Você não pode reativar convites deste papel")
|
||||||
return
|
return
|
||||||
|
|
@ -883,16 +898,41 @@ async function handleDeleteUser() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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" />
|
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
value={machineSearch}
|
value={machineSearch}
|
||||||
onChange={(event) => setMachineSearch(event.target.value)}
|
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"
|
className="h-9 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -917,7 +957,15 @@ async function handleDeleteUser() {
|
||||||
<tr key={user.id} className="hover:bg-slate-50">
|
<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 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.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 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">
|
||||||
|
|
@ -964,9 +1012,9 @@ async function handleDeleteUser() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleInviteSubmit}
|
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>
|
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invite-email"
|
id="invite-email"
|
||||||
|
|
@ -979,7 +1027,7 @@ async function handleDeleteUser() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2 md:col-span-2 xl:col-auto">
|
||||||
<Label htmlFor="invite-name">Nome</Label>
|
<Label htmlFor="invite-name">Nome</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invite-name"
|
id="invite-name"
|
||||||
|
|
@ -989,10 +1037,10 @@ async function handleDeleteUser() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2 md:col-span-1 xl:col-auto">
|
||||||
<Label>Papel</Label>
|
<Label>Papel</Label>
|
||||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
<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" />
|
<SelectValue placeholder="Selecione" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -1004,22 +1052,23 @@ async function handleDeleteUser() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invite-tenant"
|
id="invite-tenant"
|
||||||
value={tenantId}
|
value={tenantId}
|
||||||
onChange={(event) => setTenantId(event.target.value)}
|
onChange={(event) => setTenantId(event.target.value)}
|
||||||
placeholder="ex.: principal"
|
placeholder="ex.: principal"
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500">
|
<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.
|
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2 md:col-span-1 xl:col-auto">
|
||||||
<Label>Expira em</Label>
|
<Label>Expira em</Label>
|
||||||
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
||||||
<SelectTrigger id="invite-expiration">
|
<SelectTrigger id="invite-expiration" className="w-full">
|
||||||
<SelectValue placeholder="7 dias" />
|
<SelectValue placeholder="7 dias" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -1029,7 +1078,7 @@ async function handleDeleteUser() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
{isPending ? "Gerando..." : "Gerar convite"}
|
{isPending ? "Gerando..." : "Gerar convite"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1101,7 +1150,7 @@ async function handleDeleteUser() {
|
||||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? (
|
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
27
src/lib/invite-policies.ts
Normal file
27
src/lib/invite-policies.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
41
tests/invite-policies.test.ts
Normal file
41
tests/invite-policies.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue