From 580e04333925ac4a0df7f505590746e7179f06c4 Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Fri, 25 Jul 2025 17:22:06 +0000 Subject: [PATCH 1/2] feat(admin login): added admin model and login functionality with hashed password --- Pipfile | 1 + Pipfile.lock | 88 +++++++++++++++++++++++---- migrations/versions/4517993f5dfe_.py | 41 +++++++++++++ migrations/versions/876cb350dd08_.py | 42 +++++++++++++ public/index.html | 36 ++++++++++- public/rigo-baby.jpg | Bin 31327 -> 0 bytes src/api/admin.py | 29 +++++++-- src/api/models.py | 37 +++++++++++ src/api/routes.py | 47 +++++++++++++- src/app.py | 9 +++ 10 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 migrations/versions/4517993f5dfe_.py create mode 100644 migrations/versions/876cb350dd08_.py delete mode 100644 public/rigo-baby.jpg diff --git a/Pipfile b/Pipfile index 44e04f14ff..a9f8a4983a 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-bcrypt = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index b201c3decc..aa26182cc9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d2e672e650278aeeee2fe49bd76d76497d8b65a50f8b5dbb121d265cbc6ef4e5" + "sha256": "660e3c19b0c84670819deeffe92b04973852c5f06f34d9be89a2fb2b1bb8534a" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,63 @@ "markers": "python_version >= '3.8'", "version": "==1.14.1" }, + "bcrypt": { + "hashes": [ + "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", + "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", + "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", + "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", + "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", + "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d", + "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", + "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", + "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", + "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", + "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", + "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", + "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", + "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", + "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", + "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", + "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", + "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", + "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", + "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8", + "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", + "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", + "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", + "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", + "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", + "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", + "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", + "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", + "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", + "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", + "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", + "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", + "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", + "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", + "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", + "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a", + "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", + "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", + "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90", + "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492", + "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce", + "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", + "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", + "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1", + "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", + "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", + "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", + "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", + "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", + "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", + "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d" + ], + "markers": "python_version >= '3.8'", + "version": "==4.3.0" + }, "blinker": { "hashes": [ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", @@ -42,11 +99,11 @@ }, "click": { "hashes": [ - "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", - "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" + "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", + "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" ], - "markers": "python_version >= '3.7'", - "version": "==8.1.8" + "markers": "python_version >= '3.10'", + "version": "==8.2.1" }, "cloudinary": { "hashes": [ @@ -58,11 +115,12 @@ }, "flask": { "hashes": [ - "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", - "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136" + "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", + "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e" ], "index": "pypi", - "version": "==3.1.0" + "markers": "python_version >= '3.9'", + "version": "==3.1.1" }, "flask-admin": { "hashes": [ @@ -72,6 +130,14 @@ "index": "pypi", "version": "==1.6.1" }, + "flask-bcrypt": { + "hashes": [ + "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", + "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369" + ], + "index": "pypi", + "version": "==1.0.1" + }, "flask-cors": { "hashes": [ "sha256:6ccb38d16d6b72bbc156c1c3f192bc435bfcc3c2bc864b2df1eb9b2d97b2403c", @@ -209,11 +275,11 @@ }, "jinja2": { "hashes": [ - "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", - "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb" + "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", + "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" ], "markers": "python_version >= '3.7'", - "version": "==3.1.5" + "version": "==3.1.6" }, "mako": { "hashes": [ diff --git a/migrations/versions/4517993f5dfe_.py b/migrations/versions/4517993f5dfe_.py new file mode 100644 index 0000000000..f4404db8b7 --- /dev/null +++ b/migrations/versions/4517993f5dfe_.py @@ -0,0 +1,41 @@ +"""empty message + +Revision ID: 4517993f5dfe +Revises: 727b004dfad3 +Create Date: 2025-07-25 15:48:44.313778 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4517993f5dfe' +down_revision = '727b004dfad3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admin', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('token_blocked_list', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('jti', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('jti') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('token_blocked_list') + op.drop_table('admin') + # ### end Alembic commands ### diff --git a/migrations/versions/876cb350dd08_.py b/migrations/versions/876cb350dd08_.py new file mode 100644 index 0000000000..a37ba2ae01 --- /dev/null +++ b/migrations/versions/876cb350dd08_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 876cb350dd08 +Revises: 4517993f5dfe +Create Date: 2025-07-25 16:00:47.587108 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '876cb350dd08' +down_revision = '4517993f5dfe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ct_admin', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.drop_table('admin') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admin', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False), + sa.Column('password', sa.VARCHAR(length=128), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='admin_pkey'), + sa.UniqueConstraint('email', name='admin_email_key') + ) + op.drop_table('ct_admin') + # ### end Alembic commands ### diff --git a/public/index.html b/public/index.html index 9462644fe9..97fd77c5c5 100644 --- a/public/index.html +++ b/public/index.html @@ -1 +1,35 @@ -Hello Rigo with Vanilla.js
\ No newline at end of file + + + + + + CloudTech + + + + + + +
+ + + + diff --git a/public/rigo-baby.jpg b/public/rigo-baby.jpg deleted file mode 100644 index da566a74a07f685e99494fd9890b45cdb02bbb2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31327 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 Eg0VoU&;S4c diff --git a/src/api/admin.py b/src/api/admin.py index d312ff7990..c05ef5be2f 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -1,17 +1,36 @@ - + import os from flask_admin import Admin -from .models import db, Lead +from .models import db, Lead, CTAdmin, TokenBlockedList from flask_admin.contrib.sqla import ModelView +from wtforms import PasswordField + + +class CTAdminModelView(ModelView): + column_list = ('id', 'email') + form_columns = ('email', 'password_field') + column_exclude_list = ['_password'] + form_extra_fields = { + 'password_field': PasswordField('Password') + } + + def on_model_change(self, form, model, is_created): + if form.password_field.data: + model.password = form.password_field.data + elif is_created and not form.password_field.data: + raise ValueError( + 'El password es obligatorio para nuevos administradores') + def setup_admin(app): app.secret_key = os.environ.get('FLASK_APP_KEY', 'sample key') app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' - admin = Admin(app, name='4Geeks Admin', template_mode='bootstrap3') + admin = Admin(app, name='CloudTech Admin', template_mode='bootstrap3') - # Add your models here, for example this is how we add a the User model to the admin admin.add_view(ModelView(Lead, db.session)) + admin.add_view(CTAdminModelView(CTAdmin, db.session)) + admin.add_view(ModelView(TokenBlockedList, db.session)) # You can duplicate that line to add mew models - # admin.add_view(ModelView(YourModelName, db.session)) \ No newline at end of file + # admin.add_view(ModelView(YourModelName, db.session)) diff --git a/src/api/models.py b/src/api/models.py index 68dcb82cfd..16c780cc5f 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -5,6 +5,35 @@ db = SQLAlchemy() +class CTAdmin(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False) + _password: Mapped[str] = mapped_column( + "password", String(128), nullable=False) + + @property + def password(self): + raise AttributeError('Password is not a readable attribute.') + + @password.setter + def password(self, password): + from app import bcrypt + self._password = bcrypt.generate_password_hash( + password).decode('utf-8') + + # Método para verificar el password + def check_password(self, password): + from app import bcrypt + return bcrypt.check_password_hash(self._password, password) + + def serialize(self): + return { + "id": self.id, + "email": self.email + } + + class Lead(db.Model): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column( @@ -25,3 +54,11 @@ def serialize(self): "company": self.company, "message": self.message } + + +class TokenBlockedList(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + jti: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + + def __repr__(self): + return f'' diff --git a/src/api/routes.py b/src/api/routes.py index da7a2296b4..ba2d39559f 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -2,10 +2,12 @@ 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 api.models import db, Lead +from api.models import db, Lead, CTAdmin from api.utils import generate_sitemap, APIException +from sqlalchemy import select from flask_cors import CORS from sqlalchemy.exc import IntegrityError +from flask_jwt_extended import create_access_token api = Blueprint('api', __name__) @@ -52,6 +54,49 @@ def validate_lead_data(data): } +@api.route('/admin/login', methods=['POST']) +def admin_login(): + admin_data = request.get_json() + + if not admin_data: + return jsonify({"message": "Invalid Json or empty request body"}), 400 + + email = admin_data.get("email") + password = admin_data.get("password") + + if not email: + return jsonify({"message": "No email entered"}), 400 + if not password: + return jsonify({"message": "Password is required"}), 400 + + ct_admin = None + + try: + ct_admin = db.session.execute(select(CTAdmin).where( + CTAdmin.email == email)).scalar_one_or_none() + + if ct_admin is None: + return jsonify({"message": "Invalid credentials"}), 401 + + if not ct_admin.check_password(password): + return jsonify({"message": "Invalid credentials"}), 401 + + token = create_access_token( + identity=ct_admin.id, + additional_claims={"role": "ct_admin"} + ) + + return jsonify({ + "token": token, + "user_id": ct_admin.id, + "message": "Login successful" + }), 200 + + except Exception as e: + print(f"Login error: {e}") + return jsonify({"message": "Failed login. Please try again later"}), 500 + + @api.route('/contact', methods=['POST']) def add_lead(): lead_data = request.get_json() diff --git a/src/app.py b/src/app.py index ca25ac026e..f09503b59a 100644 --- a/src/app.py +++ b/src/app.py @@ -5,6 +5,8 @@ from flask import Flask, request, jsonify, url_for, send_from_directory from flask_migrate import Migrate from flask_swagger import swagger +from flask_bcrypt import Bcrypt +from flask_jwt_extended import JWTManager from api.utils import APIException, generate_sitemap from api.models import db from api.routes import api @@ -17,6 +19,9 @@ static_file_dir = os.path.join(os.path.dirname( os.path.realpath(__file__)), '../dist/') app = Flask(__name__) +bcrypt = Bcrypt(app) +jwt = JWTManager(app) +app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", "super-secret-jwt-key") app.url_map.strict_slashes = False # database condiguration @@ -42,11 +47,13 @@ # Handle/serialize errors like a JSON object + @app.before_request def handle_options_request(): if request.method == 'OPTIONS': return '', 204 + @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code @@ -61,6 +68,8 @@ def sitemap(): return send_from_directory(static_file_dir, 'index.html') # any other endpoint will try to serve it like a static file + + @app.route('/', methods=['GET']) def serve_any_other_file(path): if not os.path.isfile(os.path.join(static_file_dir, path)): From c4d25487187ba3ba763fb0f2bafff4c18dbda690 Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Fri, 25 Jul 2025 21:12:39 +0000 Subject: [PATCH 2/2] feat(login): login functionaltiy for admin user --- src/front/index.css | 16 ++++- src/front/main.jsx | 16 ++++- src/front/pages/Admin.jsx | 18 ++++- src/front/pages/Login.jsx | 134 ++++++++++++++++++++++++++++++++++++++ src/front/routes.jsx | 4 +- src/front/store.js | 27 ++++++++ 6 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/front/pages/Login.jsx diff --git a/src/front/index.css b/src/front/index.css index d8498c7c60..8b8dd4f5b4 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -296,7 +296,7 @@ h6 { background-color: rgba(10, 25, 30, 0.5); } -.hero-title-home{ +.hero-title-home { color: #fbff06 !important; } @@ -345,7 +345,7 @@ h6 { color: transparent; } /* header contacto */ -.header{ +.header { margin-bottom: 4rem !important; } .form-label-contact { @@ -381,3 +381,15 @@ h6 { .adminOption:hover { color: #fbff06; } + +/* Login styles */ + +@media (max-width: 780px) { + .login-logo { + min-width: 280px; + } +} + +.login-logo { + min-width: 200px; +} diff --git a/src/front/main.jsx b/src/front/main.jsx index 6f413bf07c..15c01ecd4d 100644 --- a/src/front/main.jsx +++ b/src/front/main.jsx @@ -17,7 +17,21 @@ const Main = () => { ); return ( - Loading language...}> + +
+
+ +
+
+ +
+
+ +
+
+ }> {/* Provide global state to all components */} {/* Set up routing for the application */} diff --git a/src/front/pages/Admin.jsx b/src/front/pages/Admin.jsx index dbd9543d3f..959c7b0b0d 100644 --- a/src/front/pages/Admin.jsx +++ b/src/front/pages/Admin.jsx @@ -1,9 +1,10 @@ import { useState, useEffect, useContext } from "react" -import { Link } from "react-router-dom" +import { Link, useNavigate } from "react-router-dom" import { Leads } from "../components/Admin/Leads" import { AppContext } from "./Layout" export const Admin = () => { + const navigate = useNavigate(); const { setShowNavbar, setShowFooter } = useContext(AppContext) const [activeContent, setActiveContent] = useState(""); @@ -24,6 +25,11 @@ export const Admin = () => { setActiveContent(contentName); } + const handleLogout = () => { + localStorage.removeItem('accessToken') + navigate('/') + } + const renderContent = () => { switch (activeContent) { case 'leads': @@ -36,7 +42,7 @@ export const Admin = () => { return (
-
+

Panel de Administrador

¡Bienvenido!

    @@ -53,6 +59,14 @@ export const Admin = () => {
+
+ + Cerrar sesión + +
{renderContent()} diff --git a/src/front/pages/Login.jsx b/src/front/pages/Login.jsx new file mode 100644 index 0000000000..eb919ca2f2 --- /dev/null +++ b/src/front/pages/Login.jsx @@ -0,0 +1,134 @@ +import { useState, useEffect, useReducer, useContext } from "react" +import storeReducer, { initialStore } from "../store"; +import { useNavigate } from "react-router-dom" +import { AppContext } from "./Layout" +import yellowLogo from '../assets/img/LogoNavbar.svg' + +const adminLogin = async (dispatch, loginData) => { + const apiUrl = import.meta.env.VITE_BACKEND_URL; + dispatch({ type: 'ADMIN_LOGIN_START' }) + try { + const response = await fetch(`${apiUrl}/api/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(loginData) + }) + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Error al loguearse"); + } + dispatch({ type: 'ADMIN_LOGIN_SUCCESS', payload: data }); + return data; + } catch (error) { + console.error("Error al loguearse:", error); + dispatch({ type: 'ADMIN_LOGIN_FAILURE', payload: error.message }); + throw error; + } +} + +export const Login = () => { + const navigate = useNavigate(); + const { setShowNavbar, setShowFooter } = useContext(AppContext) + + const [store, dispatch] = useReducer(storeReducer, initialStore()); + const { loginStatus } = store; + + useEffect(() => { + if (setShowNavbar || setShowFooter) { + setShowNavbar(false); + setShowFooter(false); + } + return () => { + if (setShowNavbar || setShowFooter) { + setShowNavbar(true); + setShowFooter(true); + } + } + }, [setShowNavbar, setShowFooter]) + + const [loginData, setLoginData] = useState({ + email: "", + password: "" + }) + + const handleChange = (e) => { + const { name, value } = e.target; + setLoginData(prevLoginData => ({ ...prevLoginData, [name]: value })); + } + + const handleLogin = async (e) => { + e.preventDefault(); + try { + const response = await adminLogin(dispatch, loginData); + console.log("Successful login", response) + alert("Successful login!") + if (response.token) { + localStorage.setItem('accessToken', response.token); + } + setLoginData({ + email: "", + password: "" + }) + navigate("/admin") + } catch (error) { + console.error("Error de logueo", error) + alert("Credenciales incorrectas") + } + } + + return ( +
+
+ +
+ CloudTech Logo +
+ +
+ +

Welcome back!

+
+
+ + +
We'll never share your email with anyone else.
+
+
+ + +
+
+ +
+ {loginStatus?.status === 'error' && ( +
+ {loginStatus.error || "Error inesperado."} +
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 7625f041d0..8b7b25f62b 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -11,6 +11,7 @@ import { About } from "./pages/About"; import { ServicesPage } from "./pages/Services"; import { Portfolio } from "./pages/Projects"; import { Contact } from "./pages/Contact"; +import { Login } from "./pages/Login"; import { Admin } from "./pages/Admin"; import { ProtectedRoute } from "./components/ProtectedRoute"; @@ -31,8 +32,9 @@ export const router = createBrowserRouter( } /> } /> } /> + } /> {/* } /> */} - {/* } /> */} + } /> ) ); \ No newline at end of file diff --git a/src/front/store.js b/src/front/store.js index edaad5a0e3..6005e5a75f 100644 --- a/src/front/store.js +++ b/src/front/store.js @@ -17,6 +17,33 @@ const initialContactFormState = { export default function storeReducer(store, action = {}) { switch (action.type) { + case "ADMIN_LOGIN_START": + return { + ...store, + loginStatus: { + status: "loading", + error: null, + }, + }; + + case "ADMIN_LOGIN_SUCCESS": + return { + ...store, + loginStatus: { + status: "success", + error: null, + }, + }; + + case "ADMIN_LOGIN_FAILURE": + return { + ...store, + loginStatus: { + status: "error", + error: action.payload, + }, + }; + case "GET_ALL_LEADS_START": return { ...store,