Implement company provisioning codes and session tweaks
This commit is contained in:
parent
0fb9bf59b2
commit
2cba553efa
28 changed files with 1407 additions and 534 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
93
src/app/api/machines/provisioning/route.ts
Normal file
93
src/app/api/machines/provisioning/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
src/app/api/portal/profile/route.ts
Normal file
169
src/app/api/portal/profile/route.ts
Normal 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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue