From cf31158a9e006a887df694b6ac3128a6bc58ebea Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 13 Oct 2025 16:30:52 -0300 Subject: [PATCH] Allow staff access to admin UI with scoped permissions --- src/app/admin/layout.tsx | 15 ++-- src/app/admin/page.tsx | 4 + src/app/api/admin/companies/[id]/route.ts | 13 ++- src/app/api/admin/companies/route.ts | 10 ++- src/app/api/admin/invites/[id]/route.ts | 10 ++- src/app/api/admin/invites/route.ts | 12 ++- src/app/api/admin/machines/access/route.ts | 4 +- .../admin/users/[id]/reset-password/route.ts | 13 ++- src/app/api/admin/users/[id]/route.ts | 29 +++++-- src/app/api/admin/users/route.ts | 11 ++- src/components/admin/admin-users-manager.tsx | 86 +++++++++++++++---- 11 files changed, 155 insertions(+), 52 deletions(-) diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 11ef447..93d629f 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,17 +1,18 @@ import { ReactNode } from "react" +import { redirect } from "next/navigation" -import { requireAdminSession, requireAuthenticatedSession } from "@/lib/auth-server" +import { requireAuthenticatedSession } from "@/lib/auth-server" +import { isStaff } from "@/lib/authz" export const dynamic = "force-dynamic" export const runtime = "nodejs" export default async function AdminLayout({ children }: { children: ReactNode }) { - if (process.env.NODE_ENV === "production") { - await requireAdminSession() - } else { - // Em desenvolvimento, basta estar autenticado para acessar a área admin, - // facilitando validação local sem depender do papel exato do usuário. - await requireAuthenticatedSession() + const session = await requireAuthenticatedSession() + const role = session.user.role ?? "agent" + if (!isStaff(role)) { + // agentes (staff) podem acessar; visitantes sem papel de staff caem no portal. + redirect("/portal") } return <>{children} } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 203d318..457e60d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { prisma } from "@/lib/prisma" import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" +import { getServerSession } from "@/lib/auth-server" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -86,6 +87,8 @@ export default async function AdminPage() { void events return rest }) + const session = await getServerSession() + const viewerRole = session?.user.role ?? "agent" return ( diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index d747d1c..57d2420 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -2,13 +2,17 @@ import { NextResponse } from "next/server" import { Prisma } from "@prisma/client" import { prisma } from "@/lib/prisma" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" export const runtime = "nodejs" export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem editar empresas" }, { status: 403 }) + } const { id } = await params const raw = (await request.json()) as Partial<{ name: string @@ -49,8 +53,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem excluir empresas" }, { status: 403 }) + } const { id } = await params const company = await prisma.company.findUnique({ diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index 92b9cd9..173bd7c 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" export const runtime = "nodejs" export async function GET() { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) const companies = await prisma.company.findMany({ @@ -16,8 +17,11 @@ export async function GET() { } export async function POST(request: Request) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem criar empresas" }, { status: 403 }) + } const body = (await request.json()) as Partial<{ name: string diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts index 67262c4..c4adc8f 100644 --- a/src/app/api/admin/invites/[id]/route.ts +++ b/src/app/api/admin/invites/[id]/route.ts @@ -4,7 +4,8 @@ import { Prisma } from "@prisma/client" import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" @@ -43,7 +44,7 @@ async function syncInvite(invite: NormalizedInvite) { export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -61,6 +62,11 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 }) } + const inviteRole = invite.role?.toLowerCase?.() + if (!isAdmin(session.user.role) && inviteRole && ["admin", "agent"].includes(inviteRole)) { + return NextResponse.json({ error: "Permissão insuficiente para alterar convites de administradores ou agentes" }, { status: 403 }) + } + const now = new Date() const status = computeInviteStatus(invite, now) diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index fa67381..b395387 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -5,9 +5,9 @@ import { Prisma } from "@prisma/client" import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" +import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" @@ -91,7 +91,7 @@ function buildInvitePayload(invite: InviteWithEvents, now: Date) { } export async function GET() { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -127,7 +127,7 @@ type CreateInvitePayload = { } export async function POST(request: Request) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -144,6 +144,10 @@ export async function POST(request: Request) { const name = typeof body.name === "string" ? body.name.trim() : undefined const role = normalizeRole(body.role) + const isSessionAdmin = isAdmin(session.user.role) + if (!isSessionAdmin && !["manager", "collaborator"].includes(role)) { + return NextResponse.json({ error: "Agentes só podem convidar gestores ou colaboradores" }, { status: 403 }) + } const tenantId = typeof body.tenantId === "string" && body.tenantId.trim() ? body.tenantId.trim() : session.user.tenantId || DEFAULT_TENANT_ID const expiresInDays = Number.isFinite(body.expiresInDays) ? Math.max(1, Number(body.expiresInDays)) : DEFAULT_EXPIRATION_DAYS diff --git a/src/app/api/admin/machines/access/route.ts b/src/app/api/admin/machines/access/route.ts index 1393777..e1baf18 100644 --- a/src/app/api/admin/machines/access/route.ts +++ b/src/app/api/admin/machines/access/route.ts @@ -4,7 +4,7 @@ import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" export const runtime = "nodejs" @@ -17,7 +17,7 @@ const schema = z.object({ }) export async function POST(request: Request) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index bea9a58..1c54255 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -3,7 +3,8 @@ import { NextResponse } from "next/server" import { hashPassword } from "better-auth/crypto" import { prisma } from "@/lib/prisma" -import { assertAdminSession } from "@/lib/auth-server" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" function generatePassword(length = 12) { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" @@ -19,10 +20,11 @@ export const runtime = "nodejs" export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } + const sessionIsAdmin = isAdmin(session.user.role) const user = await prisma.authUser.findUnique({ where: { id }, @@ -33,7 +35,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) } - if ((user.role ?? "").toLowerCase() === "machine") { + const targetRole = (user.role ?? "").toLowerCase() + if (!sessionIsAdmin && (targetRole === "admin" || targetRole === "agent")) { + return NextResponse.json({ error: "Você não pode redefinir a senha desse usuário" }, { status: 403 }) + } + + if (targetRole === "machine") { return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 }) } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index b4503ed..af112b6 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -5,8 +5,8 @@ import { api } from "@/convex/_generated/api" import { ConvexHttpClient } from "convex/browser" import { prisma } from "@/lib/prisma" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { assertAdminSession } from "@/lib/auth-server" -import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" +import { assertStaffSession } from "@/lib/auth-server" +import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" function normalizeRole(input: string | null | undefined): RoleOption { const candidate = (input ?? "agent").toLowerCase() as RoleOption @@ -20,11 +20,16 @@ function mapToUserRole(role: RoleOption): UserRole { return USER_ROLE_OPTIONS.includes(candidate) ? candidate : "AGENT" } +function canManageRole(role: string | null | undefined) { + const normalized = (role ?? "").toLowerCase() + return normalized !== "admin" && normalized !== "agent" +} + export const runtime = "nodejs" export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -73,10 +78,11 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } + const sessionIsAdmin = isAdmin(session.user.role) const payload = (await request.json().catch(() => null)) as { name?: string @@ -105,10 +111,18 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) } + if (!sessionIsAdmin && !canManageRole(user.role)) { + return NextResponse.json({ error: "Você não pode editar esse usuário" }, { status: 403 }) + } + if ((user.role ?? "").toLowerCase() === "machine") { return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 }) } + if (!sessionIsAdmin && !canManageRole(nextRole)) { + return NextResponse.json({ error: "Papel inválido para este perfil" }, { status: 403 }) + } + if (nextEmail !== user.email) { const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } }) if (conflict && conflict.id !== user.id) { @@ -210,10 +224,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } + const sessionIsAdmin = isAdmin(session.user.role) const target = await prisma.authUser.findUnique({ where: { id }, @@ -224,6 +239,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 }) } + if (!sessionIsAdmin && !canManageRole(target.role)) { + return NextResponse.json({ error: "Você não pode remover esse usuário" }, { status: 403 }) + } + if (target.role === "machine") { return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 }) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index b628000..40f27fa 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -8,8 +8,8 @@ import type { UserRole } from "@prisma/client" import { api } from "@/convex/_generated/api" import { prisma } from "@/lib/prisma" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { assertAdminSession } from "@/lib/auth-server" -import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" +import { assertStaffSession } from "@/lib/auth-server" +import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" export const runtime = "nodejs" @@ -33,7 +33,7 @@ function generatePassword(length = 12) { } export async function GET() { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -55,10 +55,13 @@ export async function GET() { } export async function POST(request: Request) { - const session = await assertAdminSession() + const session = await assertStaffSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem criar usuários" }, { status: 403 }) + } const payload = await request.json().catch(() => null) if (!payload || typeof payload !== "object") { diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index cdf39e1..7d446f2 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -1,7 +1,7 @@ "use client" import Link from "next/link" -import { useEffect, useMemo, useState, useTransition } from "react" +import { useCallback, useEffect, useMemo, useState, useTransition } from "react" import { toast } from "sonner" @@ -67,6 +67,7 @@ type Props = { initialInvites: AdminInvite[] roleOptions: readonly AdminRole[] defaultTenantId: string + viewerRole: string } const ROLE_LABELS: Record = { @@ -140,6 +141,11 @@ function extractMachineId(email: string): string | null { return match ? match[1] : null } +function isRestrictedRole(role?: string | null) { + const normalized = (role ?? "").toLowerCase() + return normalized === "admin" || normalized === "agent" +} + function canReactivateInvite(invite: AdminInvite): boolean { if (invite.status !== "revoked" || !invite.revokedAt) return false const revokedDate = new Date(invite.revokedAt) @@ -147,7 +153,7 @@ function canReactivateInvite(invite: AdminInvite): boolean { return revokedDate.getTime() > limit } -export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) { +export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) { const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) const [companies, setCompanies] = useState([]) @@ -189,6 +195,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d () => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null, [invites, revokeDialogInviteId] ) + const viewerRoleNormalized = viewerRole?.toLowerCase?.() ?? "agent" + const viewerIsAdmin = viewerRoleNormalized === "admin" + const canManageUser = useCallback((role?: string | null) => viewerIsAdmin || !isRestrictedRole(role), [viewerIsAdmin]) + const canManageInvite = useCallback((role: RoleOption) => viewerIsAdmin || !["admin", "agent"].includes(role), [viewerIsAdmin]) const normalizedRoles = useMemo(() => { return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[] @@ -197,10 +207,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const unique = new Set() normalizedRoles.forEach((roleOption) => { const coerced = coerceRole(roleOption) + if (!viewerIsAdmin && isRestrictedRole(coerced)) return unique.add(coerced) }) return Array.from(unique) - }, [normalizedRoles]) + }, [normalizedRoles, viewerIsAdmin]) const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users]) const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users]) @@ -297,6 +308,12 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d return } + if (!canManageInvite(revokeCandidate.role)) { + toast.error("Você não pode revogar convites deste papel") + setRevokeDialogInviteId(null) + return + } + setRevokingId(revokeCandidate.id) try { const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, { @@ -325,6 +342,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d async function handleReactivate(invite: AdminInvite) { if (!canReactivateInvite(invite)) return + if (!canManageInvite(invite.role)) { + toast.error("Você não pode reativar convites deste papel") + return + } setReactivatingId(invite.id) try { const response = await fetch(`/api/admin/invites/${invite.id}`, { @@ -433,6 +454,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d async function handleResetPassword() { if (!editUser) return + if (!canManageUser(editUser.role)) { + toast.error("Você não pode gerar senha para este usuário") + return + } setIsResettingPassword(true) toast.loading("Gerando nova senha...", { id: "reset-password" }) try { @@ -457,12 +482,18 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d } const isMachineEditing = editUser?.role === "machine" + const editingRestricted = editUser ? !canManageUser(editUser.role) : false const companyOptions = useMemo( () => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies], [companies] ) - async function handleDeleteUser() { +async function handleDeleteUser() { if (!deleteTarget) return + if (!canManageUser(deleteTarget.role)) { + toast.error("Você não pode remover esse usuário") + setDeleteUserId(null) + return + } setIsDeletingUser(true) const isMachine = deleteTarget.role === "machine" @@ -547,14 +578,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {formatDate(user.createdAt)}
- @@ -806,7 +849,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d - {invite.status === "pending" ? ( + {invite.status === "pending" && canManageInvite(invite.role) ? (