feat: overhaul admin user management and desktop UX
This commit is contained in:
parent
7d6f3bea01
commit
ecad81b0ea
16 changed files with 1546 additions and 395 deletions
66
src/app/api/admin/users/[id]/reset-password/route.ts
Normal file
66
src/app/api/admin/users/[id]/reset-password/route.ts
Normal 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 })
|
||||
}
|
||||
207
src/app/api/admin/users/[id]/route.ts
Normal file
207
src/app/api/admin/users/[id]/route.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
191
src/app/api/machines/companies/route.ts
Normal file
191
src/app/api/machines/companies/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue