feat: overhaul admin user management and desktop UX

This commit is contained in:
Esdras Renan 2025-10-13 10:36:38 -03:00
parent 7d6f3bea01
commit ecad81b0ea
16 changed files with 1546 additions and 395 deletions

View file

@ -0,0 +1,66 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { prisma } from "@/lib/prisma"
import { assertAdminSession } from "@/lib/auth-server"
function generatePassword(length = 12) {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
let result = ""
for (let index = 0; index < length; index += 1) {
const randomIndex = Math.floor(Math.random() * alphabet.length)
result += alphabet[randomIndex]
}
return result
}
export const runtime = "nodejs"
export async function POST(request: Request, { params }: { params: { id: string } }) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const user = await prisma.authUser.findUnique({
where: { id: params.id },
select: { id: true, role: true },
})
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
}
if ((user.role ?? "").toLowerCase() === "machine") {
return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 })
}
const body = (await request.json().catch(() => null)) as { password?: string } | null
const temporaryPassword = body?.password?.trim() || generatePassword()
const hashedPassword = await hashPassword(temporaryPassword)
const credentialAccount = await prisma.authAccount.findFirst({
where: { userId: user.id, providerId: "credential" },
})
if (credentialAccount) {
await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { password: hashedPassword } })
} else {
// se a conta não existir, cria automaticamente
const authUser = await prisma.authUser.findUnique({ where: { id: user.id } })
if (!authUser) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
}
await prisma.authAccount.create({
data: {
userId: user.id,
providerId: "credential",
accountId: authUser.email,
password: hashedPassword,
},
})
}
return NextResponse.json({ temporaryPassword })
}

View file

@ -0,0 +1,207 @@
import { NextResponse } from "next/server"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
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"
function normalizeRole(input: string | null | undefined): RoleOption {
const candidate = (input ?? "agent").toLowerCase() as RoleOption
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
}
function mapToUserRole(role: RoleOption) {
const value = role.toUpperCase()
if (["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"].includes(value)) {
return value
}
return "AGENT"
}
export const runtime = "nodejs"
export async function GET(_: Request, { params }: { params: { id: string } }) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const user = await prisma.authUser.findUnique({
where: { id: params.id },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
machinePersona: true,
createdAt: true,
updatedAt: true,
},
})
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
}
const domain = await prisma.user.findUnique({
where: { email: user.email },
select: {
companyId: true,
company: { select: { name: true } },
},
})
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name ?? "",
role: normalizeRole(user.role),
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
companyId: domain?.companyId ?? null,
companyName: domain?.company?.name ?? null,
machinePersona: user.machinePersona ?? null,
},
})
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const payload = (await request.json().catch(() => null)) as {
name?: string
email?: string
role?: RoleOption
tenantId?: string
companyId?: string | null
} | null
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const user = await prisma.authUser.findUnique({ where: { id: params.id } })
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
}
const nextName = payload.name?.trim() ?? user.name ?? ""
const nextEmail = (payload.email ?? user.email).trim().toLowerCase()
const nextRole = normalizeRole(payload.role ?? user.role)
const nextTenant = (payload.tenantId ?? user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID
const companyId = payload.companyId ? payload.companyId : null
if (!nextEmail || !nextEmail.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
if (nextRole === "machine") {
return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 })
}
if (nextEmail !== user.email) {
const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } })
if (conflict && conflict.id !== user.id) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
}
}
const updated = await prisma.authUser.update({
where: { id: user.id },
data: {
name: nextName,
email: nextEmail,
role: nextRole,
tenantId: nextTenant,
},
})
if (nextEmail !== user.email) {
const credentialAccount = await prisma.authAccount.findFirst({
where: { userId: user.id, providerId: "credential" },
})
if (credentialAccount) {
await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { accountId: nextEmail } })
}
}
const previousEmail = user.email
const domainUser = await prisma.user.findUnique({ where: { email: previousEmail } })
const companyData = companyId
? await prisma.company.findUnique({ where: { id: companyId } })
: null
if (companyId && !companyData) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 400 })
}
if (domainUser) {
await prisma.user.update({
where: { id: domainUser.id },
data: {
email: nextEmail,
name: nextName || domainUser.name,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
})
} else {
await prisma.user.upsert({
where: { email: nextEmail },
update: {
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
create: {
email: nextEmail,
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
})
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId: nextTenant,
email: nextEmail,
name: nextName || nextEmail,
avatarUrl: updated.avatarUrl ?? undefined,
role: nextRole.toUpperCase(),
companyId: companyId ? (companyId as Id<"companies">) : undefined,
})
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({
user: {
id: updated.id,
email: nextEmail,
name: nextName,
role: nextRole,
tenantId: nextTenant,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt?.toISOString() ?? null,
companyId,
companyName: companyData?.name ?? null,
machinePersona: updated.machinePersona ?? null,
},
})
}

View file

@ -101,6 +101,21 @@ export async function POST(request: Request) {
},
})
await prisma.user.upsert({
where: { email: user.email },
update: {
name: user.name ?? user.email,
role: role.toUpperCase(),
tenantId,
},
create: {
email: user.email,
name: user.name ?? user.email,
role: role.toUpperCase(),
tenantId,
},
})
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {

View file

@ -0,0 +1,191 @@
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
import { normalizeSlug, slugify } from "@/lib/slug"
import { prisma } from "@/lib/prisma"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
export const runtime = "nodejs"
const CORS_METHODS = "GET, POST, OPTIONS"
function extractSecret(request: Request, url: URL): string | null {
const header =
request.headers.get("x-machine-secret") ??
request.headers.get("x-machine-provisioning-secret") ??
request.headers.get("x-provisioning-secret")
if (header && header.trim()) return header.trim()
const auth = request.headers.get("authorization")
if (auth && auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim()
if (token) return token
}
const querySecret = url.searchParams.get("provisioningSecret")
if (querySecret && querySecret.trim()) return querySecret.trim()
return null
}
async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string }) {
if (!env.NEXT_PUBLIC_CONVEX_URL) {
throw new Error("Convex não configurado")
}
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.companies.ensureProvisioned, params)
}
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
export async function GET(request: Request) {
const url = new URL(request.url)
const origin = request.headers.get("origin")
const secret = extractSecret(request, url)
const expectedSecret = env.MACHINE_PROVISIONING_SECRET
if (!expectedSecret) {
return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS)
}
if (!secret || secret !== expectedSecret) {
return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS)
}
const tenantIdRaw = url.searchParams.get("tenantId") ?? ""
const tenantId = tenantIdRaw.trim() || DEFAULT_TENANT_ID
const search = url.searchParams.get("search")?.trim() ?? ""
try {
const companies = await prisma.company.findMany({
where: {
tenantId,
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ slug: { contains: normalizeSlug(search) ?? slugify(search), mode: "insensitive" } },
],
}
: {}),
},
orderBy: { name: "asc" },
take: 20,
})
return jsonWithCors(
{
companies: companies.map((company) => ({
id: company.id,
tenantId: company.tenantId,
name: company.name,
slug: company.slug,
})),
},
200,
origin,
CORS_METHODS
)
} catch (error) {
console.error("[machines.companies] Falha ao listar empresas", error)
return jsonWithCors({ error: "Falha ao buscar empresas" }, 500, origin, CORS_METHODS)
}
}
export async function POST(request: Request) {
const url = new URL(request.url)
const origin = request.headers.get("origin")
const secret = extractSecret(request, url)
const expectedSecret = env.MACHINE_PROVISIONING_SECRET
if (!expectedSecret) {
return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS)
}
if (!secret || secret !== expectedSecret) {
return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS)
}
let payload: Partial<{ tenantId?: string; name?: string; slug?: string }>
try {
payload = (await request.json()) as Partial<{ tenantId?: string; name?: string; slug?: string }>
} catch (error) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
const tenantId = payload?.tenantId?.trim() || DEFAULT_TENANT_ID
const name = payload?.name?.trim() ?? ""
const normalizedSlug = normalizeSlug(payload?.slug ?? name)
if (!name) {
return jsonWithCors({ error: "Informe o nome da empresa" }, 400, origin, CORS_METHODS)
}
if (!normalizedSlug) {
return jsonWithCors({ error: "Não foi possível gerar um slug para a empresa" }, 400, origin, CORS_METHODS)
}
try {
const existing = await prisma.company.findFirst({
where: { tenantId, slug: normalizedSlug },
})
const company =
existing ??
(await prisma.company.create({
data: {
tenantId,
name,
slug: normalizedSlug,
},
}))
await ensureConvexCompany({ tenantId, slug: company.slug, name: company.name })
return jsonWithCors(
{
company: {
id: company.id,
tenantId: company.tenantId,
name: company.name,
slug: company.slug,
created: existing ? false : true,
},
},
existing ? 200 : 201,
origin,
CORS_METHODS
)
} catch (error) {
const prismaError = error as { code?: string }
if (prismaError?.code === "P2002") {
try {
const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } })
if (fallback) {
await ensureConvexCompany({ tenantId, slug: fallback.slug, name: fallback.name })
return jsonWithCors(
{
company: {
id: fallback.id,
tenantId: fallback.tenantId,
name: fallback.name,
slug: fallback.slug,
created: false,
},
},
200,
origin,
CORS_METHODS
)
}
} catch (lookupError) {
console.error("[machines.companies] Falha ao recuperar empresa após conflito", lookupError)
}
}
console.error("[machines.companies] Falha ao criar empresa", error)
return jsonWithCors({ error: "Falha ao criar ou recuperar empresa" }, 500, origin, CORS_METHODS)
}
}

View file

@ -7,6 +7,7 @@ import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureMachineAccount } from "@/server/machines-auth"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { normalizeSlug } from "@/lib/slug"
const registerSchema = z
.object({
@ -27,7 +28,7 @@ const registerSchema = z
collaborator: z
.object({
email: z.string().email(),
name: z.string().optional(),
name: z.string().min(1, "Informe o nome do colaborador/gestor"),
})
.optional(),
})
@ -66,11 +67,13 @@ export async function POST(request: Request) {
}
const client = new ConvexHttpClient(convexUrl)
let normalizedCompanySlug: string | undefined
try {
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
const persona = payload.accessRole ?? undefined
const collaborator = payload.collaborator ?? null
normalizedCompanySlug = normalizeSlug(payload.companySlug)
if (persona && !collaborator) {
return jsonWithCors(
@ -99,7 +102,7 @@ export async function POST(request: Request) {
const registration = await client.mutation(api.machines.register, {
provisioningSecret: payload.provisioningSecret,
tenantId,
companySlug: payload.companySlug ?? undefined,
companySlug: normalizedCompanySlug,
hostname: payload.hostname,
os: payload.os,
macAddresses: payload.macAddresses,
@ -179,6 +182,10 @@ export async function POST(request: Request) {
const isInvalidSecret = msg.includes("código de provisionamento inválido")
const isConvexError = msg.includes("convexerror")
const status = isCompanyNotFound ? 404 : isInvalidSecret ? 401 : isConvexError ? 400 : 500
return jsonWithCors({ error: "Falha ao provisionar máquina", details }, status, request.headers.get("origin"), CORS_METHODS)
const payload = { error: "Falha ao provisionar máquina", details } as Record<string, unknown>
if (isCompanyNotFound && normalizedCompanySlug) {
payload["companySlug"] = normalizedCompanySlug
}
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
}
}