feat: implement invite onboarding and dynamic ticket fields

This commit is contained in:
esdrasrenan 2025-10-05 21:47:28 -03:00
parent 29a647f6c6
commit f24a7f68ca
34 changed files with 2240 additions and 97 deletions

View file

@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@ -28,8 +29,28 @@ type AdminUser = {
updatedAt: string | null
}
type AdminInvite = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: "pending" | "accepted" | "revoked" | "expired"
token: string
inviteUrl: string
expiresAt: string
createdAt: string
createdById: string | null
acceptedAt: string | null
acceptedById: string | null
revokedAt: string | null
revokedById: string | null
revokedReason: string | null
}
type Props = {
initialUsers: AdminUser[]
initialInvites: AdminInvite[]
roleOptions: readonly RoleOption[]
defaultTenantId: string
}
@ -42,29 +63,59 @@ function formatDate(dateIso: string) {
}).format(date)
}
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
function formatStatus(status: AdminInvite["status"]) {
switch (status) {
case "pending":
return "Pendente"
case "accepted":
return "Aceito"
case "revoked":
return "Revogado"
case "expired":
return "Expirado"
default:
return status
}
}
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
const { events: unusedEvents, ...rest } = invite
void unusedEvents
return rest
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
const [users] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId)
const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null)
const [expiresInDays, setExpiresInDays] = useState<string>("7")
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = { email, name, role, tenantId }
const payload = {
email,
name,
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
}
startTransition(async () => {
try {
const response = await fetch("/api/admin/users", {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário")
throw new Error(data.error ?? "Não foi possível gerar o convite")
}
const data = (await response.json()) as {
user: AdminUser
temporaryPassword: string
}
setUsers((previous) => [data.user, ...previous])
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
const data = (await response.json()) as { invite: AdminInvite }
const nextInvite = sanitizeInvite(data.invite)
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
setEmail("")
setName("")
setRole("agent")
setTenantId(defaultTenantId)
toast.success("Usuário criado com sucesso")
setExpiresInDays("7")
setLastInviteLink(nextInvite.inviteUrl)
toast.success("Convite criado com sucesso")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao criar usuário"
const message = error instanceof Error ? error.message : "Falha ao criar convite"
toast.error(message)
}
})
}
function handleCopy(link: string) {
navigator.clipboard
.writeText(link)
.then(() => toast.success("Link de convite copiado"))
.catch(() => toast.error("Não foi possível copiar o link"))
}
async function handleRevoke(inviteId: string) {
const invite = invites.find((item) => item.id === inviteId)
if (!invite || invite.status !== "pending") {
return
}
const confirmed = window.confirm("Deseja revogar este convite?")
if (!confirmed) return
setRevokingId(inviteId)
try {
const response = await fetch(`/api/admin/invites/${inviteId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "Revogado manualmente" }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao revogar convite")
}
const data = (await response.json()) as { invite: AdminInvite }
const updated = sanitizeInvite(data.invite)
setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item)))
toast.success("Convite revogado")
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao revogar convite"
toast.error(message)
} finally {
setRevokingId(null)
}
}
return (
<Tabs defaultValue="users" className="w-full">
<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>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-6 space-y-6">
<TabsContent value="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Convidar novo usuário</CardTitle>
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_auto]">
<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
@ -142,7 +238,15 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item}
{item === "customer"
? "Cliente"
: item === "admin"
? "Administrador"
: item === "manager"
? "Gestor"
: item === "agent"
? "Agente"
: "Colaborador"}
</SelectItem>
))}
</SelectContent>
@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
onChange={(event) => setTenantId(event.target.value)}
/>
</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 ? "Criando..." : "Criar acesso"}
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInvite ? (
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
<p className="font-medium">Acesso provisório gerado</p>
<p className="mt-1 text-neutral-600">
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
Solicite que altere após o primeiro login.
</p>
{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>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">Tenant</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 uppercase text-neutral-600">{invite.role}</td>
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</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>
<TabsContent value="users" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</CardDescription>
<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">