docs: registrar fluxo do updater e atualizar chaves

This commit is contained in:
Esdras Renan 2025-10-12 04:06:29 -03:00
parent 206d00700e
commit b5fd920efd
50 changed files with 980 additions and 93 deletions

View 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 })
}
}

View file

@ -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"),

View 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 })
}
}

View file

@ -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

View file

@ -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 })
}

View file

@ -21,7 +21,7 @@ export const metadata: Metadata = {
title: "Raven",
description: "Plataforma Raven da Rever",
icons: {
icon: "/rever-8.png",
icon: "/raven.png",
},
}

View file

@ -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}

View file

@ -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)

View file

@ -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}

View file

@ -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

View file

@ -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>

View file

@ -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,
},
}
}),

View file

@ -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
}

View file

@ -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,
},
})

View file

@ -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,