From 22f07684921a35d8b821d78f96628a8d16aff789 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 21 Oct 2025 11:06:21 -0300 Subject: [PATCH] Phase 2: multi-user links for machines (Convex schema + mutations + admin API); UI to add/remove links; user editor lists machines via linkedUsers --- convex/machines.ts | 60 +++++++++++++ convex/schema.ts | 1 + src/app/api/admin/machines/links/route.ts | 66 ++++++++++++++ src/components/admin/admin-users-manager.tsx | 5 +- .../machines/admin-machines-overview.tsx | 86 +++++++++++++++++-- 5 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 src/app/api/admin/machines/links/route.ts diff --git a/convex/machines.ts b/convex/machines.ts index 694dd30..c5bcfb4 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -715,6 +715,7 @@ export const resolveToken = mutation({ assignedUserEmail: machine.assignedUserEmail ?? null, assignedUserName: machine.assignedUserName ?? null, assignedUserRole: machine.assignedUserRole ?? null, + linkedUserIds: machine.linkedUserIds ?? [], status: machine.status, lastHeartbeatAt: machine.lastHeartbeatAt, metadata: machine.metadata, @@ -796,6 +797,16 @@ export const listByTenant = query({ } } + // linked users summary + const linkedUserIds = machine.linkedUserIds ?? [] + const linkedUsers = await Promise.all( + linkedUserIds.map(async (id) => { + const u = await ctx.db.get(id) + if (!u) return null + return { id: u._id, email: u.email, name: u.name } + }) + ).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>) + return { id: machine._id, tenantId: machine.tenantId, @@ -814,6 +825,7 @@ export const listByTenant = query({ assignedUserEmail: machine.assignedUserEmail ?? null, assignedUserName: machine.assignedUserName ?? null, assignedUserRole: machine.assignedUserRole ?? null, + linkedUsers, status: derivedStatus, isActive: machine.isActive ?? true, lastHeartbeatAt: machine.lastHeartbeatAt ?? null, @@ -989,6 +1001,15 @@ export const getContext = query({ throw new ConvexError("Máquina não encontrada") } + const linkedUserIds = machine.linkedUserIds ?? [] + const linkedUsers = await Promise.all( + linkedUserIds.map(async (id) => { + const u = await ctx.db.get(id) + if (!u) return null + return { id: u._id, email: u.email, name: u.name } + }) + ).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>) + return { id: machine._id, tenantId: machine.tenantId, @@ -1002,6 +1023,7 @@ export const getContext = query({ metadata: machine.metadata ?? null, authEmail: machine.authEmail ?? null, isActive: machine.isActive ?? true, + linkedUsers, } }, }) @@ -1050,6 +1072,44 @@ export const linkAuthAccount = mutation({ }, }) +export const linkUser = mutation({ + args: { + machineId: v.id("machines"), + email: v.string(), + }, + handler: async (ctx, { machineId, email }) => { + const machine = await ctx.db.get(machineId) + if (!machine) throw new ConvexError("Máquina não encontrada") + const tenantId = machine.tenantId + const normalized = email.trim().toLowerCase() + + const user = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalized)) + .first() + if (!user) throw new ConvexError("Usuário não encontrado") + + const current = new Set((machine.linkedUserIds ?? []).map((id) => id.id ?? id)) + current.add(user._id) + await ctx.db.patch(machine._id, { linkedUserIds: Array.from(current) as any, updatedAt: Date.now() }) + return { ok: true } + }, +}) + +export const unlinkUser = mutation({ + args: { + machineId: v.id("machines"), + userId: v.id("users"), + }, + handler: async (ctx, { machineId, userId }) => { + const machine = await ctx.db.get(machineId) + if (!machine) throw new ConvexError("Máquina não encontrada") + const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId) + await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() }) + return { ok: true } + }, +}) + export const rename = mutation({ args: { machineId: v.id("machines"), diff --git a/convex/schema.ts b/convex/schema.ts index b5cee8b..31d2cc8 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -288,6 +288,7 @@ export default defineSchema({ assignedUserEmail: v.optional(v.string()), assignedUserName: v.optional(v.string()), assignedUserRole: v.optional(v.string()), + linkedUserIds: v.optional(v.array(v.id("users"))), hostname: v.string(), osName: v.string(), osVersion: v.optional(v.string()), diff --git a/src/app/api/admin/machines/links/route.ts b/src/app/api/admin/machines/links/route.ts new file mode 100644 index 0000000..4bbb858 --- /dev/null +++ b/src/app/api/admin/machines/links/route.ts @@ -0,0 +1,66 @@ +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 { assertStaffSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +const addSchema = z.object({ machineId: z.string().min(1), email: z.string().email() }) +const removeSchema = z.object({ machineId: z.string().min(1), userId: z.string().min(1) }) + +export async function POST(request: Request) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + let parsed: z.infer + try { + parsed = addSchema.parse(await request.json()) + } 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 { + await client.mutation(api.machines.linkUser, { + machineId: parsed.machineId as Id<"machines">, + email: parsed.email, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.links.add]", error) + return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + const session = await assertStaffSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const url = new URL(request.url) + const machineId = url.searchParams.get("machineId") + const userId = url.searchParams.get("userId") + const parsed = removeSchema.safeParse({ machineId, userId }) + if (!parsed.success) return NextResponse.json({ error: "Parâmetros inválidos" }, { 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 { + await client.mutation(api.machines.unlinkUser, { + machineId: parsed.data.machineId as Id<"machines">, + userId: parsed.data.userId as Id<"users">, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.links.remove]", error) + return NextResponse.json({ error: "Falha ao desvincular usuário" }, { status: 500 }) + } +} + diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 4dfeb84..754d404 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -426,7 +426,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d if (typeof base.email === "string") collaboratorEmail = base.email.toLowerCase() } } - if (assigned === email || (collaboratorEmail && collaboratorEmail === email)) { + const linked = Array.isArray((m as any).linkedUsers) + ? ((m as any).linkedUsers as Array<{ email?: string }>).some((lu) => (lu.email ?? '').toLowerCase() === email) + : false + if (assigned === email || (collaboratorEmail && collaboratorEmail === email) || linked) { results.push({ id: m.id, hostname: m.hostname }) } }) diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 292ec5b..c838a9d 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -657,6 +657,7 @@ export type MachinesQueryItem = { inventory: MachineInventory | null postureAlerts?: Array> | null lastPostureAt?: number | null + linkedUsers?: Array<{ id: string; email: string; name: string }> } function useMachinesQuery(tenantId: string): MachinesQueryItem[] { @@ -1859,16 +1860,83 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

Usuários vinculados

- {collaborator?.email ? ( -
- - {collaborator.name || collaborator.email} - {collaborator.name ? `· ${collaborator.email}` : ''} - gerenciar usuários +
+ {collaborator?.email ? ( +
+ + {collaborator.name || collaborator.email} + {collaborator.name ? `· ${collaborator.email}` : ''} + Principal +
+ ) : null} + {Array.isArray(machine.linkedUsers) && machine.linkedUsers.length > 0 ? ( +
    + {machine.linkedUsers.map((u) => ( +
  • + {u.name || u.email} + {u.name ? `· ${u.email}` : ''} +
    + +
    +
  • + ))} +
+ ) : null} + {!collaborator?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? ( +

Nenhum usuário vinculado.

+ ) : null} +
+ setAccessEmail(e.target.value)} + placeholder="e-mail do usuário para vincular" + className="max-w-xs" + type="email" + /> + + gerenciar usuários
- ) : ( -

Nenhum usuário vinculado. Use “Ajustar acesso” para associar um colaborador/gestor.

- )} +
{/* Renomear máquina */}