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

@ -281,6 +281,43 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
} }
} }
export const sendTicketCreatedEmail = action({
args: {
to: v.string(),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
priority: v.string(),
},
handler: async (_ctx, { to, ticketId, reference, subject, priority }) => {
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityLabel = priorityLabels[priority] ?? priority
const mailSubject = `Novo chamado #${reference} aberto`
const html = await renderSimpleNotificationEmailHtml({
title: `Novo chamado #${reference} aberto`,
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
ctaLabel: "Ver chamado",
ctaUrl: url,
})
await sendSmtpMail(smtp, to, mailSubject, html)
return { ok: true }
},
})
export const sendPublicCommentEmail = action({ export const sendPublicCommentEmail = action({
args: { args: {
to: v.string(), to: v.string(),

View file

@ -2456,6 +2456,25 @@ export const create = mutation({
createdAt: now, createdAt: now,
}); });
// Notificação por e-mail: ticket criado para o solicitante
try {
const requesterEmail = requester?.email
if (requesterEmail) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
to: requesterEmail,
ticketId: String(id),
reference: nextRef,
subject,
priority: args.priority,
})
}
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e)
}
if (initialAssigneeId && initialAssignee) { if (initialAssigneeId && initialAssignee) {
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: id, ticketId: id,

188
scripts/test-all-emails.tsx Normal file
View file

@ -0,0 +1,188 @@
import * as React from "react"
import dotenv from "dotenv"
import { render } from "@react-email/render"
import { sendSmtpMail } from "@/server/email-smtp"
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
dotenv.config({ path: ".env.local" })
dotenv.config({ path: ".env" })
function getSmtpConfig() {
const host = process.env.SMTP_HOST
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER
const password = process.env.SMTP_PASS
const fromEmail = process.env.SMTP_FROM_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
type EmailScenario = {
name: string
subject: string
render: () => Promise<string>
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tickets.esdrasrenan.com.br"
const scenarios: EmailScenario[] = [
{
name: "Ticket Criado",
subject: "[TESTE] Novo chamado #41025 aberto",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Novo chamado #41025 aberto",
message: "Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: Computador reiniciando sozinho\nPrioridade: Alta\nStatus: Pendente",
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Ticket Resolvido",
subject: "[TESTE] Chamado #41025 foi encerrado",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Chamado #41025 encerrado",
message: "O chamado 'Computador reiniciando sozinho' foi marcado como concluído.\n\nCaso necessário, você pode responder pelo portal para reabrir dentro do prazo.",
ctaLabel: "Ver detalhes",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Novo Comentário",
subject: "[TESTE] Atualização no chamado #41025",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Nova atualização no seu chamado #41025",
message: "Um novo comentário foi adicionado ao chamado 'Computador reiniciando sozinho'.\n\nClique abaixo para visualizar e responder pelo portal.",
ctaLabel: "Abrir e responder",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Mudança de Prioridade",
subject: "[TESTE] Prioridade alterada no chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Prioridade alterada para Urgente",
message: "A prioridade do seu chamado foi alterada automaticamente pelo sistema.\n\nIsso pode ter ocorrido devido a regras de SLA ou categorização automática.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "URGENT",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Atribuição de Agente",
subject: "[TESTE] Agente atribuído ao chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Agente atribuído ao seu chamado",
message: "O agente Administrador foi automaticamente atribuído ao seu chamado e entrará em contato em breve.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Redefinição de Senha",
subject: "[TESTE] Redefinição de senha - Raven",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Redefinição de Senha",
message: "Recebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail.\n\nEste link expira em 1 hora.",
ctaLabel: "Redefinir Senha",
ctaUrl: `${baseUrl}/redefinir-senha?token=abc123def456`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
]
async function main() {
const targetEmail = process.argv[2] ?? "renan.pac@paulicon.com.br"
const smtp = getSmtpConfig()
if (!smtp) {
console.error("SMTP não configurado. Defina as variáveis SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL")
process.exit(1)
}
console.log("=".repeat(60))
console.log("Teste de E-mails - Sistema de Chamados Raven")
console.log("=".repeat(60))
console.log(`\nDestinatario: ${targetEmail}`)
console.log(`SMTP: ${smtp.host}:${smtp.port}`)
console.log(`De: ${smtp.from}`)
console.log(`\nEnviando ${scenarios.length} e-mails de teste...\n`)
let success = 0
let failed = 0
for (const scenario of scenarios) {
try {
process.stdout.write(` ${scenario.name}... `)
const html = await scenario.render()
await sendSmtpMail(smtp, targetEmail, scenario.subject, html)
console.log("OK")
success++
// Pequeno delay entre envios para evitar rate limit
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
console.log(`ERRO: ${error instanceof Error ? error.message : error}`)
failed++
}
}
console.log("\n" + "=".repeat(60))
console.log(`Resultado: ${success} enviados, ${failed} falharam`)
console.log("=".repeat(60))
if (failed > 0) {
process.exit(1)
}
}
main().catch((error) => {
console.error("Erro fatal:", error)
process.exit(1)
})

View file

@ -0,0 +1,101 @@
import crypto from "crypto"
import { render } from "@react-email/render"
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { sendSmtpMail } from "@/server/email-smtp"
import SimpleNotificationEmail from "../../../../../emails/simple-notification-email"
function getSmtpConfig() {
const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME
const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD
const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
starttls: process.env.SMTP_SECURE !== "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { email } = body
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 })
}
const normalizedEmail = email.toLowerCase().trim()
// Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança)
const user = await prisma.authUser.findFirst({
where: { email: normalizedEmail },
})
// Sempre retorna sucesso para não revelar se o e-mail existe
if (!user) {
return NextResponse.json({ success: true })
}
// Gera um token seguro
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora
// Remove tokens anteriores do mesmo usuário
await prisma.authVerification.deleteMany({
where: {
identifier: `password-reset:${user.id}`,
},
})
// Salva o novo token
await prisma.authVerification.create({
data: {
identifier: `password-reset:${user.id}`,
value: token,
expiresAt,
},
})
// Envia o e-mail
const smtp = getSmtpConfig()
if (!smtp) {
console.error("[FORGOT_PASSWORD] SMTP não configurado")
return NextResponse.json({ success: true }) // Não revela erro de configuração
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`
const html = await render(
SimpleNotificationEmail({
title: "Redefinição de Senha",
message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`,
ctaLabel: "Redefinir Senha",
ctaUrl: resetUrl,
}),
{ pretty: true }
)
await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html)
return NextResponse.json({ success: true })
} catch (error) {
console.error("[FORGOT_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 })
}
}

View file

@ -0,0 +1,97 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { prisma } from "@/lib/prisma"
export async function POST(request: Request) {
try {
const body = await request.json()
const { token, password } = body
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token inválido" }, { status: 400 })
}
if (!password || typeof password !== "string" || password.length < 6) {
return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 })
}
// Busca o token de verificação
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 })
}
// Extrai o userId do identifier
const userId = verification.identifier.replace("password-reset:", "")
// Busca o usuário
const user = await prisma.authUser.findUnique({
where: { id: userId },
})
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 })
}
// Hash da nova senha
const hashedPassword = await hashPassword(password)
// Atualiza a conta do usuário com a nova senha
await prisma.authAccount.updateMany({
where: {
userId: user.id,
providerId: "credential",
},
data: {
password: hashedPassword,
},
})
// Remove o token usado
await prisma.authVerification.delete({
where: { id: verification.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 })
}
}
// GET para validar se o token é válido (usado pela página)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get("token")
if (!token) {
return NextResponse.json({ valid: false, error: "Token não fornecido" })
}
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ valid: false, error: "Token inválido ou expirado" })
}
return NextResponse.json({ valid: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro ao validar token:", error)
return NextResponse.json({ valid: false, error: "Erro ao validar token" })
}
}

View file

@ -0,0 +1,188 @@
"use client"
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
import dynamic from "next/dynamic"
import { ArrowLeft, Loader2, Mail } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
export function ForgotPasswordPageClient() {
const [email, setEmail] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isSubmitting) return
if (!email) {
toast.error("Informe seu e-mail")
return
}
setIsSubmitting(true)
try {
const response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.error ?? "Erro ao processar solicitação")
setIsSubmitting(false)
return
}
setIsSuccess(true)
toast.success("E-mail de recuperação enviado!")
} catch (error) {
console.error("Erro ao solicitar recuperação", error)
toast.error("Não foi possível processar. Tente novamente")
setIsSubmitting(false)
}
}
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="text-xl font-semibold text-neutral-900">
<div className="flex flex-col leading-none items-center">
<span>Sistema de chamados</span>
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
</div>
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
{isSuccess ? (
<SuccessMessage email={email} />
) : (
<ForgotPasswordForm
email={email}
setEmail={setEmail}
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
/>
)}
</div>
</div>
<div className="flex justify-center">
<Image
src="/logo-raven.png"
alt="Logotipo Raven"
width={160}
height={160}
className="h-16 w-auto"
priority
/>
</div>
<footer className="flex justify-center text-sm text-neutral-500">
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
)
}
function ForgotPasswordForm({
email,
setEmail,
isSubmitting,
onSubmit,
}: {
email: string
setEmail: (value: string) => void
isSubmitting: boolean
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
}) {
return (
<form onSubmit={onSubmit} className={cn("flex flex-col gap-6", isSubmitting && "pointer-events-none opacity-70")}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Recuperar senha</h1>
<p className="text-muted-foreground text-sm text-balance">
Informe seu e-mail para receber as instruções de redefinição de senha.
</p>
</div>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="seu@email.com"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isSubmitting}
required
/>
</Field>
<Field>
<Button type="submit" disabled={isSubmitting} className="gap-2">
{isSubmitting && <Loader2 className="size-4 animate-spin" />}
Enviar instruções
</Button>
</Field>
<Field>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Voltar para o login
</Link>
</Field>
</FieldGroup>
</form>
)
}
function SuccessMessage({ email }: { email: string }) {
return (
<FieldGroup>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-green-100">
<Mail className="size-7 text-green-600" />
</div>
<h1 className="text-2xl font-bold">Verifique seu e-mail</h1>
<p className="text-muted-foreground text-sm text-balance">
Se existir uma conta com o e-mail <strong>{email}</strong>, você receberá um link para redefinir sua senha.
</p>
<FieldDescription className="text-sm">
O link expira em 1 hora. Verifique também sua caixa de spam.
</FieldDescription>
</div>
<div className="mt-4">
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Voltar para o login
</Link>
</div>
</FieldGroup>
)
}

View file

@ -0,0 +1,11 @@
import { Suspense } from "react"
import { ForgotPasswordPageClient } from "./forgot-password-page-client"
export default function ForgotPasswordPage() {
return (
<Suspense fallback={<div className="flex min-h-svh items-center justify-center">Carregando...</div>}>
<ForgotPasswordPageClient />
</Suspense>
)
}

View file

@ -0,0 +1,11 @@
import { Suspense } from "react"
import { ResetPasswordPageClient } from "./reset-password-page-client"
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div className="flex min-h-svh items-center justify-center">Carregando...</div>}>
<ResetPasswordPageClient />
</Suspense>
)
}

View file

@ -0,0 +1,278 @@
"use client"
import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import dynamic from "next/dynamic"
import { ArrowLeft, CheckCircle, Loader2, XCircle } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
type PageState = "loading" | "invalid" | "form" | "success"
export function ResetPasswordPageClient() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams?.get("token") ?? ""
const [pageState, setPageState] = useState<PageState>("loading")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (!token) {
setPageState("invalid")
return
}
async function validateToken() {
try {
const response = await fetch(`/api/auth/reset-password?token=${token}`)
const data = await response.json()
setPageState(data.valid ? "form" : "invalid")
} catch {
setPageState("invalid")
}
}
validateToken()
}, [token])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isSubmitting) return
if (password.length < 6) {
toast.error("A senha deve ter pelo menos 6 caracteres")
return
}
if (password !== confirmPassword) {
toast.error("As senhas não conferem")
return
}
setIsSubmitting(true)
try {
const response = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.error ?? "Erro ao redefinir senha")
setIsSubmitting(false)
return
}
setPageState("success")
toast.success("Senha redefinida com sucesso!")
} catch (error) {
console.error("Erro ao redefinir senha", error)
toast.error("Não foi possível redefinir a senha. Tente novamente")
setIsSubmitting(false)
}
}
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="text-xl font-semibold text-neutral-900">
<div className="flex flex-col leading-none items-center">
<span>Sistema de chamados</span>
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
</div>
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
{pageState === "loading" && <LoadingState />}
{pageState === "invalid" && <InvalidTokenState />}
{pageState === "form" && (
<ResetPasswordForm
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
/>
)}
{pageState === "success" && <SuccessState />}
</div>
</div>
<div className="flex justify-center">
<Image
src="/logo-raven.png"
alt="Logotipo Raven"
width={160}
height={160}
className="h-16 w-auto"
priority
/>
</div>
<footer className="flex justify-center text-sm text-neutral-500">
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
)
}
function LoadingState() {
return (
<FieldGroup>
<div className="flex flex-col items-center gap-4 text-center py-8">
<Loader2 className="size-10 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">Validando seu link...</p>
</div>
</FieldGroup>
)
}
function InvalidTokenState() {
return (
<FieldGroup>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-red-100">
<XCircle className="size-7 text-red-600" />
</div>
<h1 className="text-2xl font-bold">Link inválido</h1>
<p className="text-muted-foreground text-sm text-balance">
Este link de redefinição de senha é inválido ou expirou.
</p>
<FieldDescription className="text-sm">
Solicite um novo link na página de recuperação de senha.
</FieldDescription>
</div>
<div className="mt-6 flex flex-col gap-3">
<Button asChild>
<Link href="/recuperar">Solicitar novo link</Link>
</Button>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Voltar para o login
</Link>
</div>
</FieldGroup>
)
}
function ResetPasswordForm({
password,
setPassword,
confirmPassword,
setConfirmPassword,
isSubmitting,
onSubmit,
}: {
password: string
setPassword: (value: string) => void
confirmPassword: string
setConfirmPassword: (value: string) => void
isSubmitting: boolean
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
}) {
return (
<form onSubmit={onSubmit} className={cn("flex flex-col gap-6", isSubmitting && "pointer-events-none opacity-70")}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Nova senha</h1>
<p className="text-muted-foreground text-sm text-balance">
Digite sua nova senha abaixo. Escolha uma senha segura com pelo menos 6 caracteres.
</p>
</div>
<Field>
<FieldLabel htmlFor="password">Nova senha</FieldLabel>
<Input
id="password"
type="password"
placeholder="Digite sua nova senha"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
required
minLength={6}
/>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword">Confirmar senha</FieldLabel>
<Input
id="confirmPassword"
type="password"
placeholder="Digite novamente"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={isSubmitting}
required
minLength={6}
/>
</Field>
<Field>
<Button type="submit" disabled={isSubmitting} className="gap-2">
{isSubmitting && <Loader2 className="size-4 animate-spin" />}
Redefinir senha
</Button>
</Field>
<Field>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Voltar para o login
</Link>
</Field>
</FieldGroup>
</form>
)
}
function SuccessState() {
return (
<FieldGroup>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-green-100">
<CheckCircle className="size-7 text-green-600" />
</div>
<h1 className="text-2xl font-bold">Senha redefinida!</h1>
<p className="text-muted-foreground text-sm text-balance">
Sua senha foi alterada com sucesso. Você pode fazer login com sua nova senha.
</p>
</div>
<div className="mt-6">
<Button asChild className="w-full">
<Link href="/login">Ir para o login</Link>
</Button>
</div>
</FieldGroup>
)
}

View file

@ -1,17 +1,37 @@
"use client" "use client"
import { useMemo, useState } from "react" import { FormEvent, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" 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 { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client" import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
@ -34,87 +54,79 @@ const ROLE_LABELS: Record<string, string> = {
customer: "Cliente", 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[] = [ const SETTINGS_ACTIONS: SettingsAction[] = [
{ {
title: "Campos personalizados", 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", href: "/admin/custom-fields",
cta: "Abrir campos", cta: "Configurar",
requiredRole: "admin", requiredRole: "admin",
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
title: "Times & papéis", title: "Times e equipes",
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.", description: "Gerencie times e atribua permissões por equipe.",
href: "/admin/teams", href: "/admin/teams",
cta: "Gerenciar times", cta: "Gerenciar",
requiredRole: "admin", requiredRole: "admin",
icon: Users2, icon: Users2,
}, },
{ {
title: "Filas", title: "Filas de atendimento",
description: "Configure filas, horários de atendimento e regras automáticas de distribuição.", description: "Configure filas e regras de distribuição.",
href: "/admin/channels", href: "/admin/channels",
cta: "Abrir filas", cta: "Configurar",
requiredRole: "admin", requiredRole: "admin",
icon: Share2, icon: Share2,
}, },
{ {
title: "Categorias e formulários", title: "Categorias",
description: "Mantenha categorias padronizadas e templates de formulário alinhados à operação.", description: "Gerencie categorias e formulários de tickets.",
href: "/admin/fields", href: "/admin/fields",
cta: "Gerenciar categorias", cta: "Gerenciar",
requiredRole: "admin", requiredRole: "admin",
icon: Layers3, icon: Layers3,
}, },
{ {
title: "Equipe e convites", title: "Usuários e convites",
description: "Convide novos usuários, gerencie papéis e acompanhe quem tem acesso ao workspace.", description: "Convide novos usuários e gerencie acessos.",
href: "/admin", href: "/admin/users",
cta: "Abrir administração", cta: "Gerenciar",
requiredRole: "admin", requiredRole: "admin",
icon: UserPlus, icon: UserPlus,
}, },
{ {
title: "Templates de comentários", title: "Templates de comentários",
description: "Gerencie mensagens rápidas utilizadas nos atendimentos.", description: "Mensagens rápidas para os atendimentos.",
href: "/settings/templates", href: "/settings/templates",
cta: "Abrir templates", cta: "Gerenciar",
requiredRole: "staff", requiredRole: "staff",
icon: MessageSquareText, icon: MessageSquareText,
}, },
{ {
title: "Notificacoes por e-mail", title: "Notificações",
description: "Configure quais notificacoes por e-mail deseja receber e como recebe-las.", description: "Configure quais e-mails deseja receber.",
href: "/settings/notifications", href: "/settings/notifications",
cta: "Configurar notificacoes", cta: "Configurar",
requiredRole: "staff", requiredRole: "staff",
icon: BellRing, icon: BellRing,
}, },
{ {
title: "Preferencias da equipe", title: "Políticas de SLA",
description: "Defina padroes de notificacao e comportamento do modo play para toda a equipe.", description: "Acompanhe e configure níveis de serviço.",
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.",
href: "/admin/slas", href: "/admin/slas",
cta: "Revisar SLAs", cta: "Gerenciar",
requiredRole: "admin", requiredRole: "admin",
icon: ShieldCheck, 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() { export function SettingsContent() {
@ -124,7 +136,7 @@ export function SettingsContent() {
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent" const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente" 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 sessionExpiry = useMemo(() => {
const expiresAt = session?.session?.expiresAt const expiresAt = session?.session?.expiresAt
@ -157,126 +169,134 @@ export function SettingsContent() {
return false 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 ( return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-12 lg:px-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-8 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"> {/* Perfil do usuario */}
<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>
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h2 className="text-base font-semibold text-neutral-900">Administração do workspace</h2> <h2 className="text-lg font-semibold text-neutral-900">Meu perfil</h2>
<p className="text-sm text-neutral-600"> <p className="text-sm text-neutral-500">
Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador. Suas informações pessoais e configurações de conta.
</p> </p>
</div> </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) => { {SETTINGS_ACTIONS.map((action) => {
const allowed = canAccess(action.requiredRole) const allowed = canAccess(action.requiredRole)
const Icon = action.icon const Icon = action.icon
return ( return (
<Card key={action.title} className="border border-border/70"> <Card
<CardHeader className="flex flex-row items-start gap-3"> key={action.title}
<div className="rounded-full bg-neutral-100 p-2 text-neutral-500"> 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" /> <Icon className="size-4" />
</div> </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> <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> </div>
{!allowed ? <Badge variant="outline" className="rounded-full border-dashed px-2 py-0.5 text-[10px] uppercase">Restrito</Badge> : null}
</CardHeader> </CardHeader>
<CardFooter className="justify-end"> <CardFooter className="pt-2">
{allowed ? ( {allowed ? (
<Button asChild size="sm"> <Button asChild size="sm" variant="outline" className="w-full">
<Link href={action.href}>{action.cta}</Link> <Link href={action.href}>{action.cta}</Link>
</Button> </Button>
) : ( ) : (
<Button size="sm" variant="outline" disabled> <Button size="sm" variant="outline" className="w-full" disabled>
Acesso restrito Acesso restrito
</Button> </Button>
)} )}
@ -286,33 +306,175 @@ export function SettingsContent() {
})} })}
</div> </div>
</section> </section>
</div> </div>
) )
} }
type PreferenceItemProps = { function ProfileEditCard({
title: string name,
description: string 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 hasChanges = useMemo(() => {
const [enabled, setEnabled] = useState(false) 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 ( 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)]"> <Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
<div className="space-y-1"> <CardHeader className="pb-4">
<p className="text-sm font-medium text-neutral-800">{title}</p> <div className="flex items-start gap-4">
<p className="text-xs text-neutral-500">{description}</p> <div className="relative group">
</div> <Avatar className="size-16 border-2 border-white shadow-md">
<Button <AvatarImage src={avatarUrl ?? undefined} alt={name} />
size="sm" <AvatarFallback className="bg-gradient-to-br from-cyan-500 to-blue-600 text-lg font-semibold text-white">
variant={enabled ? "default" : "outline"} {initials}
onClick={() => setEnabled((prev) => !prev)} </AvatarFallback>
className="min-w-[96px]" </Avatar>
> <button
{enabled ? "Ativado" : "Ativar"} type="button"
</Button> className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
</div> 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>
) )
} }