Allow staff access to admin UI with scoped permissions
This commit is contained in:
parent
d6956cd99d
commit
cf31158a9e
11 changed files with 155 additions and 52 deletions
|
|
@ -1,17 +1,18 @@
|
||||||
import { ReactNode } from "react"
|
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 dynamic = "force-dynamic"
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
if (process.env.NODE_ENV === "production") {
|
const session = await requireAuthenticatedSession()
|
||||||
await requireAdminSession()
|
const role = session.user.role ?? "agent"
|
||||||
} else {
|
if (!isStaff(role)) {
|
||||||
// Em desenvolvimento, basta estar autenticado para acessar a área admin,
|
// agentes (staff) podem acessar; visitantes sem papel de staff caem no portal.
|
||||||
// facilitando validação local sem depender do papel exato do usuário.
|
redirect("/portal")
|
||||||
await requireAuthenticatedSession()
|
|
||||||
}
|
}
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
import { getServerSession } from "@/lib/auth-server"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
@ -86,6 +87,8 @@ export default async function AdminPage() {
|
||||||
void events
|
void events
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
const session = await getServerSession()
|
||||||
|
const viewerRole = session?.user.role ?? "agent"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|
@ -102,6 +105,7 @@ export default async function AdminPage() {
|
||||||
initialInvites={invitesForClient}
|
initialInvites={invitesForClient}
|
||||||
roleOptions={ROLE_OPTIONS}
|
roleOptions={ROLE_OPTIONS}
|
||||||
defaultTenantId={DEFAULT_TENANT_ID}
|
defaultTenantId={DEFAULT_TENANT_ID}
|
||||||
|
viewerRole={viewerRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@ import { NextResponse } from "next/server"
|
||||||
|
|
||||||
import { Prisma } from "@prisma/client"
|
import { Prisma } from "@prisma/client"
|
||||||
import { prisma } from "@/lib/prisma"
|
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 const runtime = "nodejs"
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
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 (!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 { id } = await params
|
||||||
const raw = (await request.json()) as Partial<{
|
const raw = (await request.json()) as Partial<{
|
||||||
name: string
|
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 }> }) {
|
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 (!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 { id } = await params
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({
|
const company = await prisma.company.findUnique({
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma"
|
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 const runtime = "nodejs"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
|
||||||
const companies = await prisma.company.findMany({
|
const companies = await prisma.company.findMany({
|
||||||
|
|
@ -16,8 +17,11 @@ export async function GET() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 (!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<{
|
const body = (await request.json()) as Partial<{
|
||||||
name: string
|
name: string
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { Prisma } from "@prisma/client"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
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 { env } from "@/lib/env"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
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 }> }) {
|
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
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 })
|
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 now = new Date()
|
||||||
const status = computeInviteStatus(invite, now)
|
const status = computeInviteStatus(invite, now)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { Prisma } from "@prisma/client"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
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 { 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 { env } from "@/lib/env"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
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() {
|
export async function GET() {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +127,7 @@ type CreateInvitePayload = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
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 name = typeof body.name === "string" ? body.name.trim() : undefined
|
||||||
const role = normalizeRole(body.role)
|
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 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
|
const expiresInDays = Number.isFinite(body.expiresInDays) ? Math.max(1, Number(body.expiresInDays)) : DEFAULT_EXPIRATION_DAYS
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
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"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
@ -17,7 +17,7 @@ const schema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { NextResponse } from "next/server"
|
||||||
import { hashPassword } from "better-auth/crypto"
|
import { hashPassword } from "better-auth/crypto"
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma"
|
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) {
|
function generatePassword(length = 12) {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
@ -19,10 +20,11 @@ export const runtime = "nodejs"
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
const sessionIsAdmin = isAdmin(session.user.role)
|
||||||
|
|
||||||
const user = await prisma.authUser.findUnique({
|
const user = await prisma.authUser.findUnique({
|
||||||
where: { id },
|
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 })
|
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 })
|
return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { api } from "@/convex/_generated/api"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { assertAdminSession } from "@/lib/auth-server"
|
import { assertStaffSession } from "@/lib/auth-server"
|
||||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
||||||
|
|
||||||
function normalizeRole(input: string | null | undefined): RoleOption {
|
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||||
const candidate = (input ?? "agent").toLowerCase() as 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"
|
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 const runtime = "nodejs"
|
||||||
|
|
||||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
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 }> }) {
|
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
const sessionIsAdmin = isAdmin(session.user.role)
|
||||||
|
|
||||||
const payload = (await request.json().catch(() => null)) as {
|
const payload = (await request.json().catch(() => null)) as {
|
||||||
name?: string
|
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 })
|
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") {
|
if ((user.role ?? "").toLowerCase() === "machine") {
|
||||||
return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 })
|
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) {
|
if (nextEmail !== user.email) {
|
||||||
const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } })
|
const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } })
|
||||||
if (conflict && conflict.id !== user.id) {
|
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 }> }) {
|
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
const sessionIsAdmin = isAdmin(session.user.role)
|
||||||
|
|
||||||
const target = await prisma.authUser.findUnique({
|
const target = await prisma.authUser.findUnique({
|
||||||
where: { id },
|
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 })
|
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") {
|
if (target.role === "machine") {
|
||||||
return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 })
|
return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import type { UserRole } from "@prisma/client"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { assertAdminSession } from "@/lib/auth-server"
|
import { assertStaffSession } from "@/lib/auth-server"
|
||||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ function generatePassword(length = 12) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
@ -55,10 +55,13 @@ export async function GET() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await assertAdminSession()
|
const session = await assertStaffSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
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)
|
const payload = await request.json().catch(() => null)
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object") {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react"
|
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||||
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -67,6 +67,7 @@ type Props = {
|
||||||
initialInvites: AdminInvite[]
|
initialInvites: AdminInvite[]
|
||||||
roleOptions: readonly AdminRole[]
|
roleOptions: readonly AdminRole[]
|
||||||
defaultTenantId: string
|
defaultTenantId: string
|
||||||
|
viewerRole: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
|
@ -140,6 +141,11 @@ function extractMachineId(email: string): string | null {
|
||||||
return match ? match[1] : 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 {
|
function canReactivateInvite(invite: AdminInvite): boolean {
|
||||||
if (invite.status !== "revoked" || !invite.revokedAt) return false
|
if (invite.status !== "revoked" || !invite.revokedAt) return false
|
||||||
const revokedDate = new Date(invite.revokedAt)
|
const revokedDate = new Date(invite.revokedAt)
|
||||||
|
|
@ -147,7 +153,7 @@ function canReactivateInvite(invite: AdminInvite): boolean {
|
||||||
return revokedDate.getTime() > limit
|
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<AdminUser[]>(initialUsers)
|
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||||
|
|
@ -189,6 +195,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
|
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
|
||||||
[invites, revokeDialogInviteId]
|
[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<readonly AdminRole[]>(() => {
|
const normalizedRoles = useMemo<readonly AdminRole[]>(() => {
|
||||||
return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[]
|
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<RoleOption>()
|
const unique = new Set<RoleOption>()
|
||||||
normalizedRoles.forEach((roleOption) => {
|
normalizedRoles.forEach((roleOption) => {
|
||||||
const coerced = coerceRole(roleOption)
|
const coerced = coerceRole(roleOption)
|
||||||
|
if (!viewerIsAdmin && isRestrictedRole(coerced)) return
|
||||||
unique.add(coerced)
|
unique.add(coerced)
|
||||||
})
|
})
|
||||||
return Array.from(unique)
|
return Array.from(unique)
|
||||||
}, [normalizedRoles])
|
}, [normalizedRoles, viewerIsAdmin])
|
||||||
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
||||||
const machineUsers = 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canManageInvite(revokeCandidate.role)) {
|
||||||
|
toast.error("Você não pode revogar convites deste papel")
|
||||||
|
setRevokeDialogInviteId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setRevokingId(revokeCandidate.id)
|
setRevokingId(revokeCandidate.id)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, {
|
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) {
|
async function handleReactivate(invite: AdminInvite) {
|
||||||
if (!canReactivateInvite(invite)) return
|
if (!canReactivateInvite(invite)) return
|
||||||
|
if (!canManageInvite(invite.role)) {
|
||||||
|
toast.error("Você não pode reativar convites deste papel")
|
||||||
|
return
|
||||||
|
}
|
||||||
setReactivatingId(invite.id)
|
setReactivatingId(invite.id)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/invites/${invite.id}`, {
|
const response = await fetch(`/api/admin/invites/${invite.id}`, {
|
||||||
|
|
@ -433,6 +454,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
|
|
||||||
async function handleResetPassword() {
|
async function handleResetPassword() {
|
||||||
if (!editUser) return
|
if (!editUser) return
|
||||||
|
if (!canManageUser(editUser.role)) {
|
||||||
|
toast.error("Você não pode gerar senha para este usuário")
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsResettingPassword(true)
|
setIsResettingPassword(true)
|
||||||
toast.loading("Gerando nova senha...", { id: "reset-password" })
|
toast.loading("Gerando nova senha...", { id: "reset-password" })
|
||||||
try {
|
try {
|
||||||
|
|
@ -457,12 +482,18 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMachineEditing = editUser?.role === "machine"
|
const isMachineEditing = editUser?.role === "machine"
|
||||||
|
const editingRestricted = editUser ? !canManageUser(editUser.role) : false
|
||||||
const companyOptions = useMemo(
|
const companyOptions = useMemo(
|
||||||
() => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies],
|
() => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies],
|
||||||
[companies]
|
[companies]
|
||||||
)
|
)
|
||||||
async function handleDeleteUser() {
|
async function handleDeleteUser() {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
|
if (!canManageUser(deleteTarget.role)) {
|
||||||
|
toast.error("Você não pode remover esse usuário")
|
||||||
|
setDeleteUserId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsDeletingUser(true)
|
setIsDeletingUser(true)
|
||||||
const isMachine = deleteTarget.role === "machine"
|
const isMachine = deleteTarget.role === "machine"
|
||||||
|
|
||||||
|
|
@ -547,14 +578,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<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">
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canManageUser(user.role)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canManageUser(user.role)) return
|
||||||
|
setEditUserId(user.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-red-600 hover:bg-red-500/10"
|
className="text-red-600 hover:bg-red-500/10"
|
||||||
onClick={() => setDeleteUserId(user.id)}
|
disabled={!canManageUser(user.role)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canManageUser(user.role)) return
|
||||||
|
setDeleteUserId(user.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Remover
|
Remover
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -806,7 +849,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||||
Copiar link
|
Copiar link
|
||||||
</Button>
|
</Button>
|
||||||
{invite.status === "pending" ? (
|
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -817,7 +860,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{invite.status === "revoked" && canReactivateInvite(invite) ? (
|
{invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -855,6 +898,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
|
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<form onSubmit={handleSaveUser} className="space-y-6">
|
<form onSubmit={handleSaveUser} className="space-y-6">
|
||||||
|
{editingRestricted ? (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||||
|
Você pode visualizar este perfil, mas apenas administradores podem alterá-lo.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Nome</Label>
|
<Label>Nome</Label>
|
||||||
|
|
@ -862,7 +910,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
|
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
placeholder="Nome completo"
|
placeholder="Nome completo"
|
||||||
disabled={isSavingUser || isMachineEditing}
|
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -872,7 +920,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
value={editForm.email}
|
value={editForm.email}
|
||||||
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
|
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||||
type="email"
|
type="email"
|
||||||
disabled={isSavingUser || isMachineEditing}
|
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -881,7 +929,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<Select
|
<Select
|
||||||
value={editForm.role}
|
value={editForm.role}
|
||||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
||||||
disabled={isSavingUser || isMachineEditing}
|
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione" />
|
<SelectValue placeholder="Selecione" />
|
||||||
|
|
@ -901,7 +949,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
value={editForm.tenantId}
|
value={editForm.tenantId}
|
||||||
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
||||||
placeholder="tenant-atlas"
|
placeholder="tenant-atlas"
|
||||||
disabled={isSavingUser || isMachineEditing}
|
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|
@ -911,7 +959,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setEditForm((prev) => ({ ...prev, companyId: value === NO_COMPANY_ID ? "" : value }))
|
setEditForm((prev) => ({ ...prev, companyId: value === NO_COMPANY_ID ? "" : value }))
|
||||||
}
|
}
|
||||||
disabled={isSavingUser}
|
disabled={isSavingUser || editingRestricted}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione" />
|
<SelectValue placeholder="Selecione" />
|
||||||
|
|
@ -937,11 +985,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<p className="font-medium text-neutral-900">Gerar nova senha</p>
|
<p className="font-medium text-neutral-900">Gerar nova senha</p>
|
||||||
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
|
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
||||||
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{passwordPreview ? (
|
{passwordPreview ? (
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
|
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
|
||||||
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
|
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -962,7 +1010,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
|
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSavingUser || isMachineEditing} className="sm:ml-auto">
|
<Button type="submit" disabled={isSavingUser || isMachineEditing || editingRestricted} className="sm:ml-auto">
|
||||||
{isSavingUser ? "Salvando..." : "Salvar alterações"}
|
{isSavingUser ? "Salvando..." : "Salvar alterações"}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue