Implement company provisioning codes and session tweaks

This commit is contained in:
Esdras Renan 2025-10-15 20:45:25 -03:00
parent 0fb9bf59b2
commit 2cba553efa
28 changed files with 1407 additions and 534 deletions

View file

@ -1,4 +1,5 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
@ -38,11 +39,13 @@ export async function POST(request: Request) {
}
try {
const provisioningCode = randomBytes(32).toString("hex")
const company = await prisma.company.create({
data: {
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
provisioningCode,
// Campos opcionais (isAvulso, contractedHoursPerMonth) podem ser definidos via PATCH posteriormente.
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,

View file

@ -3,7 +3,6 @@ import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const tokenModeSchema = z.object({
@ -21,9 +20,7 @@ const tokenModeSchema = z.object({
})
const provisioningModeSchema = z.object({
provisioningSecret: z.string().min(1),
tenantId: z.string().optional(),
companySlug: z.string().optional(),
provisioningCode: z.string().min(32),
hostname: z.string().min(1),
os: z.object({
name: z.string().min(1),
@ -87,9 +84,7 @@ export async function POST(request: Request) {
if (provParsed.success) {
try {
const result = await client.mutation(api.machines.upsertInventory, {
provisioningSecret: provParsed.data.provisioningSecret,
tenantId: provParsed.data.tenantId ?? DEFAULT_TENANT_ID,
companySlug: provParsed.data.companySlug ?? undefined,
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
hostname: provParsed.data.hostname,
os: provParsed.data.os,
macAddresses: provParsed.data.macAddresses,
@ -108,4 +103,3 @@ export async function POST(request: Request) {
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS)
}

View file

@ -0,0 +1,93 @@
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
export const runtime = "nodejs"
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let payload: unknown
try {
payload = await request.json()
} catch (error) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
const provisioningCodeRaw =
payload && typeof payload === "object" && "provisioningCode" in payload
? (payload as { provisioningCode?: unknown }).provisioningCode
: null
const provisioningCode =
typeof provisioningCodeRaw === "string" ? provisioningCodeRaw.trim().toLowerCase() : ""
if (!provisioningCode) {
return jsonWithCors({ error: "Informe o código de provisionamento" }, 400, origin, CORS_METHODS)
}
try {
const company = await prisma.company.findFirst({
where: { provisioningCode },
select: {
id: true,
tenantId: true,
name: true,
slug: true,
provisioningCode: true,
},
})
if (!company) {
return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS)
}
if (env.NEXT_PUBLIC_CONVEX_URL) {
try {
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.companies.ensureProvisioned, {
tenantId: company.tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
} catch (error) {
console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error)
}
}
return jsonWithCors(
{
company: {
id: company.id,
tenantId: company.tenantId,
name: company.name,
slug: company.slug,
},
},
200,
origin,
CORS_METHODS
)
} catch (error) {
console.error("[machines.provisioning] Falha ao validar código", error)
return jsonWithCors(
{ error: "Falha ao validar código de provisionamento" },
500,
origin,
CORS_METHODS
)
}
}

View file

@ -5,15 +5,13 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureMachineAccount } from "@/server/machines-auth"
import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { normalizeSlug } from "@/lib/slug"
import { prisma } from "@/lib/prisma"
const registerSchema = z
.object({
provisioningSecret: z.string().min(1),
tenantId: z.string().optional(),
companySlug: z.string().optional(),
provisioningCode: z.string().min(32),
hostname: z.string().min(1),
os: z.object({
name: z.string().min(1),
@ -67,13 +65,25 @@ export async function POST(request: Request) {
}
const client = new ConvexHttpClient(convexUrl)
let normalizedCompanySlug: string | undefined
try {
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
const provisioningCode = payload.provisioningCode.trim().toLowerCase()
const companyRecord = await prisma.company.findFirst({
where: { provisioningCode },
select: { id: true, tenantId: true, name: true, slug: true, provisioningCode: true },
})
if (!companyRecord) {
return jsonWithCors(
{ error: "Código de provisionamento inválido" },
404,
request.headers.get("origin"),
CORS_METHODS
)
}
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const persona = payload.accessRole ?? undefined
const collaborator = payload.collaborator ?? null
normalizedCompanySlug = normalizeSlug(payload.companySlug)
if (persona && !collaborator) {
return jsonWithCors(
@ -99,10 +109,15 @@ export async function POST(request: Request) {
}
}
const registration = await client.mutation(api.machines.register, {
provisioningSecret: payload.provisioningSecret,
await client.mutation(api.companies.ensureProvisioned, {
tenantId,
companySlug: normalizedCompanySlug,
slug: companyRecord.slug,
name: companyRecord.name,
provisioningCode: companyRecord.provisioningCode,
})
const registration = await client.mutation(api.machines.register, {
provisioningCode,
hostname: payload.hostname,
os: payload.os,
macAddresses: payload.macAddresses,
@ -126,26 +141,39 @@ export async function POST(request: Request) {
})
let assignedUserId: Id<"users"> | undefined
if (persona && collaborator) {
if (collaborator) {
const ensuredUser = (await client.mutation(api.users.ensureUser, {
tenantId,
email: collaborator.email,
name: collaborator.name ?? collaborator.email,
avatarUrl: undefined,
role: persona.toUpperCase(),
role: persona?.toUpperCase(),
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
assignedUserEmail: collaborator.email,
assignedUserName: collaborator.name ?? undefined,
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
await ensureCollaboratorAccount({
email: collaborator.email,
name: collaborator.name ?? collaborator.email,
tenantId,
companyId: companyRecord.id,
})
if (persona) {
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
assignedUserEmail: collaborator.email,
assignedUserName: collaborator.name ?? undefined,
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
})
} else {
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona: "",
})
}
} else {
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
@ -174,18 +202,11 @@ export async function POST(request: Request) {
console.error("[machines.register] Falha no provisionamento", error)
const details = error instanceof Error ? error.message : String(error)
const msg = details.toLowerCase()
// Mapear alguns erros "esperados" para códigos adequados
// - empresa inválida → 404
// - segredo inválido → 401
// - demais ConvexError → 400
const isInvalidCode = msg.includes("código de provisionamento inválido")
const isCompanyNotFound = msg.includes("empresa não encontrada")
const isInvalidSecret = msg.includes("código de provisionamento inválido")
const isConvexError = msg.includes("convexerror")
const status = isCompanyNotFound ? 404 : isInvalidSecret ? 401 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details } as Record<string, unknown>
if (isCompanyNotFound && normalizedCompanySlug) {
payload["companySlug"] = normalizedCompanySlug
}
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details }
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
}
}

View file

@ -0,0 +1,169 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
import { prisma } from "@/lib/prisma"
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { api } from "@/convex/_generated/api"
import { ensureCollaboratorAccount } from "@/server/machines-auth"
const updateSchema = z.object({
email: z.string().email().optional(),
password: z
.object({
newPassword: z.string().min(8, "A nova senha deve ter pelo menos 8 caracteres."),
confirmPassword: z.string().min(8),
})
.optional(),
})
export async function PATCH(request: Request) {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
if (role !== "collaborator" && role !== "manager") {
return NextResponse.json({ error: "Acesso não autorizado" }, { status: 403 })
}
let payload: unknown
try {
payload = await request.json()
} catch {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const parsed = updateSchema.safeParse(payload)
if (!parsed.success) {
return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 })
}
const { email: emailInput, password } = parsed.data
const currentEmail = session.user.email.trim().toLowerCase()
const authUserId = session.user.id
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
let newEmail = emailInput?.trim().toLowerCase()
if (newEmail && newEmail === currentEmail) {
newEmail = undefined
}
if (password && password.newPassword !== password.confirmPassword) {
return NextResponse.json({ error: "As senhas informadas não conferem." }, { status: 400 })
}
if (newEmail) {
const existingEmail = await prisma.authUser.findUnique({ where: { email: newEmail } })
if (existingEmail && existingEmail.id !== authUserId) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail." }, { status: 409 })
}
}
const domainUser = await prisma.user.findUnique({ where: { email: currentEmail } })
const companyId = domainUser?.companyId ?? null
const name = session.user.name ?? currentEmail
await prisma.$transaction(async (tx) => {
if (newEmail) {
await tx.authUser.update({
where: { id: authUserId },
data: { email: newEmail },
})
const existingAccount = await tx.authAccount.findUnique({
where: {
providerId_accountId: {
providerId: "credential",
accountId: currentEmail,
},
},
})
if (existingAccount) {
await tx.authAccount.update({
where: { id: existingAccount.id },
data: { accountId: newEmail },
})
} else {
await tx.authAccount.create({
data: {
providerId: "credential",
accountId: newEmail,
userId: authUserId,
password: null,
},
})
}
await tx.user.updateMany({
where: { email: currentEmail },
data: { email: newEmail },
})
}
if (password) {
const hashed = await hashPassword(password.newPassword)
await tx.authAccount.upsert({
where: {
providerId_accountId: {
providerId: "credential",
accountId: newEmail ?? currentEmail,
},
},
update: {
password: hashed,
userId: authUserId,
},
create: {
providerId: "credential",
accountId: newEmail ?? currentEmail,
userId: authUserId,
password: hashed,
},
})
}
})
const effectiveEmail = newEmail ?? currentEmail
await prisma.user.upsert({
where: { email: effectiveEmail },
update: {
name,
tenantId,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
companyId: companyId ?? undefined,
},
create: {
email: effectiveEmail,
name,
tenantId,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
companyId: companyId ?? undefined,
},
})
await ensureCollaboratorAccount({
email: effectiveEmail,
name,
tenantId,
companyId,
})
if (env.NEXT_PUBLIC_CONVEX_URL) {
try {
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.users.ensureUser, {
tenantId,
email: effectiveEmail,
name,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
})
} catch (error) {
console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({ ok: true, email: effectiveEmail })
}