diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 9d1d405..4e3b540 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -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 = { + 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(), diff --git a/convex/tickets.ts b/convex/tickets.ts index 2a7af9f..428fd74 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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, diff --git a/scripts/test-all-emails.tsx b/scripts/test-all-emails.tsx new file mode 100644 index 0000000..ef9929a --- /dev/null +++ b/scripts/test-all-emails.tsx @@ -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 +} + +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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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) +}) diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..dd2ed23 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..2c7fe6e --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -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" }) + } +} diff --git a/src/app/recuperar/forgot-password-page-client.tsx b/src/app/recuperar/forgot-password-page-client.tsx new file mode 100644 index 0000000..46562aa --- /dev/null +++ b/src/app/recuperar/forgot-password-page-client.tsx @@ -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) { + 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 ( +
+
+
+ +
+ Sistema de chamados + Por Rever Tecnologia +
+ +
+
+
+ {isSuccess ? ( + + ) : ( + + )} +
+
+
+ Logotipo Raven +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} + +function ForgotPasswordForm({ + email, + setEmail, + isSubmitting, + onSubmit, +}: { + email: string + setEmail: (value: string) => void + isSubmitting: boolean + onSubmit: (event: React.FormEvent) => void +}) { + return ( +
+ +
+

Recuperar senha

+

+ Informe seu e-mail para receber as instruções de redefinição de senha. +

+
+ + E-mail + setEmail(event.target.value)} + disabled={isSubmitting} + required + /> + + + + + + + + Voltar para o login + + +
+
+ ) +} + +function SuccessMessage({ email }: { email: string }) { + return ( + +
+
+ +
+

Verifique seu e-mail

+

+ Se existir uma conta com o e-mail {email}, você receberá um link para redefinir sua senha. +

+ + O link expira em 1 hora. Verifique também sua caixa de spam. + +
+
+ + + Voltar para o login + +
+
+ ) +} diff --git a/src/app/recuperar/page.tsx b/src/app/recuperar/page.tsx new file mode 100644 index 0000000..7b510a1 --- /dev/null +++ b/src/app/recuperar/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react" + +import { ForgotPasswordPageClient } from "./forgot-password-page-client" + +export default function ForgotPasswordPage() { + return ( + Carregando...}> + + + ) +} diff --git a/src/app/redefinir-senha/page.tsx b/src/app/redefinir-senha/page.tsx new file mode 100644 index 0000000..8fe78ac --- /dev/null +++ b/src/app/redefinir-senha/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react" + +import { ResetPasswordPageClient } from "./reset-password-page-client" + +export default function ResetPasswordPage() { + return ( + Carregando...}> + + + ) +} diff --git a/src/app/redefinir-senha/reset-password-page-client.tsx b/src/app/redefinir-senha/reset-password-page-client.tsx new file mode 100644 index 0000000..96122d0 --- /dev/null +++ b/src/app/redefinir-senha/reset-password-page-client.tsx @@ -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("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) { + 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 ( +
+
+
+ +
+ Sistema de chamados + Por Rever Tecnologia +
+ +
+
+
+ {pageState === "loading" && } + {pageState === "invalid" && } + {pageState === "form" && ( + + )} + {pageState === "success" && } +
+
+
+ Logotipo Raven +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} + +function LoadingState() { + return ( + +
+ +

Validando seu link...

+
+
+ ) +} + +function InvalidTokenState() { + return ( + +
+
+ +
+

Link inválido

+

+ Este link de redefinição de senha é inválido ou já expirou. +

+ + Solicite um novo link na página de recuperação de senha. + +
+
+ + + + Voltar para o login + +
+
+ ) +} + +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) => void +}) { + return ( +
+ +
+

Nova senha

+

+ Digite sua nova senha abaixo. Escolha uma senha segura com pelo menos 6 caracteres. +

+
+ + Nova senha + setPassword(event.target.value)} + disabled={isSubmitting} + required + minLength={6} + /> + + + Confirmar senha + setConfirmPassword(event.target.value)} + disabled={isSubmitting} + required + minLength={6} + /> + + + + + + + + Voltar para o login + + +
+
+ ) +} + +function SuccessState() { + return ( + +
+
+ +
+

Senha redefinida!

+

+ Sua senha foi alterada com sucesso. Você já pode fazer login com sua nova senha. +

+
+
+ +
+
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index c8c65f5..7f8edd2 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -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 = { customer: "Cliente", } +const ROLE_COLORS: Record = { + 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 ( -
-
- - - Perfil - - Dados sincronizados via Better Auth e utilizados para provisionamento no Convex. - - - -
-
-
Nome
-
{session?.user.name ?? "—"}
-
-
-
E-mail
-
{session?.user.email ?? "—"}
-
-
-
Tenant
-
- - {tenant} - -
-
-
-
Papel
-
- - {roleLabel} - -
-
-
- -
-
- Sessão ativa - {session?.session?.id ? ( - - {session.session.id.slice(0, 8)}… - - ) : null} -
-

{sessionExpiry ? `Expira em ${sessionExpiry}` : "Sessão em background com renovação automática."}

-

- Alterações no perfil refletem instantaneamente no painel administrativo e nos relatórios. -

-
-
- - - - - -
- - - - Preferências rápidas - - - Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho. - - - - - - - - -
+
+ {/* Perfil do usuario */}
-

Administração do workspace

-

- Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador. +

Meu perfil

+

+ Suas informações pessoais e configurações de conta.

-
+ +
+ + +
+ + + + + Acesso + + + +
+ Papel + + {roleLabel} + +
+ +
+ Sessão +
+
+ Ativa +
+
+ {sessionExpiry && ( +

+ Expira em {sessionExpiry} +

+ )} + + + + + + + + + + + Segurança + + + + + + +
+
+
+ + {/* Configuracoes do workspace */} +
+
+

Configurações

+

+ Gerencie times, filas, categorias e outras configurações do sistema. +

+
+
{SETTINGS_ACTIONS.map((action) => { const allowed = canAccess(action.requiredRole) const Icon = action.icon return ( - - -
+ + +
-
+
{action.title} - {action.description} + + {action.description} +
- {!allowed ? Restrito : null} - + {allowed ? ( - ) : ( - )} @@ -286,33 +306,175 @@ export function SettingsContent() { })}
-
) } -type PreferenceItemProps = { - title: string - description: string -} +function ProfileEditCard({ + name, + email, + avatarUrl, + initials, +}: { + name: string + email: string + avatarUrl: string | null + initials: string +}) { + const [editName, setEditName] = useState(name) + const [editEmail, setEditEmail] = useState(email) + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) -function PreferenceItem({ title, description }: PreferenceItemProps) { - const [enabled, setEnabled] = useState(false) + const hasChanges = useMemo(() => { + const nameChanged = editName.trim() !== name + const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase() + const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0 + return nameChanged || emailChanged || passwordChanged + }, [editName, name, editEmail, email, newPassword, confirmPassword]) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (!hasChanges) { + toast.info("Nenhuma alteração a salvar.") + return + } + + const payload: Record = {} + 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 ( -
-
-

{title}

-

{description}

-
- -
+ + +
+
+ + + + {initials} + + + +
+
+ {name || "Usuário"} + {email} +
+
+
+ +
+
+
+ + setEditName(e.target.value)} + placeholder="Seu nome" + disabled + className="bg-neutral-50" + /> +

Editável apenas por administradores

+
+
+ + setEditEmail(e.target.value)} + placeholder="seu@email.com" + /> +
+
+ +
+ +
+ setNewPassword(e.target.value)} + placeholder="Nova senha" + /> + setConfirmPassword(e.target.value)} + placeholder="Confirmar senha" + /> +
+

+ Mínimo de 8 caracteres. Deixe em branco se não quiser alterar. +

+
+
+ +
+ +
+
) }