Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Flujo de forgot password #87

Merged
merged 11 commits into from
Sep 25, 2024
123 changes: 123 additions & 0 deletions src/components/changePassword/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useState } from "react";
import { Button } from "../button";
import { Alert, SuccessAlert } from "../alert";
import { PasswordInput } from "../password";
import { IChangePasswordProps } from "@/pages/changePassword";

export default function ChangePassword({
token,
}: IChangePasswordProps): JSX.Element {
const [error, setError] = useState<Error | null>(null);
const [passHasBeenChanged, setPassHasBeenChanged] = useState<boolean>(false);
return (
<main className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<figure className="sm:mx-auto sm:w-full sm:max-w-sm">
<img
className="mx-auto my-8 h-28 w-auto"
src="/lightLogo.svg"
alt="OFMI"
/>
<figcaption className="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
<h2>Cambiar contraseña</h2>
</figcaption>
</figure>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{error && <Alert errorMsg={error.message} />}
{passHasBeenChanged ? (
<SuccessAlert
title="Listo!"
text="Has cambiado tu contraseña exitosamente!"
/>
) : (
<form
className="space-y-6"
method="POST"
onSubmit={async (ev) => {
ev.preventDefault();
setError(null);
const data = new FormData(ev.currentTarget);
const pass = data.get("password")?.toString();
const passConfirm = data.get("confirmPassword")?.toString();
if (pass == null || passConfirm == null) {
setError(new Error("Todos los campos son requeridos"));
return;
}
if (pass !== passConfirm) {
setError(new Error("Las contraseñas no coinciden"));
return;
}
if (pass.length < 8) {
setError(
new Error("La contraseña debe tener al menos 8 caracteres"),
);
return;
}
const response = await fetch("/api/user/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
password: pass,
}),
});
const req = await response.json();
if (response.status !== 200) {
setError(new Error(req.message));
return;
}
setPassHasBeenChanged(true);
}}
>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium leading-6 text-gray-900"
>
Contraseña
</label>
</div>
<div className="mt-2">
<PasswordInput
id="password"
name="password"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>

<div>
<div className="flex items-center justify-between">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium leading-6 text-gray-900"
>
Confirmar contraseña
</label>
</div>
<div className="mt-2">
<PasswordInput
id="confirmPassword"
name="confirmPassword"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<Button
type="submit"
buttonType="primary"
className="w-full"
disabled={false}
>
Cambiar contraseña
</Button>
</div>
</form>
)}
</div>
</main>
);
}
78 changes: 78 additions & 0 deletions src/components/forgotPassword/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState } from "react";
import { Button } from "../button";
import { SuccessAlert } from "../alert";

export default function ForgotPassword(): JSX.Element {
const [emailHasBeenSent, setEmailHasBeenSet] = useState<boolean>(false);
return (
<main className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<figure className="sm:mx-auto sm:w-full sm:max-w-sm">
<img
className="mx-auto my-8 h-28 w-auto"
src="/lightLogo.svg"
alt="OFMI"
/>
<figcaption className="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
<h2>Recuperar cuenta</h2>
</figcaption>
</figure>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{emailHasBeenSent ? (
<SuccessAlert
title="Listo!"
text="Te hemos enviado un correo con un enlace para cambiar tu contraseña"
/>
) : (
<form
className="space-y-6"
method="POST"
onSubmit={async (ev) => {
ev.preventDefault();
const data = new FormData(ev.currentTarget);
const email = data.get("email")?.toString();
await fetch("/api/user/resetPassword", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
}),
});
setEmailHasBeenSet(true);
}}
>
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-6 text-gray-900"
>
Correo electrónico
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<Button
type="submit"
buttonType="primary"
className="w-full"
disabled={false}
>
Recuperar cuenta
</Button>
</div>
</form>
)}
</div>
</main>
);
}
3 changes: 1 addition & 2 deletions src/components/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ export default function Login({
}

async function handleResendEmailVerification(email: string): Promise<void> {
console.log("resend email verification");
const response = await resendEmailVerification({ email });
if (!response.success) {
console.log("error", response.error);
Expand Down Expand Up @@ -141,7 +140,7 @@ export default function Login({
</label>
<div className="text-right">
<a
href="#"
href="/forgotPassword"
className="font-medium text-blue-500 hover:text-blue-700"
>
¿Olvidaste tu contraseña?
Expand Down
2 changes: 1 addition & 1 deletion src/lib/emailVerificationToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const VERIFICATION_EMAIL_SECRET_KEY = "VERIFICATION_EMAIL_SECRET";
export type verificationEmailToken = Static<
typeof verificationEmailTokenSchema
>;
const verificationEmailTokenSchema = Type.Object({
export const verificationEmailTokenSchema = Type.Object({
userAuthId: Type.String(), // User auth id
});

Expand Down
13 changes: 13 additions & 0 deletions src/lib/emailer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
ofmiRegistrationCompleteTemplate,
signUpSuccessfulEmailTemplate,
OFMI_EMAIL_SMTP_USER_KEY,
passwordRecoveryAttemptTemplate,
successfulPasswordRecoveryTemplate,
} from "./template";
import config from "@/config/default";
import { getSecretOrError } from "../secret";
Expand Down Expand Up @@ -46,6 +48,17 @@ export class Emailer {
): Promise<void> {
await this.sendEmail(ofmiRegistrationCompleteTemplate(email, gDriveFolder));
}

public async notifyPasswordRecoveryAttempt(
email: string,
url: string,
): Promise<void> {
await this.sendEmail(passwordRecoveryAttemptTemplate(email, url));
}

public async notifySuccessfulPasswordRecovery(email: string): Promise<void> {
await this.sendEmail(successfulPasswordRecoveryTemplate(email));
}
}

export const emailer = new Emailer();
38 changes: 38 additions & 0 deletions src/lib/emailer/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,41 @@ export const ofmiRegistrationCompleteTemplate = (
`,
};
};

export const passwordRecoveryAttemptTemplate = (
email: string,
url: string,
): MailOptions => {
return {
from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY),
to: email,
subject: "Recuperacion de contraseña de la OFMI",
text: "Recuperacion de contraseña de la OFMI",
html: `
<p>Estás recibiendo este correo porque se recibió una solicitud de recuperación de tu contraseña de la OFMI.</p>
<p>Para continuar, haz click en el siguiente <a href="${url}">enlace</a>.</p>
<p>Si no realizaste esta solicitud o tienes alguna duda, por favor envía un correo a
<a href="mailto:[email protected]">[email protected]</a></p>
<br />
<p>Equipo organizador de la OFMI</p>
`,
};
};

export const successfulPasswordRecoveryTemplate = (
email: string,
): MailOptions => {
return {
from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY),
to: email,
subject: "Actualizacion de contraseña de la OFMI",
text: "Actualizacion de contraseña de la OFMI",
html: `
<p>Estás recibiendo este correo porque la contraseña de tu cuenta de la OFMI ha sido cambiada.</p>
<p>Si no realizaste este cambio o tienes alguna duda, por favor envía un correo a
<a href="mailto:[email protected]">[email protected]</a></p>
<br />
<p>Equipo organizador de la OFMI</p>
`,
};
};
55 changes: 55 additions & 0 deletions src/lib/passwordRecoveryToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import jwt from "jsonwebtoken";
import config from "@/config/default";
import {
verificationEmailToken,
verificationEmailTokenSchema,
} from "./emailVerificationToken";
import { jwtSign, jwtVerify } from "./jwt";
import { getSecretOrError } from "./secret";

const VERIFICATION_EMAIL_SECRET_KEY = "VERIFICATION_EMAIL_SECRET";

export default async function generateRecoveryToken(
userId: string,
): Promise<string> {
const payload: verificationEmailToken = { userAuthId: userId };
const emailToken = await jwtSign(
payload,
getSecretOrError(VERIFICATION_EMAIL_SECRET_KEY),
{
expiresIn: config.VERIFICATION_TOKEN_EXPIRATION,
},
);
return emailToken;
}

export async function validateRecoveryToken(token: string): Promise<
| {
message: string;
success: false;
}
| { success: true; userId: string }
> {
try {
const result = await jwtVerify(
verificationEmailTokenSchema,
token,
getSecretOrError(VERIFICATION_EMAIL_SECRET_KEY),
);
return {
success: true,
userId: result.userAuthId,
};
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
success: false,
message: "El token ha expirado, por favor solicita uno nuevo",
};
}
return {
success: false,
message: "El token es invalido",
};
}
}
21 changes: 21 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
// Prefix routes that requires only to be log in
const withAuthPaths = ["/mentorias", "/registro", "/oauth"];

const unauthenticatedPaths = ["changePassword", "/forgotPassword", "/signup"];

export type CustomMiddleware = (
request: NextRequest,
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;
Expand All @@ -38,6 +40,16 @@ function withAuthRoles(roles?: Array<Role>): CustomMiddleware {
};
}

const unauthenticated: CustomMiddleware = async (request) => {
const user = await getUser(request);
if (!user) {
return NextResponse.next({ request });
}
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
};

const withAuth = withAuthRoles();
const asAdmin = withAuthRoles([Role.ADMIN]);

Expand Down Expand Up @@ -68,6 +80,15 @@ export const middleware: CustomMiddleware = async (
return withAuth(request);
}

// Pages that requiresr to be unauthenticated
if (
unauthenticatedPaths.some((path) =>
request.nextUrl.pathname.startsWith(path),
)
) {
return unauthenticated(request);
}

// Allow
return NextResponse.next({ request });
};
Expand Down
Loading