feat: overhaul admin user management and desktop UX
This commit is contained in:
parent
7d6f3bea01
commit
ecad81b0ea
16 changed files with 1546 additions and 395 deletions
|
|
@ -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<AdminUser[]>(initialUsers)
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
|
||||
const [email, setEmail] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [role, setRole] = useState<RoleOption>("agent")
|
||||
const [tenantId, setTenantId] = useState(defaultTenantId)
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>("7")
|
||||
const [expiresInDays, setExpiresInDays] = useState("7")
|
||||
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
const [linkEmail, setLinkEmail] = useState("")
|
||||
const [linkCompanyId, setLinkCompanyId] = useState("")
|
||||
const [assigningCompany, setAssigningCompany] = useState(false)
|
||||
|
||||
const [editUserId, setEditUserId] = useState<string | null>(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<string | null>(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<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<Tabs defaultValue="invites" className="w-full">
|
||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
</TabsList>
|
||||
<>
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="users" className="rounded-lg">Equipe</TabsTrigger>
|
||||
<TabsTrigger value="machines" className="rounded-lg">Agentes de máquina</TabsTrigger>
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gerar convite</CardTitle>
|
||||
<CardDescription>
|
||||
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleInviteSubmit}
|
||||
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nome@suaempresa.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Nome completo"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{formatRole(item)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
placeholder="ex.: principal"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expira em</Label>
|
||||
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
||||
<SelectTrigger id="invite-expiration">
|
||||
<SelectValue placeholder="7 dias" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 dias</SelectItem>
|
||||
<SelectItem value="14">14 dias</SelectItem>
|
||||
<SelectItem value="30">30 dias</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? "Gerando..." : "Gerar convite"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{lastInviteLink ? (
|
||||
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
|
||||
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
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" })
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail do usuário</Label>
|
||||
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa</Label>
|
||||
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending}>Vincular</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convites emitidos</CardTitle>
|
||||
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
|
||||
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRevoke(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
<TabsContent value="users" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{teamUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
||||
Editar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{teamUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||
<CardDescription>Associe colaboradores existentes a uma empresa para liberar painéis de gestores e filtros específicos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAssignCompany} className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto]">
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail do usuário</Label>
|
||||
<Input
|
||||
value={linkEmail}
|
||||
onChange={(event) => setLinkEmail(event.target.value)}
|
||||
placeholder="colaborador@empresa.com"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa</Label>
|
||||
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={assigningCompany || companies.length === 0}>
|
||||
{assigningCompany ? "Vinculando..." : "Vincular"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-2 text-xs text-neutral-500">Caso a empresa ainda não exista, cadastre-a em <Link href="/admin/companies" className="underline underline-offset-4">Admin ▸ Empresas & clientes</Link>.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="machines" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agentes de máquina</CardTitle>
|
||||
<CardDescription>Contas provisionadas automaticamente via agente desktop. Ajustes de vínculo podem ser feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin ▸ Máquinas</Link>.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Identificação</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail técnico</th>
|
||||
<th className="py-3 pr-4 font-medium">Perfil</th>
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{machineUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machineUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhuma máquina provisionada ainda.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gerar convite</CardTitle>
|
||||
<CardDescription>Envie convites personalizados com validade controlada e acompanhe o status em tempo real.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleInviteSubmit}
|
||||
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nome@suaempresa.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Nome completo"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles
|
||||
.filter((option) => option !== "machine")
|
||||
.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
placeholder="ex.: principal"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expira em</Label>
|
||||
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
||||
<SelectTrigger id="invite-expiration">
|
||||
<SelectValue placeholder="7 dias" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 dias</SelectItem>
|
||||
<SelectItem value="14">14 dias</SelectItem>
|
||||
<SelectItem value="30">30 dias</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? "Gerando..." : "Gerar convite"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{lastInviteLink ? (
|
||||
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.</p>
|
||||
<code className="mt-2 block rounded bg-white px-3 py-1 text-xs text-neutral-700">{lastInviteLink}</code>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => handleCopy(lastInviteLink)}>Copiar link</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convites emitidos</CardTitle>
|
||||
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
|
||||
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRevoke(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{invites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
|
||||
<SheetContent position="right" size="lg" className="space-y-6 overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Editar usuário</SheetTitle>
|
||||
<SheetDescription>Atualize os dados cadastrais, papel e vínculo do colaborador.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{editUser ? (
|
||||
<form onSubmit={handleSaveUser} className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
<Input
|
||||
value={editForm.name}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail</Label>
|
||||
<Input
|
||||
value={editForm.email}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||
type="email"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles
|
||||
.filter((option) => option !== "machine")
|
||||
.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Espaço (tenant)</Label>
|
||||
<Input
|
||||
value={editForm.tenantId}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
||||
placeholder="tenant-atlas"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa vinculada</Label>
|
||||
<Select
|
||||
value={editForm.companyId}
|
||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, companyId: value }))}
|
||||
disabled={isSavingUser}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companyOptions.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
|
||||
</div>
|
||||
{isMachineEditing ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
|
||||
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin ▸ Máquinas</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Gerar nova senha</p>
|
||||
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{invites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
||||
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
{passwordPreview ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
|
||||
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(passwordPreview).then(() => toast.success("Senha copiada"))}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
<SheetFooter className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSavingUser || isMachineEditing} className="sm:ml-auto">
|
||||
{isSavingUser ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue