diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 184d166..67d1bbd 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { createRoot } from "react-dom/client" import { invoke } from "@tauri-apps/api/core" import { Store } from "@tauri-apps/plugin-store" -import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, RefreshCw } from "lucide-react" +import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, Loader2, RefreshCw } from "lucide-react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { cn } from "./lib/utils" @@ -54,10 +54,18 @@ type MachineRegisterResponse = { } | null } +type CompanyOption = { + id: string + tenantId: string + name: string + slug: string +} + type AgentConfig = { machineId: string tenantId?: string | null companySlug?: string | null + companyName?: string | null machineEmail?: string | null collaboratorEmail?: string | null collaboratorName?: string | null @@ -83,6 +91,7 @@ declare global { const STORE_FILENAME = "machine-agent.json" const DEFAULT_APP_URL = import.meta.env.MODE === "production" ? "https://tickets.esdrasrenan.com.br" : "http://localhost:3000" +const DEFAULT_TENANT_ID = "tenant-atlas" function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { const trimmed = (value ?? fallback).trim() @@ -134,6 +143,7 @@ function App() { const [busy, setBusy] = useState(false) const [status, setStatus] = useState(null) const [showSecret, setShowSecret] = useState(false) + const [isLaunchingSystem, setIsLaunchingSystem] = useState(false) const [provisioningSecret, setProvisioningSecret] = useState("") const [tenantId, setTenantId] = useState("") @@ -141,6 +151,9 @@ function App() { const [collabEmail, setCollabEmail] = useState("") const [collabName, setCollabName] = useState("") const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator") + const [companyOptions, setCompanyOptions] = useState([]) + const [selectedCompany, setSelectedCompany] = useState(null) + const [companyLookupPending, setCompanyLookupPending] = useState(false) const [updating, setUpdating] = useState(false) const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({ message: "Atualizações automáticas são verificadas a cada inicialização.", @@ -161,6 +174,8 @@ function App() { setAccessRole(cfg?.accessRole ?? "collaborator") if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail) if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName) + if (cfg?.companyName) setCompany(cfg.companyName) + if (cfg?.tenantId) setTenantId(cfg.tenantId) if (!t) { const p = await invoke("collect_machine_profile") setProfile(p) @@ -218,6 +233,71 @@ function App() { return normalized }, [config?.appUrl]) + useEffect(() => { + const trimmedSecret = provisioningSecret.trim() + const query = company.trim() + if (!trimmedSecret || query.length < 2) { + setCompanyOptions([]) + setCompanyLookupPending(false) + return + } + if (selectedCompany && selectedCompany.name.toLowerCase() === query.toLowerCase()) { + setCompanyOptions([]) + setCompanyLookupPending(false) + return + } + const controller = new AbortController() + const timeout = setTimeout(async () => { + setCompanyLookupPending(true) + try { + const params = new URLSearchParams() + params.set("search", query) + const tenant = tenantId.trim() + if (tenant) params.set("tenantId", tenant) + const res = await fetch(`${apiBaseUrl}/api/machines/companies?${params.toString()}`, { + headers: { + "X-Machine-Secret": trimmedSecret, + }, + signal: controller.signal, + }) + if (!res.ok) { + setCompanyOptions([]) + return + } + const data = (await res.json()) as { companies?: CompanyOption[] } + setCompanyOptions(data.companies ?? []) + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return + if (typeof error === "object" && error && "name" in error && (error as { name?: string }).name === "AbortError") return + console.error("Falha ao buscar empresas", error) + } finally { + setCompanyLookupPending(false) + } + }, 200) + return () => { + clearTimeout(timeout) + controller.abort() + setCompanyLookupPending(false) + } + }, [company, tenantId, provisioningSecret, selectedCompany]) + + function handleSelectCompany(option: CompanyOption) { + setCompany(option.name) + setSelectedCompany(option) + setCompanyOptions([]) + if (!tenantId.trim()) { + setTenantId(option.tenantId) + } + } + + function handleCompanyInputChange(value: string) { + setCompany(value) + setSelectedCompany((prev) => { + if (!prev) return null + return prev.name.toLowerCase() === value.trim().toLowerCase() ? prev : null + }) + } + async function register() { if (!profile) return if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } @@ -226,11 +306,55 @@ function App() { setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.") return } + const normalizedName = collabName.trim() + if (!normalizedName) { + setError("Informe o nome completo do colaborador ou gestor.") + return + } setBusy(true); setError(null) try { + const trimmedTenantId = tenantId.trim() + const trimmedCompanyName = company.trim() + let ensuredCompany: { name: string; slug: string } | null = null + if (trimmedCompanyName) { + if (selectedCompany && selectedCompany.name.toLowerCase() === trimmedCompanyName.toLowerCase()) { + ensuredCompany = { name: selectedCompany.name, slug: selectedCompany.slug } + } else { + const ensureRes = await fetch(`${apiBaseUrl}/api/machines/companies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Machine-Secret": provisioningSecret.trim(), + }, + body: JSON.stringify({ + tenantId: trimmedTenantId || undefined, + name: trimmedCompanyName, + }), + }) + if (!ensureRes.ok) { + const text = await ensureRes.text() + throw new Error(`Falha ao preparar empresa (${ensureRes.status}): ${text.slice(0, 300)}`) + } + const ensureData = (await ensureRes.json()) as { company?: { name: string; slug: string; tenantId?: string } } + if (!ensureData.company?.slug) { + throw new Error("Resposta inválida ao preparar empresa.") + } + ensuredCompany = { name: ensureData.company.name, slug: ensureData.company.slug } + setSelectedCompany({ + id: ensureData.company.slug, + name: ensureData.company.name, + slug: ensureData.company.slug, + tenantId: ensureData.company.tenantId ?? (trimmedTenantId || DEFAULT_TENANT_ID), + }) + if (!trimmedTenantId && ensureData.company.tenantId) { + setTenantId(ensureData.company.tenantId) + } + } + } + const collaboratorPayload = { email: normalizedEmail, - name: collabName.trim() || undefined, + name: normalizedName, } const collaboratorMetadata = collaboratorPayload ? { ...collaboratorPayload, role: accessRole } @@ -244,8 +368,8 @@ function App() { } const payload = { provisioningSecret: provisioningSecret.trim(), - tenantId: tenantId.trim() || undefined, - companySlug: company.trim() || undefined, + tenantId: trimmedTenantId || undefined, + companySlug: ensuredCompany?.slug, hostname: profile.hostname, os: profile.os, macAddresses: profile.macAddresses, @@ -266,7 +390,8 @@ function App() { const cfg: AgentConfig = { machineId: data.machineId, tenantId: data.tenantId ?? null, - companySlug: data.companySlug ?? null, + companySlug: data.companySlug ?? ensuredCompany?.slug ?? null, + companyName: ensuredCompany?.name ?? (trimmedCompanyName || null), machineEmail: data.machineEmail ?? null, collaboratorEmail: collaboratorPayload?.email ?? null, collaboratorName: collaboratorPayload?.name ?? null, @@ -282,6 +407,9 @@ function App() { } await writeConfig(store, cfg) setConfig(cfg); setToken(data.machineToken) + if (ensuredCompany?.name) { + setCompany(ensuredCompany.name) + } await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 }) setStatus("online") } catch (err) { @@ -293,6 +421,7 @@ function App() { const openSystem = useCallback(() => { if (!token) return + setIsLaunchingSystem(true) const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator" const redirectTarget = persona === "manager" ? "/dashboard" : "/portal" const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` @@ -304,6 +433,8 @@ function App() { await store.delete("token"); await store.delete("config"); await store.save() autoLaunchRef.current = false setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator") + setCompany(""); setSelectedCompany(null); setCompanyOptions([]) + setIsLaunchingSystem(false) const p = await invoke("collect_machine_profile") setProfile(p) } @@ -397,9 +528,22 @@ function App() { if (!token) return if (autoLaunchRef.current) return autoLaunchRef.current = true + setIsLaunchingSystem(true) openSystem() }, [token, config?.accessRole, openSystem]) + if (isLaunchingSystem && token) { + return ( +
+
+ +

Abrindo plataforma da Rever…

+

Aguarde só um instante.

+
+
+ ) + } + return (
@@ -425,8 +569,34 @@ function App() {
- - setCompany(e.target.value)} /> + + handleCompanyInputChange(e.target.value)} + /> + {companyLookupPending ? ( +

Buscando empresas...

+ ) : companyOptions.length > 0 ? ( +
+ {companyOptions.map((option) => ( + + ))} +
+ ) : company.trim().length >= 2 ? ( +

Empresa não encontrada — criaremos automaticamente ao registrar.

+ ) : ( +

Pode informar o nome completo que transformamos no slug registrado.

+ )}
@@ -449,7 +619,7 @@ function App() { setCollabEmail(e.target.value)} />
- + setCollabName(e.target.value)} />
diff --git a/convex/companies.ts b/convex/companies.ts index 42c7717..75469af 100644 --- a/convex/companies.ts +++ b/convex/companies.ts @@ -1,7 +1,21 @@ -import { query } from "./_generated/server"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; +import { mutation, query } from "./_generated/server"; import { requireStaff } from "./rbac"; +function normalizeSlug(input?: string | null): string | undefined { + if (!input) return undefined + const trimmed = input.trim() + if (!trimmed) return undefined + const ascii = trimmed + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[\u2013\u2014]/g, "-") + const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-") + const collapsed = sanitized.replace(/-+/g, "-").toLowerCase() + const normalized = collapsed.replace(/^-+|-+$/g, "") + return normalized || undefined +} + export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { @@ -13,3 +27,56 @@ export const list = query({ return companies.map((c) => ({ id: c._id, name: c.name, slug: c.slug })) }, }) + +export const ensureProvisioned = mutation({ + args: { + tenantId: v.string(), + slug: v.string(), + name: v.string(), + }, + handler: async (ctx, { tenantId, slug, name }) => { + const normalizedSlug = normalizeSlug(slug) + if (!normalizedSlug) { + throw new ConvexError("Slug inválido") + } + const trimmedName = name.trim() + if (!trimmedName) { + throw new ConvexError("Nome inválido") + } + + const existing = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", normalizedSlug)) + .unique() + + if (existing) { + return { + id: existing._id, + slug: existing.slug, + name: existing.name, + } + } + + const now = Date.now() + const id = await ctx.db.insert("companies", { + tenantId, + name: trimmedName, + slug: normalizedSlug, + isAvulso: false, + contractedHoursPerMonth: undefined, + cnpj: undefined, + domain: undefined, + phone: undefined, + description: undefined, + address: undefined, + createdAt: now, + updatedAt: now, + }) + + return { + id, + slug: normalizedSlug, + name: trimmedName, + } + }, +}) diff --git a/convex/machines.ts b/convex/machines.ts index 12c3e51..03dc52c 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -16,6 +16,20 @@ type NormalizedIdentifiers = { serials: string[] } +function normalizeCompanySlug(input?: string | null): string | undefined { + if (!input) return undefined + const trimmed = input.trim() + if (!trimmed) return undefined + const ascii = trimmed + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[\u2013\u2014]/g, "-") + const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-") + const collapsed = sanitized.replace(/-+/g, "-").toLowerCase() + const normalized = collapsed.replace(/^-+|-+$/g, "") + return normalized || undefined +} + function getProvisioningSecret() { const secret = process.env["MACHINE_PROVISIONING_SECRET"] if (!secret) { @@ -92,10 +106,11 @@ async function ensureCompany( tenantId: string, companySlug?: string ): Promise<{ companyId?: Id<"companies">; companySlug?: string }> { - if (!companySlug) return {} + const normalized = normalizeCompanySlug(companySlug) + if (!normalized) return {} const company = await ctx.db .query("companies") - .withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug)) + .withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", normalized)) .unique() if (!company) { throw new ConvexError("Empresa não encontrada para o tenant informado") @@ -317,9 +332,10 @@ export const register = mutation({ } const tenantId = args.tenantId ?? DEFAULT_TENANT_ID + const normalizedCompanySlug = normalizeCompanySlug(args.companySlug) const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) - const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) - const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) + const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers) + const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug) const now = Date.now() const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record) : undefined @@ -454,9 +470,10 @@ export const upsertInventory = mutation({ } const tenantId = args.tenantId ?? DEFAULT_TENANT_ID + const normalizedCompanySlug = normalizeCompanySlug(args.companySlug) const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) - const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) - const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) + const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers) + const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug) const now = Date.now() const metadataPatch: Record = {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 43f9ea6..9a3fa52 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -18,20 +18,43 @@ async function loadUsers() { name: true, role: true, tenantId: true, + machinePersona: true, createdAt: true, updatedAt: true, }, }) - return users.map((user) => ({ - id: user.id, - email: user.email, - name: user.name ?? "", - role: (normalizeRole(user.role) ?? "agent") as RoleOption, - tenantId: user.tenantId ?? DEFAULT_TENANT_ID, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt?.toISOString() ?? null, - })) + const domainUsers = await prisma.user.findMany({ + select: { + email: true, + companyId: true, + company: { + select: { + id: true, + name: true, + }, + }, + }, + }) + + const domainByEmail = new Map(domainUsers.map((user) => [user.email.toLowerCase(), user])) + + return users.map((user) => { + const domain = domainByEmail.get(user.email.toLowerCase()) + const normalizedRole = (normalizeRole(user.role) ?? "agent") as RoleOption + return { + id: user.id, + email: user.email, + name: user.name ?? "", + role: normalizedRole, + 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, + } + }) } async function loadInvites(): Promise { diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts new file mode 100644 index 0000000..c8cd347 --- /dev/null +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -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 }) +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..dacd4dd --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -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, + }, + }) +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d4a10b6..7791355 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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 { diff --git a/src/app/api/machines/companies/route.ts b/src/app/api/machines/companies/route.ts new file mode 100644 index 0000000..efb7799 --- /dev/null +++ b/src/app/api/machines/companies/route.ts @@ -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) + } +} diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index 5579036..e31eeb3 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -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 + if (isCompanyNotFound && normalizedCompanySlug) { + payload["companySlug"] = normalizedCompanySlug + } + return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS) } } diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 32c8ee2..cd7c81c 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -1,5 +1,6 @@ "use client" +import Link from "next/link" import { useEffect, useMemo, useState, useTransition } from "react" import { toast } from "sonner" @@ -16,6 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" @@ -27,6 +29,9 @@ type AdminUser = { tenantId: string createdAt: string updatedAt: string | null + companyId: string | null + companyName: string | null + machinePersona: string | null } type AdminInvite = { @@ -48,6 +53,11 @@ type AdminInvite = { revokedReason: string | null } +type CompanyOption = { + id: string + name: string +} + type Props = { initialUsers: AdminUser[] initialInvites: AdminInvite[] @@ -68,6 +78,11 @@ function formatRole(role: string) { return ROLE_LABELS[key] ?? role } +function normalizeRoleValue(role: string | null | undefined): RoleOption { + const candidate = (role ?? "agent").toLowerCase() as RoleOption + return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption +} + function formatTenantLabel(tenantId: string, defaultTenantId: string) { if (!tenantId) return "Principal" if (tenantId === defaultTenantId) return "Principal" @@ -104,46 +119,85 @@ function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite } export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) { - const [users] = useState(initialUsers) + const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) + const [companies, setCompanies] = useState([]) + const [email, setEmail] = useState("") const [name, setName] = useState("") const [role, setRole] = useState("agent") const [tenantId, setTenantId] = useState(defaultTenantId) - const [expiresInDays, setExpiresInDays] = useState("7") + const [expiresInDays, setExpiresInDays] = useState("7") const [lastInviteLink, setLastInviteLink] = useState(null) const [revokingId, setRevokingId] = useState(null) const [isPending, startTransition] = useTransition() - const [companies, setCompanies] = useState>([]) + const [linkEmail, setLinkEmail] = useState("") const [linkCompanyId, setLinkCompanyId] = useState("") + const [assigningCompany, setAssigningCompany] = useState(false) + + const [editUserId, setEditUserId] = useState(null) + const editUser = useMemo(() => users.find((user) => user.id === editUserId) ?? null, [users, editUserId]) + const [editForm, setEditForm] = useState({ + name: "", + email: "", + role: "agent" as RoleOption, + tenantId: defaultTenantId, + companyId: "", + }) + const [isSavingUser, setIsSavingUser] = useState(false) + const [isResettingPassword, setIsResettingPassword] = useState(false) + const [passwordPreview, setPasswordPreview] = useState(null) const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions]) + const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users]) + const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users]) - // load companies for association useEffect(() => { void (async () => { try { - const r = await fetch("/api/admin/companies", { credentials: "include" }) - const j = (await r.json()) as { companies?: Array<{ id: string; name: string }> } - const items = (j.companies ?? []).map((c) => ({ id: c.id, name: c.name })) - setCompanies(items) - } catch { - // noop + const response = await fetch("/api/admin/companies", { credentials: "include" }) + const json = (await response.json()) as { companies?: CompanyOption[] } + const mapped = (json.companies ?? []).map((company) => ({ id: company.id, name: company.name })) + setCompanies(mapped) + if (mapped.length > 0 && !linkCompanyId) { + setLinkCompanyId(mapped[0].id) + } + } catch (error) { + console.error("Falha ao carregar empresas", error) } })() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + if (!editUser) { + setEditForm({ name: "", email: "", role: "agent", tenantId: defaultTenantId, companyId: "" }) + setPasswordPreview(null) + return + } + + setEditForm({ + name: editUser.name || "", + email: editUser.email, + role: editUser.role, + tenantId: editUser.tenantId || defaultTenantId, + companyId: editUser.companyId ?? "", + }) + setPasswordPreview(null) + }, [editUser, defaultTenantId]) + async function handleInviteSubmit(event: React.FormEvent) { event.preventDefault() - if (!email || !email.includes("@")) { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail || !normalizedEmail.includes("@")) { toast.error("Informe um e-mail válido") return } const payload = { - email, - name, + email: normalizedEmail, + name: name.trim(), role, tenantId, expiresInDays: Number.parseInt(expiresInDays, 10), @@ -188,9 +242,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d async function handleRevoke(inviteId: string) { const invite = invites.find((item) => item.id === inviteId) - if (!invite || invite.status !== "pending") { - return - } + if (!invite || invite.status !== "pending") return const confirmed = window.confirm("Deseja revogar este convite?") if (!confirmed) return @@ -220,277 +272,555 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d } } + async function handleAssignCompany(event: React.FormEvent) { + event.preventDefault() + const normalizedEmail = linkEmail.trim().toLowerCase() + if (!normalizedEmail || !normalizedEmail.includes("@")) { + toast.error("Informe um e-mail válido para vincular") + return + } + if (!linkCompanyId) { + toast.error("Selecione a empresa para vincular") + return + } + + setAssigningCompany(true) + toast.loading("Vinculando colaborador...", { id: "assign-company" }) + try { + const response = await fetch("/api/admin/users/assign-company", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: normalizedEmail, companyId: linkCompanyId }), + credentials: "include", + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Falha ao vincular") + } + toast.success("Colaborador vinculado com sucesso", { id: "assign-company" }) + setLinkEmail("") + setLinkCompanyId("") + } catch (error) { + const message = error instanceof Error ? error.message : "Não foi possível vincular" + toast.error(message, { id: "assign-company" }) + } finally { + setAssigningCompany(false) + } + } + + async function handleSaveUser(event: React.FormEvent) { + event.preventDefault() + if (!editUser) return + + const payload = { + name: editForm.name.trim(), + email: editForm.email.trim().toLowerCase(), + role: editForm.role, + tenantId: editForm.tenantId.trim() || defaultTenantId, + companyId: editForm.companyId || null, + } + + if (!payload.name) { + toast.error("Informe o nome do usuário") + return + } + if (!payload.email || !payload.email.includes("@")) { + toast.error("Informe um e-mail válido") + return + } + + setIsSavingUser(true) + try { + const response = await fetch(`/api/admin/users/${editUser.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Não foi possível atualizar o usuário") + } + + const data = (await response.json()) as { user: AdminUser } + setUsers((previous) => previous.map((item) => (item.id === data.user.id ? data.user : item))) + toast.success("Usuário atualizado com sucesso") + setEditUserId(null) + } catch (error) { + const message = error instanceof Error ? error.message : "Erro ao salvar alterações" + toast.error(message) + } finally { + setIsSavingUser(false) + } + } + + async function handleResetPassword() { + if (!editUser) return + setIsResettingPassword(true) + toast.loading("Gerando nova senha...", { id: "reset-password" }) + try { + const response = await fetch(`/api/admin/users/${editUser.id}/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Falha ao gerar nova senha") + } + const data = (await response.json()) as { temporaryPassword: string } + setPasswordPreview(data.temporaryPassword) + toast.success("Senha temporária criada", { id: "reset-password" }) + } catch (error) { + const message = error instanceof Error ? error.message : "Erro ao gerar senha" + toast.error(message, { id: "reset-password" }) + } finally { + setIsResettingPassword(false) + } + } + + const isMachineEditing = editUser?.role === "machine" + const companyOptions = useMemo(() => [ + { id: "", name: "Sem empresa vinculada" }, + ...companies, + ], [companies]) + return ( - - - Convites - Usuários - + <> + + + Equipe + Agentes de máquina + Convites + - - - - Gerar convite - - Envie convites personalizados com validade controlada e acompanhe o status em tempo real. - - - -
-
- - setEmail(event.target.value)} - required - autoComplete="off" - /> -
-
- - setName(event.target.value)} - autoComplete="off" - /> -
-
- - -
-
- - setTenantId(event.target.value)} - placeholder="ex.: principal" - /> -

- Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão. -

-
-
- - -
-
- -
-
- {lastInviteLink ? ( -
-
-

Link de convite pronto

-

Compartilhe com o convidado. O link expira automaticamente no prazo definido.

-

{lastInviteLink}

-
- -
- ) : null} -
-
- - - - Vincular usuário a empresa - Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios). - - -
{ - e.preventDefault() - if (!linkEmail || !linkCompanyId) { - toast.error("Informe e-mail e empresa") - return - } - startTransition(async () => { - toast.loading("Vinculando...", { id: "assign-company" }) - try { - const r = await fetch("/api/admin/users/assign-company", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }), - credentials: "include", - }) - if (!r.ok) throw new Error("failed") - toast.success("Usuário vinculado à empresa!", { id: "assign-company" }) - } catch { - toast.error("Não foi possível vincular", { id: "assign-company" }) - } - }) - }} - > -
- - setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" /> -
-
- - -
-
- -
-
-
-
- - - - Convites emitidos - Histórico e status atual de todos os convites enviados para o workspace. - - - - - - - - - - - - - - - {invites.map((invite) => ( - - - - - - - - - ))} - {invites.length === 0 ? ( - - - - ) : null} - -
ColaboradorPapelEspaçoExpira emStatusAções
-
- {invite.name || invite.email} - {invite.email} -
-
{formatRole(invite.role)}{formatTenantLabel(invite.tenantId, defaultTenantId)}{formatDate(invite.expiresAt)} - - {formatStatus(invite.status)} - - -
- - {invite.status === "pending" ? ( - +
+ +

Caso a empresa ainda não exista, cadastre-a em Admin ▸ Empresas & clientes.

+ + + + + + + + Agentes de máquina + Contas provisionadas automaticamente via agente desktop. Ajustes de vínculo podem ser feitos em Admin ▸ Máquinas. + + + + + + + + + + + + + + {machineUsers.map((user) => ( + + + + + + + + ))} + {machineUsers.length === 0 ? ( + + + + ) : null} + +
IdentificaçãoE-mail técnicoPerfilCriado emAções
{user.name || "Máquina"}{user.email}{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}{formatDate(user.createdAt)} + +
+ Nenhuma máquina provisionada ainda. +
+
+
+
+ + + + + Gerar convite + Envie convites personalizados com validade controlada e acompanhe o status em tempo real. + + +
+
+ + setEmail(event.target.value)} + required + autoComplete="off" + /> +
+
+ + setName(event.target.value)} + autoComplete="off" + /> +
+
+ + +
+
+ + setTenantId(event.target.value)} + placeholder="ex.: principal" + /> +

+ Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão. +

+
+
+ + +
+
+ +
+
+ {lastInviteLink ? ( +
+
+

Link de convite pronto

+

Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.

+ {lastInviteLink} +
+ +
+ ) : null} +
+
+ + + + Convites emitidos + Histórico e status atual de todos os convites enviados para o workspace. + + + + + + + + + + + + + + + {invites.map((invite) => ( + + + + + + + + + ))} + {invites.length === 0 ? ( + + + + ) : null} + +
ColaboradorPapelEspaçoExpira emStatusAções
+
+ {invite.name || invite.email} + {invite.email} +
+
{formatRole(invite.role)}{formatTenantLabel(invite.tenantId, defaultTenantId)}{formatDate(invite.expiresAt)} + + {formatStatus(invite.status)} + + +
+ + {invite.status === "pending" ? ( + + ) : null} +
+
+ Nenhum convite emitido até o momento. +
+
+
+
+ + + (!open ? setEditUserId(null) : null)}> + + + Editar usuário + Atualize os dados cadastrais, papel e vínculo do colaborador. + + + {editUser ? ( +
+
+
+ + setEditForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder="Nome completo" + disabled={isSavingUser || isMachineEditing} + required + /> +
+
+ + setEditForm((prev) => ({ ...prev, email: event.target.value }))} + type="email" + disabled={isSavingUser || isMachineEditing} + required + /> +
+
+ + +
+
+ + setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))} + placeholder="tenant-atlas" + disabled={isSavingUser || isMachineEditing} + /> +
+
+ + +

Essa seleção substitui o vínculo atual no portal do cliente.

+
+ {isMachineEditing ? ( +
+ Os ajustes detalhados de agentes de máquina são feitos em Admin ▸ Máquinas. +
+ ) : ( +
+
+
+

Gerar nova senha

+

Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.

-
- Nenhum convite emitido até o momento. -
-
-
-
+ +
+ {passwordPreview ? ( +
+ {passwordPreview} + +
+ ) : null} + + )} + - - - - Equipe cadastrada - Usuários ativos e provisionados via convites aceitos. - - - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - ))} - {users.length === 0 ? ( - - - - ) : null} - -
NomeE-mailPapelEspaçoCriado em
{user.name || "—"}{user.email}{formatRole(user.role)}{formatTenantLabel(user.tenantId, defaultTenantId)}{formatDate(user.createdAt)}
- Nenhum usuário cadastrado até o momento. -
-
-
-
- - + + + + + + ) : null} + + + ) } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 2037dc5..015e66a 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { UserPlus, BellRing, ChevronDown, + ShieldCheck, } from "lucide-react" import { usePathname } from "next/navigation" @@ -73,7 +74,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { url: "/tickets", icon: Ticket, requiredRole: "staff", - children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }], + children: [{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" }], }, { title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" }, { title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" }, @@ -263,6 +264,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { + {child.icon ? : null} {child.title} diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index dec4676..1acf994 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { IconDotsVertical, @@ -45,6 +45,12 @@ export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar() const router = useRouter() const [isSigningOut, setIsSigningOut] = useState(false) + const [isDesktopShell, setIsDesktopShell] = useState(false) + + useEffect(() => { + if (typeof window === "undefined") return + setIsDesktopShell(Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__)) + }, []) const initials = useMemo(() => { const source = normalizedUser.name?.trim() || normalizedUser.email?.trim() || "" @@ -135,17 +141,21 @@ export function NavUser({ user }: NavUserProps) { Notificações (em breve) - - { - event.preventDefault() - handleSignOut() - }} - disabled={isSigningOut} - > - - {isSigningOut ? "Encerrando…" : "Encerrar sessão"} - + {!isDesktopShell ? ( + <> + + { + event.preventDefault() + handleSignOut() + }} + disabled={isSigningOut} + > + + {isSigningOut ? "Encerrando…" : "Encerrar sessão"} + + + ) : null} diff --git a/src/components/portal/portal-shell.tsx b/src/components/portal/portal-shell.tsx index db0a04f..98dcabb 100644 --- a/src/components/portal/portal-shell.tsx +++ b/src/components/portal/portal-shell.tsx @@ -28,8 +28,12 @@ export function PortalShell({ children }: PortalShellProps) { const isMachineSession = session?.user.role === "machine" const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null - const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente" - const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? "" + const collaboratorName = machineContext?.assignedUserName?.trim() ?? "" + const collaboratorEmail = machineContext?.assignedUserEmail?.trim() ?? "" + const userName = session?.user.name?.trim() ?? "" + const userEmail = session?.user.email?.trim() ?? "" + const displayName = collaboratorName || userName || collaboratorEmail || userEmail || "Cliente" + const displayEmail = collaboratorEmail || userEmail const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador" const initials = useMemo(() => { @@ -57,31 +61,40 @@ export function PortalShell({ children }: PortalShellProps) { } } + const isNavItemActive = (itemHref: string) => { + if (itemHref === "/portal/tickets") { + if (pathname === "/portal" || pathname === "/portal/tickets") return true + if (/^\/portal\/tickets\/[A-Za-z0-9_-]+$/.test(pathname) && !pathname.endsWith("/new")) return true + return false + } + return pathname === itemHref + } + return (
-
+
- + Portal do cliente Raven
-