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({
|
export const sendPublicCommentEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -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
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"
|
"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">
|
<section className="space-y-4">
|
||||||
<CardHeader className="flex flex-col gap-1">
|
<div>
|
||||||
<CardTitle className="text-2xl font-semibold text-neutral-900">Perfil</CardTitle>
|
<h2 className="text-lg font-semibold text-neutral-900">Meu perfil</h2>
|
||||||
<CardDescription className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-500">
|
||||||
Dados sincronizados via Better Auth e utilizados para provisionamento no Convex.
|
Suas informações pessoais e configurações de conta.
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-wrap gap-2">
|
<CardFooter className="pt-0">
|
||||||
<Button size="sm" variant="outline" disabled>
|
<Button
|
||||||
Editar perfil (em breve)
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
<Button size="sm" variant="outline" asChild>
|
className="w-full gap-2 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
<Link href="mailto:suporte@sistema.dev">Solicitar ajustes</Link>
|
onClick={handleSignOut}
|
||||||
</Button>
|
disabled={isSigningOut}
|
||||||
<Button size="sm" variant="destructive" onClick={handleSignOut} disabled={isSigningOut}>
|
>
|
||||||
Encerrar sessão
|
<LogOut className="size-4" />
|
||||||
|
{isSigningOut ? "Encerrando..." : "Encerrar sessão"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-border/70">
|
|
||||||
<CardHeader className="flex flex-col gap-1">
|
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
<CardHeader className="pb-3">
|
||||||
<UserCog className="size-4 text-neutral-500" /> Preferências rápidas
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
||||||
|
<Clock className="size-4 text-neutral-500" />
|
||||||
|
Segurança
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-neutral-600">
|
|
||||||
Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-2">
|
||||||
<PreferenceItem
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2" asChild>
|
||||||
title="Abertura de tickets"
|
<Link href="/recuperar">
|
||||||
description="Sempre abrir detalhes em nova aba ao clicar na listagem."
|
<Key className="size-4" />
|
||||||
/>
|
Alterar senha
|
||||||
<PreferenceItem
|
</Link>
|
||||||
title="Notificações"
|
</Button>
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Configuracoes do workspace */}
|
||||||
<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">Configurações</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.
|
Gerencie times, filas, categorias e outras configurações do sistema.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => {
|
{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
|
||||||
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue