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:
parent
300179279a
commit
1bc08d3a5f
10 changed files with 1258 additions and 166 deletions
|
|
@ -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({
|
||||
args: {
|
||||
to: v.string(),
|
||||
|
|
|
|||
|
|
@ -2456,6 +2456,25 @@ export const create = mutation({
|
|||
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) {
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
|
|
|
|||
188
scripts/test-all-emails.tsx
Normal file
188
scripts/test-all-emails.tsx
Normal 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)
|
||||
})
|
||||
101
src/app/api/auth/forgot-password/route.ts
Normal file
101
src/app/api/auth/forgot-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
97
src/app/api/auth/reset-password/route.ts
Normal file
97
src/app/api/auth/reset-password/route.ts
Normal 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" })
|
||||
}
|
||||
}
|
||||
188
src/app/recuperar/forgot-password-page-client.tsx
Normal file
188
src/app/recuperar/forgot-password-page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/app/recuperar/page.tsx
Normal file
11
src/app/recuperar/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/app/redefinir-senha/page.tsx
Normal file
11
src/app/redefinir-senha/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
278
src/app/redefinir-senha/reset-password-page-client.tsx
Normal file
278
src/app/redefinir-senha/reset-password-page-client.tsx
Normal 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 já 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ê já 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
<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-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 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="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
|
||||
<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="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
|
||||
|
||||
<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>
|
||||
<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 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-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">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-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function PreferenceItem({ title, description }: PreferenceItemProps) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
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]"
|
||||
<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!")}
|
||||
>
|
||||
{enabled ? "Ativado" : "Ativar"}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue