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 da566a74a0..0000000000 Binary files a/public/rigo-baby.jpg and /dev/null differ 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)): 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,