docs: registrar fluxo do updater e atualizar chaves
This commit is contained in:
parent
206d00700e
commit
b5fd920efd
50 changed files with 980 additions and 93 deletions
78
src/app/api/admin/machines/access/route.ts
Normal file
78
src/app/api/admin/machines/access/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const schema = z.object({
|
||||
machineId: z.string().min(1),
|
||||
persona: z.enum(["collaborator", "manager"]),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
let parsed: z.infer<typeof schema>
|
||||
try {
|
||||
const body = await request.json()
|
||||
parsed = schema.parse(body)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const machine = (await client.query(api.machines.getContext, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
companyId: string | null
|
||||
} | null
|
||||
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
|
||||
}
|
||||
|
||||
const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: parsed.email,
|
||||
name: parsed.name ?? parsed.email,
|
||||
avatarUrl: undefined,
|
||||
role: parsed.persona.toUpperCase(),
|
||||
companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
|
||||
})) as { _id?: Id<"users"> } | null
|
||||
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
persona: parsed.persona,
|
||||
assignedUserId: ensuredUser?._id,
|
||||
assignedUserEmail: parsed.email,
|
||||
assignedUserName: parsed.name ?? undefined,
|
||||
assignedUserRole: parsed.persona === "manager" ? "MANAGER" : "COLLABORATOR",
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error("[machines.access]", error)
|
||||
return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,13 @@ const registerSchema = z
|
|||
serialNumbers: z.array(z.string()).default([]),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
registeredBy: z.string().optional(),
|
||||
accessRole: z.enum(["collaborator", "manager"]).optional(),
|
||||
collaborator: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
|
||||
|
|
@ -61,15 +68,43 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
|
||||
const persona = payload.accessRole ?? undefined
|
||||
const collaborator = payload.collaborator ?? null
|
||||
|
||||
if (persona && !collaborator) {
|
||||
return jsonWithCors(
|
||||
{ error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." },
|
||||
400,
|
||||
request.headers.get("origin"),
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
let metadataPayload: Record<string, unknown> | undefined = payload.metadata
|
||||
? { ...(payload.metadata as Record<string, unknown>) }
|
||||
: undefined
|
||||
if (collaborator) {
|
||||
const collaboratorMeta = {
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? null,
|
||||
role: persona ?? "collaborator",
|
||||
}
|
||||
metadataPayload = {
|
||||
...(metadataPayload ?? {}),
|
||||
collaborator: collaboratorMeta,
|
||||
}
|
||||
}
|
||||
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
|
||||
tenantId,
|
||||
companySlug: payload.companySlug ?? undefined,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
serialNumbers: payload.serialNumbers,
|
||||
metadata: payload.metadata,
|
||||
metadata: metadataPayload,
|
||||
registeredBy: payload.registeredBy,
|
||||
})
|
||||
|
||||
|
|
@ -78,6 +113,7 @@ export async function POST(request: Request) {
|
|||
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: payload.hostname,
|
||||
machineToken: registration.machineToken,
|
||||
persona,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
|
|
@ -86,6 +122,34 @@ export async function POST(request: Request) {
|
|||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
let assignedUserId: Id<"users"> | undefined
|
||||
if (persona && collaborator) {
|
||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? collaborator.email,
|
||||
avatarUrl: undefined,
|
||||
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",
|
||||
})
|
||||
} else {
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona: "",
|
||||
})
|
||||
}
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
machineId: registration.machineId,
|
||||
|
|
@ -95,6 +159,9 @@ export async function POST(request: Request) {
|
|||
machineToken: registration.machineToken,
|
||||
machineEmail: account.authEmail,
|
||||
expiresAt: registration.expiresAt,
|
||||
persona: persona ?? null,
|
||||
assignedUserId: assignedUserId ?? null,
|
||||
collaborator: collaborator ?? null,
|
||||
},
|
||||
{ status: 201 },
|
||||
request.headers.get("origin"),
|
||||
|
|
|
|||
77
src/app/api/machines/session/route.ts
Normal file
77
src/app/api/machines/session/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
|
||||
const MACHINE_CTX_COOKIE = "machine_ctx"
|
||||
|
||||
function decodeMachineCookie(value: string) {
|
||||
try {
|
||||
const json = Buffer.from(value, "base64url").toString("utf8")
|
||||
return JSON.parse(json) as {
|
||||
machineId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session || session.user?.role !== "machine") {
|
||||
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value
|
||||
if (!cookieValue) {
|
||||
return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 })
|
||||
}
|
||||
|
||||
const decoded = decodeMachineCookie(cookieValue)
|
||||
if (!decoded?.machineId) {
|
||||
return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const context = (await client.query(api.machines.getContext, {
|
||||
machineId: decoded.machineId as Id<"machines">,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
companyId: string | null
|
||||
companySlug: string | null
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
authEmail: string | null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
machine: context,
|
||||
cookie: decoded,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao obter contexto da máquina", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,24 @@ export async function POST(request: Request) {
|
|||
response.headers.set(key, value)
|
||||
})
|
||||
|
||||
const machineCookiePayload = {
|
||||
machineId: session.machine.id,
|
||||
persona: session.machine.persona,
|
||||
assignedUserId: session.machine.assignedUserId,
|
||||
assignedUserEmail: session.machine.assignedUserEmail,
|
||||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
applyCorsHeaders(response, request.headers.get("origin"), CORS_METHODS)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
|
||||
// Header with logo and brand bar
|
||||
try {
|
||||
const logoPath = path.join(process.cwd(), "public", "rever-8.png")
|
||||
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||
if (fs.existsSync(logoPath)) {
|
||||
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
|||
title: "Raven",
|
||||
description: "Plataforma Raven da Rever",
|
||||
icons: {
|
||||
icon: "/rever-8.png",
|
||||
icon: "/raven.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function LoginPageClient() {
|
|||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/rever-8.png"
|
||||
src="/raven.png"
|
||||
alt="Logotipo Rever Tecnologia"
|
||||
width={110}
|
||||
height={110}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,25 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
})
|
||||
|
||||
const machineCookiePayload = {
|
||||
machineId: session.machine.id,
|
||||
persona: session.machine.persona,
|
||||
assignedUserId: session.machine.assignedUserId,
|
||||
assignedUserEmail: session.machine.assignedUserEmail,
|
||||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url")
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: encodedContext,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.handshake] Falha ao autenticar máquina", error)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ type MachineInventory = {
|
|||
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
|
||||
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
|
||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||
collaborator?: { email?: string; name?: string }
|
||||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
export type MachinesQueryItem = {
|
||||
|
|
@ -135,6 +135,11 @@ export type MachinesQueryItem = {
|
|||
serialNumbers: string[]
|
||||
authUserId: string | null
|
||||
authEmail: string | null
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
status: string | null
|
||||
lastHeartbeatAt: number | null
|
||||
heartbeatAgeMs: number | null
|
||||
|
|
@ -209,12 +214,6 @@ function formatPercent(value?: number | null) {
|
|||
return `${normalized.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function fmtBool(value: unknown) {
|
||||
if (value === true) return "Sim"
|
||||
if (value === false) return "Não"
|
||||
return "—"
|
||||
}
|
||||
|
||||
function readBool(source: unknown, key: string): boolean | undefined {
|
||||
if (!source || typeof source !== "object") return undefined
|
||||
const value = (source as Record<string, unknown>)[key]
|
||||
|
|
@ -490,15 +489,31 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// collaborator (from inventory metadata, when provided by onboarding)
|
||||
type Collaborator = { email?: string; name?: string }
|
||||
const collaborator: Collaborator | null = (() => {
|
||||
// collaborator (from machine assignment or metadata)
|
||||
type Collaborator = { email?: string; name?: string; role?: string }
|
||||
const collaborator: Collaborator | null = useMemo(() => {
|
||||
if (machine?.assignedUserEmail) {
|
||||
return {
|
||||
email: machine.assignedUserEmail ?? undefined,
|
||||
name: machine.assignedUserName ?? undefined,
|
||||
role: machine.persona ?? machine.assignedUserRole ?? undefined,
|
||||
}
|
||||
}
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
const inv = metadata as Record<string, unknown>
|
||||
const c = inv["collaborator"]
|
||||
if (c && typeof c === "object") return c as Collaborator
|
||||
if (c && typeof c === "object") {
|
||||
const base = c as Record<string, unknown>
|
||||
return {
|
||||
email: typeof base.email === "string" ? base.email : undefined,
|
||||
name: typeof base.name === "string" ? base.name : undefined,
|
||||
role: typeof base.role === "string" ? (base.role as string) : undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})()
|
||||
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
|
||||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
|
|
@ -513,6 +528,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const [dialogQuery, setDialogQuery] = useState("")
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [accessDialog, setAccessDialog] = useState(false)
|
||||
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "")
|
||||
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(
|
||||
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
|
||||
)
|
||||
const [savingAccess, setSavingAccess] = useState(false)
|
||||
const jsonText = useMemo(() => {
|
||||
const payload = {
|
||||
id: machine?.id,
|
||||
|
|
@ -535,6 +557,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}, [jsonText, dialogQuery])
|
||||
|
||||
// removed copy/export inventory JSON buttons as requested
|
||||
useEffect(() => {
|
||||
setAccessEmail(collaborator?.email ?? "")
|
||||
setAccessName(collaborator?.name ?? "")
|
||||
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
|
||||
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
|
||||
|
||||
const handleSaveAccess = async () => {
|
||||
if (!machine) return
|
||||
if (!accessEmail.trim()) {
|
||||
toast.error("Informe o e-mail do colaborador ou gestor.")
|
||||
return
|
||||
}
|
||||
setSavingAccess(true)
|
||||
try {
|
||||
const response = await fetch("/api/admin/machines/access", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
machineId: machine.id,
|
||||
persona: accessRole,
|
||||
email: accessEmail.trim(),
|
||||
name: accessName.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text())
|
||||
}
|
||||
toast.success("Perfil de acesso atualizado.")
|
||||
setAccessDialog(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Falha ao atualizar acesso da máquina.")
|
||||
} finally {
|
||||
setSavingAccess(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
|
|
@ -594,17 +652,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
) : null}
|
||||
{collaborator?.email ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Colaborador: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||
<ClipboardCopy className="size-4" />
|
||||
Copiar e-mail
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
|
||||
<ClipboardCopy className="size-4" />
|
||||
Copiar e-mail
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Ajustar acesso
|
||||
</Button>
|
||||
{machine.registeredBy ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Registrada via {machine.registeredBy}
|
||||
|
|
@ -653,6 +715,42 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajustar acesso da máquina</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Perfil</label>
|
||||
<Select value={accessRole} onValueChange={(value) => setAccessRole((value as "collaborator" | "manager") ?? "collaborator")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="collaborator">Colaborador (portal)</SelectItem>
|
||||
<SelectItem value="manager">Gestor (painel completo)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">E-mail</label>
|
||||
<Input type="email" value={accessEmail} onChange={(e) => setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Nome (opcional)</label>
|
||||
<Input value={accessName} onChange={(e) => setAccessName(e.target.value)} placeholder="Nome completo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setAccessDialog(false)} disabled={savingAccess}>Cancelar</Button>
|
||||
<Button onClick={handleSaveAccess} disabled={savingAccess || !accessEmail.trim()}>
|
||||
{savingAccess ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -1372,16 +1470,27 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
|
||||
const cpuPct = mm?.cpuUsagePercent ?? NaN
|
||||
const collaborator = (() => {
|
||||
if (machine.assignedUserEmail) {
|
||||
return {
|
||||
email: machine.assignedUserEmail ?? undefined,
|
||||
name: machine.assignedUserName ?? undefined,
|
||||
role: machine.persona ?? machine.assignedUserRole ?? undefined,
|
||||
}
|
||||
}
|
||||
const inv = machine.inventory as unknown
|
||||
if (!inv || typeof inv !== "object") return null
|
||||
const raw = (inv as Record<string, unknown>).collaborator
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
const obj = raw as Record<string, unknown>
|
||||
const email = typeof obj.email === "string" ? obj.email : undefined
|
||||
const name = typeof obj.name === "string" ? obj.name : undefined
|
||||
if (!email) return null
|
||||
return { email, name }
|
||||
return {
|
||||
email,
|
||||
name: typeof obj.name === "string" ? obj.name : undefined,
|
||||
role: typeof obj.role === "string" ? (obj.role as string) : undefined,
|
||||
}
|
||||
})()
|
||||
const persona = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
const companyLabel = companyName ?? machine.companySlug ?? null
|
||||
|
||||
return (
|
||||
|
|
@ -1427,7 +1536,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
</div>
|
||||
{collaborator?.email ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{collaborator.name ? `${collaborator.name} · ` : ""}
|
||||
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
|
||||
{collaborator.email}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,21 @@ const navItems = [
|
|||
export function PortalShell({ children }: PortalShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { session } = useAuth()
|
||||
const { session, machineContext } = useAuth()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
|
||||
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
|
||||
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const name = session?.user.name || session?.user.email || "Cliente"
|
||||
const name = displayName || displayEmail || "Cliente"
|
||||
return name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join("")
|
||||
}, [session?.user.name, session?.user.email])
|
||||
}, [displayName, displayEmail])
|
||||
|
||||
async function handleSignOut() {
|
||||
if (isSigningOut) return
|
||||
|
|
@ -85,12 +89,15 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={session?.user.name ?? ""} />
|
||||
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={displayName ?? ""} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
|
||||
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</span>
|
||||
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail}</span>
|
||||
{machineContext ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type AppSession = {
|
|||
role: string
|
||||
tenantId: string | null
|
||||
avatarUrl: string | null
|
||||
machinePersona?: string | null
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
|
|
@ -25,6 +26,17 @@ export type AppSession = {
|
|||
}
|
||||
}
|
||||
|
||||
type MachineContext = {
|
||||
machineId: string
|
||||
tenantId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
companyId: string | null
|
||||
}
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [customSessionClient<AppAuth>()],
|
||||
fetchOptions: {
|
||||
|
|
@ -40,6 +52,7 @@ type AuthContextValue = {
|
|||
isAdmin: boolean
|
||||
isStaff: boolean
|
||||
isCustomer: boolean
|
||||
machineContext: MachineContext | null
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
|
|
@ -50,6 +63,7 @@ const AuthContext = createContext<AuthContextValue>({
|
|||
isAdmin: false,
|
||||
isStaff: false,
|
||||
isCustomer: false,
|
||||
machineContext: null,
|
||||
})
|
||||
|
||||
export function useAuth() {
|
||||
|
|
@ -62,15 +76,68 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const { data: session, isPending } = useSession()
|
||||
const ensureUser = useMutation(api.users.ensureUser)
|
||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user) {
|
||||
if (!session?.user || session.user.role === "machine") {
|
||||
setConvexUserId(null)
|
||||
}
|
||||
}, [session?.user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || convexUserId) return
|
||||
if (!session?.user || session.user.role !== "machine") {
|
||||
setMachineContext(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/machines/session", { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
if (!cancelled) {
|
||||
setMachineContext(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!cancelled) {
|
||||
const machine = data.machine as {
|
||||
id: string
|
||||
tenantId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
companyId: string | null
|
||||
}
|
||||
setMachineContext({
|
||||
machineId: machine.id,
|
||||
tenantId: machine.tenantId,
|
||||
persona: machine.persona ?? null,
|
||||
assignedUserId: machine.assignedUserId ?? null,
|
||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||
assignedUserName: machine.assignedUserName ?? null,
|
||||
assignedUserRole: machine.assignedUserRole ?? null,
|
||||
companyId: machine.companyId ?? null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load machine context", error)
|
||||
if (!cancelled) {
|
||||
setMachineContext(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [session?.user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || session.user.role === "machine" || convexUserId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
|
|
@ -99,19 +166,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
|
||||
|
||||
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
const baseRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
|
||||
const normalizedRole =
|
||||
baseRole === "machine" ? machineContext?.persona ?? personaRole ?? null : baseRole
|
||||
|
||||
const effectiveConvexUserId =
|
||||
baseRole === "machine" ? machineContext?.assignedUserId ?? null : convexUserId
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session: session ?? null,
|
||||
isLoading: isPending,
|
||||
convexUserId,
|
||||
convexUserId: effectiveConvexUserId,
|
||||
role: normalizedRole,
|
||||
isAdmin: isAdmin(normalizedRole),
|
||||
isStaff: isStaff(normalizedRole),
|
||||
isCustomer: false,
|
||||
isCustomer: normalizedRole === "collaborator",
|
||||
machineContext,
|
||||
}),
|
||||
[session, isPending, convexUserId, normalizedRole]
|
||||
[session, isPending, effectiveConvexUserId, normalizedRole, machineContext]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ export const auth = betterAuth({
|
|||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
machinePersona: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
@ -76,6 +81,7 @@ export const auth = betterAuth({
|
|||
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
|
||||
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
|
||||
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? null,
|
||||
machinePersona: (user as { machinePersona?: string | null }).machinePersona ?? null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
|
||||
|
||||
const ADMIN_ROLE = "admin"
|
||||
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
|
||||
const PORTAL_ROLE = "collaborator"
|
||||
const STAFF_ROLES = new Set(["admin", "manager", "agent"])
|
||||
|
||||
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
||||
|
||||
|
|
@ -16,3 +17,7 @@ export function isAdmin(role?: string | null) {
|
|||
export function isStaff(role?: string | null) {
|
||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
||||
}
|
||||
|
||||
export function isPortalUser(role?: string | null) {
|
||||
return normalizeRole(role) === PORTAL_ROLE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ type EnsureMachineAccountParams = {
|
|||
tenantId: string
|
||||
hostname: string
|
||||
machineToken: string
|
||||
persona?: string
|
||||
}
|
||||
|
||||
export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
||||
const { machineId, tenantId, hostname, machineToken } = params
|
||||
const { machineId, tenantId, hostname, machineToken, persona } = params
|
||||
const machineEmail = `machine-${machineId}@machines.local`
|
||||
const context = await auth.$context
|
||||
|
||||
|
|
@ -22,12 +23,14 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
|||
name: machineName,
|
||||
tenantId,
|
||||
role: "machine",
|
||||
machinePersona: persona ?? null,
|
||||
},
|
||||
create: {
|
||||
email: machineEmail,
|
||||
name: machineName,
|
||||
role: "machine",
|
||||
tenantId,
|
||||
machinePersona: persona ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export type MachineSessionContext = {
|
|||
companyId: Id<"companies"> | null
|
||||
companySlug: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
persona: string | null
|
||||
assignedUserId: Id<"users"> | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}
|
||||
headers: Headers
|
||||
response: unknown
|
||||
|
|
@ -41,6 +46,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: resolved.machine.hostname,
|
||||
machineToken,
|
||||
persona: (resolved.machine.persona ?? null) ?? undefined,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
|
|
@ -73,6 +79,11 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
companyId: (resolved.machine.companyId ?? null) as Id<"companies"> | null,
|
||||
companySlug: resolved.machine.companySlug ?? null,
|
||||
metadata: (resolved.machine.metadata ?? null) as Record<string, unknown> | null,
|
||||
persona: (resolved.machine.persona ?? null) as string | null,
|
||||
assignedUserId: (resolved.machine.assignedUserId ?? null) as Id<"users"> | null,
|
||||
assignedUserEmail: resolved.machine.assignedUserEmail ?? null,
|
||||
assignedUserName: resolved.machine.assignedUserName ?? null,
|
||||
assignedUserRole: resolved.machine.assignedUserRole ?? null,
|
||||
},
|
||||
headers: signIn.headers,
|
||||
response: signIn.response,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue