Initial SeConectai briefing system
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { encryptCpf, hashCpf, onlyDigits } from "@/lib/crypto";
|
||||
import { cadastroSchema, normalizeWhatsapp } from "@/lib/validators";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type CadastroState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function cadastrarCliente(_state: CadastroState, formData: FormData): Promise<CadastroState> {
|
||||
const emailForLimit = formData.get("email")?.toString().trim().toLowerCase() ?? "sem-email";
|
||||
const limit = checkRateLimit(`cadastro:${emailForLimit}`, 5, 60 * 60 * 1000);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return { ok: false, message: "Muitas tentativas. Tente novamente mais tarde." };
|
||||
}
|
||||
|
||||
const parsed = cadastroSchema.safeParse({
|
||||
nomeCompleto: formData.get("nomeCompleto"),
|
||||
cpf: formData.get("cpf"),
|
||||
whatsapp: formData.get("whatsapp"),
|
||||
email: formData.get("email"),
|
||||
senha: formData.get("senha"),
|
||||
confirmarSenha: formData.get("confirmarSenha"),
|
||||
tipo: formData.get("tipo"),
|
||||
razaoSocial: formData.get("razaoSocial") || undefined,
|
||||
cnpj: formData.get("cnpj") || undefined,
|
||||
cep: formData.get("cep") || undefined,
|
||||
logradouro: formData.get("logradouro") || undefined,
|
||||
numero: formData.get("numero") || undefined,
|
||||
complemento: formData.get("complemento") || undefined,
|
||||
bairro: formData.get("bairro") || undefined,
|
||||
cidade: formData.get("cidade") || undefined,
|
||||
uf: formData.get("uf") || undefined,
|
||||
lgpdAceite: formData.get("lgpdAceite")
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const cpfHash = hashCpf(data.cpf);
|
||||
const whatsapp = normalizeWhatsapp(data.whatsapp);
|
||||
|
||||
if (!whatsapp) {
|
||||
return { ok: false, message: "Informe um WhatsApp com DDD." };
|
||||
}
|
||||
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: data.email }, { cliente: { cpfHash } }]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
throw new Error("DUPLICADO");
|
||||
}
|
||||
|
||||
await tx.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
senhaHash: await argon2.hash(data.senha),
|
||||
papel: "CLIENTE",
|
||||
cliente: {
|
||||
create: {
|
||||
nomeCompleto: data.nomeCompleto,
|
||||
cpfCifrado: encryptCpf(data.cpf),
|
||||
cpfHash,
|
||||
whatsapp,
|
||||
tipo: data.tipo,
|
||||
razaoSocial: data.tipo === "PJ" ? data.razaoSocial : null,
|
||||
cnpj: data.tipo === "PJ" && data.cnpj ? onlyDigits(data.cnpj) : null,
|
||||
cep: data.cep ? onlyDigits(data.cep) : null,
|
||||
logradouro: data.logradouro || null,
|
||||
numero: data.numero || null,
|
||||
complemento: data.complemento || null,
|
||||
bairro: data.bairro || null,
|
||||
cidade: data.cidade || null,
|
||||
uf: data.uf || null,
|
||||
lgpdAceiteEm: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "DUPLICADO") {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Nao foi possivel concluir o cadastro com os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: "Nao foi possivel concluir o cadastro agora. Tente novamente."
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMail({
|
||||
to: data.email,
|
||||
subject: "Cadastro criado - Briefing SeConectai",
|
||||
text: [
|
||||
"Seu cadastro foi criado com sucesso.",
|
||||
"",
|
||||
"Acesse o sistema para iniciar ou acompanhar seu briefing.",
|
||||
"",
|
||||
"Este e-mail nao inclui CPF completo nem senhas."
|
||||
].join("\n")
|
||||
});
|
||||
} catch {
|
||||
// Falha de e-mail nao desfaz cadastro.
|
||||
}
|
||||
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cadastrarCliente, type CadastroState } from "./actions";
|
||||
|
||||
const initialState: CadastroState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Criando cadastro..." : "Criar cadastro"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadastroForm() {
|
||||
const [state, action] = useFormState(cadastrarCliente, initialState);
|
||||
const [tipo, setTipo] = useState<"PF" | "PJ">("PF");
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-5">
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<input type="hidden" name="tipo" value={tipo} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome completo</Label>
|
||||
<Input id="nomeCompleto" name="nomeCompleto" autoComplete="name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF</Label>
|
||||
<Input id="cpf" name="cpf" inputMode="numeric" autoComplete="off" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="whatsapp">WhatsApp</Label>
|
||||
<Input id="whatsapp" name="whatsapp" inputMode="tel" autoComplete="tel" required />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmarSenha">Confirmar senha</Label>
|
||||
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3 rounded-lg border p-4">
|
||||
<legend className="px-1 text-sm font-medium">Este site e para uma empresa?</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button type="button" variant={tipo === "PF" ? "default" : "outline"} onClick={() => setTipo("PF")}>
|
||||
Nao
|
||||
</Button>
|
||||
<Button type="button" variant={tipo === "PJ" ? "default" : "outline"} onClick={() => setTipo("PJ")}>
|
||||
Sim
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{tipo === "PJ" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="razaoSocial">Razao social</Label>
|
||||
<Input id="razaoSocial" name="razaoSocial" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnpj">CNPJ</Label>
|
||||
<Input id="cnpj" name="cnpj" inputMode="numeric" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input id="cep" name="cep" inputMode="numeric" autoComplete="postal-code" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="logradouro">Logradouro</Label>
|
||||
<Input id="logradouro" name="logradouro" autoComplete="address-line1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero">Numero</Label>
|
||||
<Input id="numero" name="numero" autoComplete="address-line2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input id="complemento" name="complemento" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input id="bairro" name="bairro" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input id="cidade" name="cidade" autoComplete="address-level2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uf">UF</Label>
|
||||
<Input id="uf" name="uf" maxLength={2} autoComplete="address-level1" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-start gap-3 rounded-lg border p-4 text-sm leading-6">
|
||||
<Checkbox name="lgpdAceite" required />
|
||||
<span>
|
||||
Li e aceito a Politica de Privacidade e autorizo o tratamento dos dados para criacao e acompanhamento do briefing.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CadastroForm } from "./cadastro-form";
|
||||
|
||||
export default function CadastroPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-2xl shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Crie seu cadastro</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Depois do cadastro, voce podera iniciar e continuar seu briefing de site.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CadastroForm />
|
||||
<p className="mt-4 text-center text-xs leading-5 text-muted-foreground">
|
||||
Ao criar cadastro, consulte tambem a{" "}
|
||||
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
|
||||
Politica de Privacidade
|
||||
</Link>{" "}
|
||||
e os{" "}
|
||||
<Link href="/termos" className="text-primary underline-offset-4 hover:underline">
|
||||
Termos de Uso
|
||||
</Link>.
|
||||
</p>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Ja tem cadastro?{" "}
|
||||
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export type LoginState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function login(_state: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const callbackUrl = formData.get("callbackUrl")?.toString();
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
email: formData.get("email"),
|
||||
senha: formData.get("senha"),
|
||||
redirectTo: callbackUrl || undefined
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "E-mail ou senha invalidos."
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
redirect(callbackUrl || "/briefings");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { login, type LoginState } from "./actions";
|
||||
|
||||
const initialState: LoginState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const [state, action] = useFormState(login, initialState);
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "";
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="current-password" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Entrar no briefing</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Acesse sua area para preencher ou acompanhar o briefing.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
<Link href="/recuperar-senha" className="text-primary underline-offset-4 hover:underline">
|
||||
Esqueci minha senha
|
||||
</Link>
|
||||
<Link href="/cadastro" className="text-primary underline-offset-4 hover:underline">
|
||||
Criar cadastro
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PrivacidadePage() {
|
||||
return (
|
||||
<main className="container max-w-3xl py-10">
|
||||
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||
Voltar
|
||||
</Link>
|
||||
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||
<h1 className="text-3xl font-semibold">Politica de Privacidade</h1>
|
||||
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Texto-base para revisao juridica. Os dados informados no cadastro e no briefing sao usados para identificar o cliente,
|
||||
organizar o projeto e permitir o acompanhamento pela SeConectai.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Dados sensiveis, como CPF, devem ser protegidos por criptografia e nunca devem aparecer em logs, e-mails ou listagens
|
||||
publicas do sistema.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type RecuperarSenhaState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function solicitarRecuperacao(
|
||||
_state: RecuperarSenhaState,
|
||||
formData: FormData
|
||||
): Promise<RecuperarSenhaState> {
|
||||
const email = formData.get("email")?.toString().trim().toLowerCase();
|
||||
const genericMessage = "Se o e-mail estiver cadastrado, enviaremos as instrucoes de redefinicao.";
|
||||
const limit = checkRateLimit(`recuperar-senha:${email ?? "sem-email"}`, 5, 60 * 60 * 1000);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, email: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const expiraEm = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await db.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiraEm
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`;
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: "Redefinicao de senha - Briefing SeConectai",
|
||||
text: `Use o link abaixo para redefinir sua senha. Ele expira em 1 hora.\n\n${resetUrl}`
|
||||
});
|
||||
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
export { hashToken };
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RecuperarSenhaForm } from "./recuperar-senha-form";
|
||||
|
||||
export default function RecuperarSenhaPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Recuperar senha</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Informe seu e-mail para receber as instrucoes de redefinicao.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecuperarSenhaForm />
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { solicitarRecuperacao, type RecuperarSenhaState } from "./actions";
|
||||
|
||||
const initialState: RecuperarSenhaState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Enviando..." : "Enviar instrucoes"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecuperarSenhaForm() {
|
||||
const [state, action] = useFormState(solicitarRecuperacao, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
{state.message ? (
|
||||
<Alert>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { hashToken } from "@/app/(publico)/recuperar-senha/actions";
|
||||
|
||||
const redefinirSenhaSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
senha: z.string().min(8, "A senha precisa ter pelo menos 8 caracteres."),
|
||||
confirmarSenha: z.string()
|
||||
})
|
||||
.refine((data) => data.senha === data.confirmarSenha, {
|
||||
path: ["confirmarSenha"],
|
||||
message: "As senhas nao conferem."
|
||||
});
|
||||
|
||||
export type RedefinirSenhaState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function redefinirSenha(
|
||||
_state: RedefinirSenhaState,
|
||||
formData: FormData
|
||||
): Promise<RedefinirSenhaState> {
|
||||
const parsed = redefinirSenhaSchema.safeParse({
|
||||
token: formData.get("token"),
|
||||
senha: formData.get("senha"),
|
||||
confirmarSenha: formData.get("confirmarSenha")
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(parsed.data.token);
|
||||
const resetToken = await db.passwordResetToken.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
expiraEm: true,
|
||||
usadoEm: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!resetToken || resetToken.usadoEm || resetToken.expiraEm < new Date()) {
|
||||
return { ok: false, message: "Link invalido ou expirado." };
|
||||
}
|
||||
|
||||
await db.$transaction([
|
||||
db.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { senhaHash: await argon2.hash(parsed.data.senha) }
|
||||
}),
|
||||
db.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { usadoEm: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RedefinirSenhaForm } from "./redefinir-senha-form";
|
||||
|
||||
export default function RedefinirSenhaPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: { token?: string };
|
||||
}) {
|
||||
const token = searchParams.token ?? "";
|
||||
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Redefinir senha</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Crie uma nova senha para acessar o briefing.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{token ? (
|
||||
<RedefinirSenhaForm token={token} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Link de redefinicao invalido.</p>
|
||||
)}
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { redefinirSenha, type RedefinirSenhaState } from "./actions";
|
||||
|
||||
const initialState: RedefinirSenhaState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Salvando..." : "Redefinir senha"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedefinirSenhaForm({ token }: { token: string }) {
|
||||
const [state, action] = useFormState(redefinirSenha, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Nova senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmarSenha">Confirmar nova senha</Label>
|
||||
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TermosPage() {
|
||||
return (
|
||||
<main className="container max-w-3xl py-10">
|
||||
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||
Voltar
|
||||
</Link>
|
||||
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||
<h1 className="text-3xl font-semibold">Termos de Uso</h1>
|
||||
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Texto-base para revisao juridica. O sistema deve ser usado para preenchimento do briefing de criacao de site, sem envio
|
||||
de senhas ou acessos sensiveis.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Informacoes enviadas pelo cliente podem orientar escopo, prazos e prioridades do projeto. Mudancas relevantes depois do
|
||||
envio podem exigir reavaliacao.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user