diff --git a/Pipfile b/Pipfile index bdd43c634b..630ad9874f 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,7 @@ typing-extensions = "*" flask-jwt-extended = "*" wtforms = "==3.1.2" flask-bcrypt = "*" +flask-mail = "*" [requires] python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock index 3f91abb23b..dbf8d0dae4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "962f6b9c1214f196ffbb77af32268c9ca3e3e2dff9105182dc0aa1f56879dcfb" + "sha256": "622091408571d30ecefdd6f7a96bce85eef0b10b8468f5110c00adb6d86cfdf4" }, "pipfile-spec": 6, "requires": { @@ -153,6 +153,14 @@ "index": "pypi", "version": "==4.7.1" }, + "flask-mail": { + "hashes": [ + "sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d", + "sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7" + ], + "index": "pypi", + "version": "==0.10.0" + }, "flask-migrate": { "hashes": [ "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", diff --git a/migrations/versions/3388104734f6_.py b/migrations/versions/dda1fcda5948_.py similarity index 85% rename from migrations/versions/3388104734f6_.py rename to migrations/versions/dda1fcda5948_.py index 75a591d58f..b93ea547ca 100644 --- a/migrations/versions/3388104734f6_.py +++ b/migrations/versions/dda1fcda5948_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: 3388104734f6 +Revision ID: dda1fcda5948 Revises: -Create Date: 2025-03-17 11:09:47.021713 +Create Date: 2025-03-18 16:04:42.679001 """ from alembic import op @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. -revision = '3388104734f6' +revision = 'dda1fcda5948' down_revision = None branch_labels = None depends_on = None @@ -44,11 +44,20 @@ def upgrade(): sa.Column('url', sa.String(length=255), nullable=True), sa.PrimaryKeyConstraint('id') ) + op.create_table('password_reset', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('token', sa.String(length=255), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) op.create_table('user', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('email', sa.String(length=120), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), + sa.Column('is_temporary_password', sa.Boolean(), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) @@ -82,6 +91,7 @@ def downgrade(): op.drop_table('pet') op.drop_table('order') op.drop_table('user') + op.drop_table('password_reset') op.drop_table('foods') op.drop_table('accessories') # ### end Alembic commands ### diff --git a/src/api/models.py b/src/api/models.py index 34664abb4a..8cb0a08067 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,7 +1,7 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy import ForeignKey - +from datetime import datetime @@ -27,6 +27,25 @@ def serialize(self): "name": self.name # do not serialize the password, its a security breach } + +class PasswordReset(db.Model): + __tablename__ = 'password_reset' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), nullable=False) + token = db.Column(db.String(255), nullable=False, unique=True) + expires_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def serialize(self): + return { + "id": self.id, + "email": self.email, + "token": self.token, + "expires_at": self.expires_at.strftime("%Y-%m-%d %H:%M:%S") + } + + def __repr__(self): + return f'' class Food(db.Model): diff --git a/src/api/routes.py b/src/api/routes.py index 0d6a915b1d..2df4de377e 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,7 +1,7 @@ """ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ -from flask import Flask, request, jsonify, url_for, Blueprint +from flask import Flask, request, jsonify, url_for, Blueprint, current_app from api.models import db, User, Food, Pet, Accessories, Order from api.utils import generate_sitemap, APIException from flask_cors import CORS @@ -9,6 +9,9 @@ import json from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity from flask_bcrypt import Bcrypt +from flask_mail import Message +import random, string + api = Blueprint('api', __name__) bcrypt = Bcrypt() @@ -27,6 +30,34 @@ # return jsonify(response_body), 200 +@api.route("/forgotpassword", methods=["POST"]) +def forgotpassword(): + recover_email = request.json.get('email') + + user = User.query.filter_by(email=recover_email).first() + if not user: + return jsonify({"msg": "El correo ingresado no existe en nuestros registros"}), 400 + + # Genera una nueva contraseña temporal (aleatoria) + recover_password = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(12)) + + # Guardar la nueva contraseña cifrada (muy importante) + bcrypt = Bcrypt() + hashed_password = bcrypt.generate_password_hash(recover_password).decode('utf-8') + user.password = hashed_password + db.session.commit() + + # Enviar la contraseña temporal al correo del usuario + from flask_mail import Message + msg = Message( + subject="Recuperación de contraseña", + sender=current_app.config["MAIL_USERNAME"], + recipients=[recover_email], + body=f"Tu nueva contraseña temporal es: {recover_password}. Por favor inicia sesión y cámbiala inmediatamente." + ) + current_app.mail.send(msg) + + return jsonify({"msg": "Se ha enviado una nueva contraseña a tu correo electrónico"}), 200 @api.route('/') def sitemap(): diff --git a/src/app.py b/src/app.py index 73df54a838..fc6b818cb4 100644 --- a/src/app.py +++ b/src/app.py @@ -4,25 +4,24 @@ import os from flask import Flask, request, jsonify, url_for, send_from_directory from flask_migrate import Migrate -from flask_swagger import swagger from api.utils import APIException, generate_sitemap from api.models import db from api.routes import api from api.admin import setup_admin from api.commands import setup_commands -from flask_jwt_extended import JWTManager -from flask_bcrypt import Bcrypt +from flask_jwt_extended import JWTManager +from flask_bcrypt import Bcrypt from datetime import timedelta - -# from models import Person +from flask_mail import Mail ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production" static_file_dir = os.path.join(os.path.dirname( os.path.realpath(__file__)), '../public/') + app = Flask(__name__) app.url_map.strict_slashes = False -# database condiguration +# configuración base de datos db_url = os.getenv("DATABASE_URL") if db_url is not None: app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace( @@ -34,47 +33,58 @@ MIGRATE = Migrate(app, db, compare_type=True) db.init_app(app) -app.config["JWT_ACCESS_TOKEN_EXPIRE"] = timedelta(hours=1) +# JWT y Bcrypt +app.config["JWT_SECRET_KEY"] = "cualquiercosa" +app.config["JWT_ACCESS_TOKEN_EXPIRE"] = timedelta(hours=1) jwt = JWTManager(app) bcrypt = Bcrypt(app) +# Configuración correcta y real de Flask-Mail 👇 (ajusta tus credenciales aquí) +mail_settings = { + "MAIL_SERVER": 'smtp.gmail.com', + "MAIL_PORT": 465, + "MAIL_USE_TLS": False, + "MAIL_USE_SSL": True, + "MAIL_USERNAME": 'Puppereatsapp@gmail.com', # el nuevo correo Gmail creado + "MAIL_PASSWORD": 'kxrv widw xqhr frgi' # la contraseña que generaste +} + +app.config.update(mail_settings) +mail = Mail(app) +app.mail = mail + # add the admin setup_admin(app) -# add the admin +# add custom commands setup_commands(app) -# Add all endpoints form the API with a "api" prefix +# Registra todas tus rutas API app.register_blueprint(api, url_prefix='/api') -# Handle/serialize errors like a JSON object - - +# Manejo de errores @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code -# generate sitemap with all your endpoints - - +# Sitemap @app.route('/') def sitemap(): if ENV == "development": return generate_sitemap(app) return send_from_directory(static_file_dir, 'index.html') -# any other endpoint will try to serve it like a static file +# Cualquier otra ruta intenta servir un archivo estático @app.route('/', methods=['GET']) def serve_any_other_file(path): if not os.path.isfile(os.path.join(static_file_dir, path)): path = 'index.html' response = send_from_directory(static_file_dir, path) - response.cache_control.max_age = 0 # avoid cache memory + response.cache_control.max_age = 0 return response - -# this only runs if `$ python src/main.py` is executed +# Arranca el servidor Flask if __name__ == '__main__': PORT = int(os.environ.get('PORT', 3001)) - app.run(host='0.0.0.0', port=PORT, debug=True) \ No newline at end of file + app.run(host='0.0.0.0', port=PORT, debug=True) diff --git "a/src/front/js/component/recuperacionContrase\303\261a.js" "b/src/front/js/component/recuperacionContrase\303\261a.js" new file mode 100644 index 0000000000..75d04d9eb4 --- /dev/null +++ "b/src/front/js/component/recuperacionContrase\303\261a.js" @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; // ✅ Importamos useNavigate + +export const RecuperacionContraseña = () => { + const [email, setEmail] = useState(''); + const navigate = useNavigate(); // ✅ Inicializamos el hook de navegación + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + const response = await fetch(`${process.env.BACKEND_URL}/api/forgotpassword`, { // ✅ Asegúrate de que esta ruta es correcta + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + + const data = await response.json(); + + if (response.ok) { + alert("Correo enviado correctamente. Revisa tu bandeja de entrada."); // ✅ Alert con el mensaje + navigate("/loginSignup"); // ✅ Redirigir al inicio de sesión después de aceptar el alert + } else { + alert(`Error: ${data.msg}`); + } + } catch (error) { + alert("Error de conexión con el servidor."); + } + }; + + return ( +
+

Recuperar contraseña

+
+ setEmail(e.target.value)} + required + /> + +
+
+ ); +}; diff --git a/src/front/js/layout.js b/src/front/js/layout.js index 31ab008d86..1d8e8b166b 100755 --- a/src/front/js/layout.js +++ b/src/front/js/layout.js @@ -12,9 +12,9 @@ import injectContext from "./store/appContext"; import { Navbar } from "./component/navbar"; import { Footer } from "./component/footer"; +import { RecuperacionContraseña } from "./component/recuperacionContraseña"; import { VistaMascota } from "./pages/vistaMascota"; import { VistaProducto } from "./pages/VistaProducto"; - import { LoginSignup } from "./pages/loginSignup"; import { RegistroMascota } from "./pages/RegistroMascota"; import { CarritoPago } from "./pages/CarritoPago"; @@ -58,6 +58,7 @@ const PageWithNavbar = () => { } path="/single/:theid" /> } path="/registro-mascota" /> } path="/carrito" /> + } path="/recuperacionContraseña" /> Not found!} path="*" /> diff --git a/src/front/js/pages/loginSignup.js b/src/front/js/pages/loginSignup.js index aabba20dc4..9171ae89cf 100644 --- a/src/front/js/pages/loginSignup.js +++ b/src/front/js/pages/loginSignup.js @@ -6,7 +6,7 @@ import "../../styles/home.css"; export const LoginSignup = () => { const { actions, store } = useContext(Context); const navigate = useNavigate(); - + const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -20,7 +20,7 @@ export const LoginSignup = () => { setLoading(true); setError(''); setSuccessMessage(''); - + try { const dataUser = { name, email, password }; if (isSignup) { @@ -28,7 +28,7 @@ export const LoginSignup = () => { setSuccessMessage('¡Registro exitoso! Redirigiendo a inicio de sesión...'); setTimeout(() => { setIsSignup(false); - }, 3000); + },); } else { await actions.login(email, password, navigate); } @@ -41,7 +41,7 @@ export const LoginSignup = () => { setLoading(false); } }; - + return (
@@ -67,6 +67,14 @@ export const LoginSignup = () => { {error &&

{error}

}

setIsSignup(true)}>¿No tienes cuenta? Regístrate

+ + {/* NUEVO: Enlace para recuperación de contraseña */} +

+ ¿Olvidaste tu contraseña? Recupérala aquí +

+

+ Volver +

)} {isSignup && ( @@ -104,4 +112,4 @@ export const LoginSignup = () => {
); -}; \ No newline at end of file +};