feat: migrate auth stack and admin portal

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-05 17:25:57 -03:00
parent ff674d5bb5
commit 7946b8d017
46 changed files with 2564 additions and 178 deletions

View file

@ -0,0 +1,240 @@
"use client"
import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
type AdminUser = {
id: string
email: string
name: string
role: RoleOption
tenantId: string
createdAt: string
updatedAt: string | null
}
type Props = {
initialUsers: AdminUser[]
roleOptions: readonly RoleOption[]
defaultTenantId: string
}
function formatDate(dateIso: string) {
const date = new Date(dateIso)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date)
}
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
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 [isPending, startTransition] = useTransition()
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleSubmit(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 }
startTransition(async () => {
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário")
}
const data = (await response.json()) as {
user: AdminUser
temporaryPassword: string
}
setUsers((previous) => [data.user, ...previous])
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
setEmail("")
setName("")
setRole("agent")
setTenantId(defaultTenantId)
toast.success("Usuário criado com sucesso")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao criar usuário"
toast.error(message)
}
})
}
return (
<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">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">
<Card>
<CardHeader>
<CardTitle>Convidar novo usuário</CardTitle>
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_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}>
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Tenant</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Criando..." : "Criar acesso"}
</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>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</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">Tenant</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 uppercase text-neutral-600">{user.role}</td>
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</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>
<TabsContent value="queues" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de filas</CardTitle>
<CardDescription>
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<TabsContent value="categories" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de categorias</CardTitle>
<CardDescription>
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
</Tabs>
)
}