Phase 2: multi-user links for machines (Convex schema + mutations + admin API); UI to add/remove links; user editor lists machines via linkedUsers
This commit is contained in:
parent
6653ef250e
commit
22f0768492
5 changed files with 208 additions and 10 deletions
|
|
@ -715,6 +715,7 @@ export const resolveToken = mutation({
|
||||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||||
assignedUserName: machine.assignedUserName ?? null,
|
assignedUserName: machine.assignedUserName ?? null,
|
||||||
assignedUserRole: machine.assignedUserRole ?? null,
|
assignedUserRole: machine.assignedUserRole ?? null,
|
||||||
|
linkedUserIds: machine.linkedUserIds ?? [],
|
||||||
status: machine.status,
|
status: machine.status,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt,
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
||||||
metadata: machine.metadata,
|
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 {
|
return {
|
||||||
id: machine._id,
|
id: machine._id,
|
||||||
tenantId: machine.tenantId,
|
tenantId: machine.tenantId,
|
||||||
|
|
@ -814,6 +825,7 @@ export const listByTenant = query({
|
||||||
assignedUserEmail: machine.assignedUserEmail ?? null,
|
assignedUserEmail: machine.assignedUserEmail ?? null,
|
||||||
assignedUserName: machine.assignedUserName ?? null,
|
assignedUserName: machine.assignedUserName ?? null,
|
||||||
assignedUserRole: machine.assignedUserRole ?? null,
|
assignedUserRole: machine.assignedUserRole ?? null,
|
||||||
|
linkedUsers,
|
||||||
status: derivedStatus,
|
status: derivedStatus,
|
||||||
isActive: machine.isActive ?? true,
|
isActive: machine.isActive ?? true,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||||
|
|
@ -989,6 +1001,15 @@ export const getContext = query({
|
||||||
throw new ConvexError("Máquina não encontrada")
|
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 {
|
return {
|
||||||
id: machine._id,
|
id: machine._id,
|
||||||
tenantId: machine.tenantId,
|
tenantId: machine.tenantId,
|
||||||
|
|
@ -1002,6 +1023,7 @@ export const getContext = query({
|
||||||
metadata: machine.metadata ?? null,
|
metadata: machine.metadata ?? null,
|
||||||
authEmail: machine.authEmail ?? null,
|
authEmail: machine.authEmail ?? null,
|
||||||
isActive: machine.isActive ?? true,
|
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({
|
export const rename = mutation({
|
||||||
args: {
|
args: {
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export default defineSchema({
|
||||||
assignedUserEmail: v.optional(v.string()),
|
assignedUserEmail: v.optional(v.string()),
|
||||||
assignedUserName: v.optional(v.string()),
|
assignedUserName: v.optional(v.string()),
|
||||||
assignedUserRole: v.optional(v.string()),
|
assignedUserRole: v.optional(v.string()),
|
||||||
|
linkedUserIds: v.optional(v.array(v.id("users"))),
|
||||||
hostname: v.string(),
|
hostname: v.string(),
|
||||||
osName: v.string(),
|
osName: v.string(),
|
||||||
osVersion: v.optional(v.string()),
|
osVersion: v.optional(v.string()),
|
||||||
|
|
|
||||||
66
src/app/api/admin/machines/links/route.ts
Normal file
66
src/app/api/admin/machines/links/route.ts
Normal file
|
|
@ -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<typeof addSchema>
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +426,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
if (typeof base.email === "string") collaboratorEmail = base.email.toLowerCase()
|
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 })
|
results.push({ id: m.id, hostname: m.hostname })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,7 @@ export type MachinesQueryItem = {
|
||||||
inventory: MachineInventory | null
|
inventory: MachineInventory | null
|
||||||
postureAlerts?: Array<Record<string, unknown>> | null
|
postureAlerts?: Array<Record<string, unknown>> | null
|
||||||
lastPostureAt?: number | null
|
lastPostureAt?: number | null
|
||||||
|
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||||
|
|
@ -1859,16 +1860,83 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Usuários vinculados</h4>
|
<h4 className="text-sm font-semibold">Usuários vinculados</h4>
|
||||||
{collaborator?.email ? (
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2 text-sm">
|
{collaborator?.email ? (
|
||||||
<ShieldCheck className="size-4 text-slate-500" />
|
<div className="flex flex-wrap items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2 text-sm">
|
||||||
<span className="font-medium text-neutral-800">{collaborator.name || collaborator.email}</span>
|
<ShieldCheck className="size-4 text-slate-500" />
|
||||||
<span className="text-neutral-500">{collaborator.name ? `· ${collaborator.email}` : ''}</span>
|
<span className="font-medium text-neutral-800">{collaborator.name || collaborator.email}</span>
|
||||||
<Link href="/admin" className="ml-auto text-xs underline underline-offset-4">gerenciar usuários</Link>
|
<span className="text-neutral-500">{collaborator.name ? `· ${collaborator.email}` : ''}</span>
|
||||||
|
<span className="ml-2 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600">Principal</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(machine.linkedUsers) && machine.linkedUsers.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200 bg-slate-50/60">
|
||||||
|
{machine.linkedUsers.map((u) => (
|
||||||
|
<li key={`lu-${u.id}`} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium text-neutral-800">{u.name || u.email}</span>
|
||||||
|
<span className="text-neutral-500">{u.name ? `· ${u.email}` : ''}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/machines/links?machineId=${machine.id}&userId=${u.id}`, { method: 'DELETE', credentials: 'include' })
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
toast.success('Vínculo removido')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error('Falha ao remover vínculo')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{!collaborator?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? (
|
||||||
|
<p className="text-xs text-neutral-500">Nenhum usuário vinculado.</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={accessEmail}
|
||||||
|
onChange={(e) => setAccessEmail(e.target.value)}
|
||||||
|
placeholder="e-mail do usuário para vincular"
|
||||||
|
className="max-w-xs"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!accessEmail.trim()) {
|
||||||
|
toast.error('Informe um e-mail para vincular')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/machines/links', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ machineId: machine.id, email: accessEmail.trim() }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
toast.success('Usuário vinculado')
|
||||||
|
setAccessEmail('')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error('Falha ao vincular usuário')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Adicionar vínculo
|
||||||
|
</Button>
|
||||||
|
<Link href="/admin" className="text-xs underline underline-offset-4">gerenciar usuários</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-xs text-neutral-500">Nenhum usuário vinculado. Use “Ajustar acesso” para associar um colaborador/gestor.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Renomear máquina */}
|
{/* Renomear máquina */}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue