feat: adiciona fluxo de redefinição de senha e melhora página de configurações

- Adiciona página /recuperar para solicitar redefinição de senha
- Adiciona página /redefinir-senha para definir nova senha com token
- Cria APIs /api/auth/forgot-password e /api/auth/reset-password
- Adiciona notificação por e-mail quando ticket é criado
- Repagina página de configurações removendo informações técnicas
- Adiciona script de teste para todos os tipos de e-mail
- Corrige acentuações em templates de e-mail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-15 10:42:08 -03:00
parent 300179279a
commit 1bc08d3a5f
10 changed files with 1258 additions and 166 deletions

View file

@ -1,17 +1,37 @@
"use client"
import { useMemo, useState } from "react"
import { FormEvent, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText, BellRing, ClipboardList } from "lucide-react"
import {
Settings2,
Share2,
ShieldCheck,
UserPlus,
Users2,
Layers3,
MessageSquareText,
BellRing,
ClipboardList,
LogOut,
Mail,
Key,
User,
Building2,
Shield,
Clock,
Camera,
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { LucideIcon } from "lucide-react"
@ -34,87 +54,79 @@ const ROLE_LABELS: Record<string, string> = {
customer: "Cliente",
}
const ROLE_COLORS: Record<string, string> = {
admin: "bg-violet-100 text-violet-700 border-violet-200",
manager: "bg-blue-100 text-blue-700 border-blue-200",
agent: "bg-cyan-100 text-cyan-700 border-cyan-200",
collaborator: "bg-slate-100 text-slate-700 border-slate-200",
customer: "bg-slate-100 text-slate-700 border-slate-200",
}
const SETTINGS_ACTIONS: SettingsAction[] = [
{
title: "Campos personalizados",
description: "Configure campos extras por formulário e empresa para enriquecer os tickets.",
description: "Configure campos extras para enriquecer os tickets.",
href: "/admin/custom-fields",
cta: "Abrir campos",
cta: "Configurar",
requiredRole: "admin",
icon: ClipboardList,
},
{
title: "Times & papéis",
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.",
title: "Times e equipes",
description: "Gerencie times e atribua permissões por equipe.",
href: "/admin/teams",
cta: "Gerenciar times",
cta: "Gerenciar",
requiredRole: "admin",
icon: Users2,
},
{
title: "Filas",
description: "Configure filas, horários de atendimento e regras automáticas de distribuição.",
title: "Filas de atendimento",
description: "Configure filas e regras de distribuição.",
href: "/admin/channels",
cta: "Abrir filas",
cta: "Configurar",
requiredRole: "admin",
icon: Share2,
},
{
title: "Categorias e formulários",
description: "Mantenha categorias padronizadas e templates de formulário alinhados à operação.",
title: "Categorias",
description: "Gerencie categorias e formulários de tickets.",
href: "/admin/fields",
cta: "Gerenciar categorias",
cta: "Gerenciar",
requiredRole: "admin",
icon: Layers3,
},
{
title: "Equipe e convites",
description: "Convide novos usuários, gerencie papéis e acompanhe quem tem acesso ao workspace.",
href: "/admin",
cta: "Abrir administração",
title: "Usuários e convites",
description: "Convide novos usuários e gerencie acessos.",
href: "/admin/users",
cta: "Gerenciar",
requiredRole: "admin",
icon: UserPlus,
},
{
title: "Templates de comentários",
description: "Gerencie mensagens rápidas utilizadas nos atendimentos.",
description: "Mensagens rápidas para os atendimentos.",
href: "/settings/templates",
cta: "Abrir templates",
cta: "Gerenciar",
requiredRole: "staff",
icon: MessageSquareText,
},
{
title: "Notificacoes por e-mail",
description: "Configure quais notificacoes por e-mail deseja receber e como recebe-las.",
title: "Notificações",
description: "Configure quais e-mails deseja receber.",
href: "/settings/notifications",
cta: "Configurar notificacoes",
cta: "Configurar",
requiredRole: "staff",
icon: BellRing,
},
{
title: "Preferencias da equipe",
description: "Defina padroes de notificacao e comportamento do modo play para toda a equipe.",
href: "#preferencias",
cta: "Ajustar preferencias",
requiredRole: "staff",
icon: Settings2,
},
{
title: "Políticas e segurança",
description: "Acompanhe SLAs críticos, rastreie integrações e revise auditorias de acesso.",
title: "Políticas de SLA",
description: "Acompanhe e configure níveis de serviço.",
href: "/admin/slas",
cta: "Revisar SLAs",
cta: "Gerenciar",
requiredRole: "admin",
icon: ShieldCheck,
},
{
title: "Alertas enviados",
description: "Histórico de alertas e notificações emitidos pela plataforma.",
href: "/admin/alerts",
cta: "Ver alertas",
requiredRole: "admin",
icon: BellRing,
},
]
export function SettingsContent() {
@ -124,7 +136,7 @@ export function SettingsContent() {
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
const tenant = session?.user.tenantId ?? DEFAULT_TENANT_ID
const roleColorClass = ROLE_COLORS[normalizedRole] ?? ROLE_COLORS.agent
const sessionExpiry = useMemo(() => {
const expiresAt = session?.session?.expiresAt
@ -157,126 +169,134 @@ export function SettingsContent() {
return false
}
const initials = useMemo(() => {
const name = session?.user.name ?? ""
const parts = name.split(" ").filter(Boolean)
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return name.slice(0, 2).toUpperCase() || "U"
}, [session?.user.name])
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-12 lg:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] lg:items-start">
<Card id="preferencias" className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="text-2xl font-semibold text-neutral-900">Perfil</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Dados sincronizados via Better Auth e utilizados para provisionamento no Convex.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<dl className="grid gap-4 text-sm text-neutral-700 sm:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Nome</dt>
<dd className="font-medium text-neutral-900">{session?.user.name ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">E-mail</dt>
<dd className="font-medium text-neutral-900">{session?.user.email ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Tenant</dt>
<dd>
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-1 text-xs uppercase tracking-wide">
{tenant}
</Badge>
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Papel</dt>
<dd>
<Badge className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide">
{roleLabel}
</Badge>
</dd>
</div>
</dl>
<Separator />
<div className="space-y-2 text-sm text-neutral-600">
<div className="flex items-center justify-between">
<span className="font-medium text-neutral-800">Sessão ativa</span>
{session?.session?.id ? (
<code className="rounded-md bg-slate-100 px-2 py-1 text-xs font-mono text-neutral-700">
{session.session.id.slice(0, 8)}
</code>
) : null}
</div>
<p>{sessionExpiry ? `Expira em ${sessionExpiry}` : "Sessão em background com renovação automática."}</p>
<p className="text-xs text-neutral-500">
Alterações no perfil refletem instantaneamente no painel administrativo e nos relatórios.
</p>
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled>
Editar perfil (em breve)
</Button>
<Button size="sm" variant="outline" asChild>
<Link href="mailto:suporte@sistema.dev">Solicitar ajustes</Link>
</Button>
<Button size="sm" variant="destructive" onClick={handleSignOut} disabled={isSigningOut}>
Encerrar sessão
</Button>
</CardFooter>
</Card>
<Card className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<UserCog className="size-4 text-neutral-500" /> Preferências rápidas
</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<PreferenceItem
title="Abertura de tickets"
description="Sempre abrir detalhes em nova aba ao clicar na listagem."
/>
<PreferenceItem
title="Notificações"
description="Receber alertas sonoros ao entrar novos tickets urgentes."
/>
<PreferenceItem
title="Modo play"
description="Priorizar tickets da fila 'Chamados' ao iniciar uma nova sessão."
/>
</CardContent>
</Card>
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 pb-12 lg:px-6">
{/* Perfil do usuario */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Administração do workspace</h2>
<p className="text-sm text-neutral-600">
Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador.
<h2 className="text-lg font-semibold text-neutral-900">Meu perfil</h2>
<p className="text-sm text-neutral-500">
Suas informações pessoais e configurações de conta.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<ProfileEditCard
name={session?.user.name ?? ""}
email={session?.user.email ?? ""}
avatarUrl={session?.user.avatarUrl ?? null}
initials={initials}
/>
<div className="space-y-4">
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
<Shield className="size-4 text-neutral-500" />
Acesso
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Papel</span>
<Badge className={`rounded-full border px-3 py-1 text-xs font-medium ${roleColorClass}`}>
{roleLabel}
</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Sessão</span>
<div className="flex items-center gap-2">
<div className="size-2 animate-pulse rounded-full bg-green-500" />
<span className="text-xs text-neutral-500">Ativa</span>
</div>
</div>
{sessionExpiry && (
<p className="text-xs text-neutral-400">
Expira em {sessionExpiry}
</p>
)}
</CardContent>
<CardFooter className="pt-0">
<Button
variant="outline"
size="sm"
className="w-full gap-2 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={handleSignOut}
disabled={isSigningOut}
>
<LogOut className="size-4" />
{isSigningOut ? "Encerrando..." : "Encerrar sessão"}
</Button>
</CardFooter>
</Card>
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
<Clock className="size-4 text-neutral-500" />
Segurança
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start gap-2" asChild>
<Link href="/recuperar">
<Key className="size-4" />
Alterar senha
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Configuracoes do workspace */}
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Configurações</h2>
<p className="text-sm text-neutral-500">
Gerencie times, filas, categorias e outras configurações do sistema.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{SETTINGS_ACTIONS.map((action) => {
const allowed = canAccess(action.requiredRole)
const Icon = action.icon
return (
<Card key={action.title} className="border border-border/70">
<CardHeader className="flex flex-row items-start gap-3">
<div className="rounded-full bg-neutral-100 p-2 text-neutral-500">
<Card
key={action.title}
className={`rounded-2xl border border-border/60 bg-white shadow-sm transition-all ${
allowed ? "hover:border-primary/30 hover:shadow-md" : "opacity-60"
}`}
>
<CardHeader className="flex flex-row items-start gap-3 pb-2">
<div className="flex size-9 items-center justify-center rounded-xl bg-neutral-100 text-neutral-600">
<Icon className="size-4" />
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="flex-1 space-y-0.5">
<CardTitle className="text-sm font-semibold text-neutral-900">{action.title}</CardTitle>
<CardDescription className="text-xs text-neutral-600">{action.description}</CardDescription>
<CardDescription className="text-xs text-neutral-500 leading-relaxed">
{action.description}
</CardDescription>
</div>
{!allowed ? <Badge variant="outline" className="rounded-full border-dashed px-2 py-0.5 text-[10px] uppercase">Restrito</Badge> : null}
</CardHeader>
<CardFooter className="justify-end">
<CardFooter className="pt-2">
{allowed ? (
<Button asChild size="sm">
<Button asChild size="sm" variant="outline" className="w-full">
<Link href={action.href}>{action.cta}</Link>
</Button>
) : (
<Button size="sm" variant="outline" disabled>
<Button size="sm" variant="outline" className="w-full" disabled>
Acesso restrito
</Button>
)}
@ -286,33 +306,175 @@ export function SettingsContent() {
})}
</div>
</section>
</div>
)
}
type PreferenceItemProps = {
title: string
description: string
}
function ProfileEditCard({
name,
email,
avatarUrl,
initials,
}: {
name: string
email: string
avatarUrl: string | null
initials: string
}) {
const [editName, setEditName] = useState(name)
const [editEmail, setEditEmail] = useState(email)
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
function PreferenceItem({ title, description }: PreferenceItemProps) {
const [enabled, setEnabled] = useState(false)
const hasChanges = useMemo(() => {
const nameChanged = editName.trim() !== name
const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase()
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
return nameChanged || emailChanged || passwordChanged
}, [editName, name, editEmail, email, newPassword, confirmPassword])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!hasChanges) {
toast.info("Nenhuma alteração a salvar.")
return
}
const payload: Record<string, unknown> = {}
const trimmedEmail = editEmail.trim()
if (trimmedEmail && trimmedEmail.toLowerCase() !== email.toLowerCase()) {
payload.email = trimmedEmail
}
if (newPassword || confirmPassword) {
if (newPassword !== confirmPassword) {
toast.error("As senhas não conferem")
return
}
if (newPassword.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres")
return
}
payload.password = { newPassword, confirmPassword }
}
setIsSubmitting(true)
try {
const res = await fetch("/api/portal/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" }))
const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil"
toast.error(message)
return
}
const data = (await res.json().catch(() => null)) as { email?: string } | null
if (data?.email) {
setEditEmail(data.email)
}
setNewPassword("")
setConfirmPassword("")
toast.success("Dados atualizados com sucesso!")
} catch (error) {
console.error("Falha ao atualizar perfil", error)
toast.error("Não foi possível atualizar o perfil.")
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex items-start justify-between gap-4 rounded-xl border border-dashed border-slate-200/80 bg-white/70 p-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<div className="space-y-1">
<p className="text-sm font-medium text-neutral-800">{title}</p>
<p className="text-xs text-neutral-500">{description}</p>
</div>
<Button
size="sm"
variant={enabled ? "default" : "outline"}
onClick={() => setEnabled((prev) => !prev)}
className="min-w-[96px]"
>
{enabled ? "Ativado" : "Ativar"}
</Button>
</div>
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-start gap-4">
<div className="relative group">
<Avatar className="size-16 border-2 border-white shadow-md">
<AvatarImage src={avatarUrl ?? undefined} alt={name} />
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-blue-600 text-lg font-semibold text-white">
{initials}
</AvatarFallback>
</Avatar>
<button
type="button"
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => toast.info("Upload de foto em breve!")}
>
<Camera className="size-5 text-white" />
</button>
</div>
<div className="flex-1">
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<User className="size-4 text-neutral-400" />
Nome
</Label>
<Input
id="profile-name"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Seu nome"
disabled
className="bg-neutral-50"
/>
<p className="text-xs text-neutral-400">Editável apenas por administradores</p>
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<Mail className="size-4 text-neutral-400" />
E-mail
</Label>
<Input
id="profile-email"
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
placeholder="seu@email.com"
/>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label className="flex items-center gap-2 text-sm font-medium text-neutral-700">
<Key className="size-4 text-neutral-400" />
Alterar senha
</Label>
<div className="grid gap-3 sm:grid-cols-2">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Nova senha"
/>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirmar senha"
/>
</div>
<p className="text-xs text-neutral-400">
Mínimo de 8 caracteres. Deixe em branco se não quiser alterar.
</p>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting || !hasChanges}>
{isSubmitting ? "Salvando..." : "Salvar alterações"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}