diff --git a/mock-server/.env.example b/mock-server/.env.example index dc30bca..3d1261e 100644 --- a/mock-server/.env.example +++ b/mock-server/.env.example @@ -1,3 +1,4 @@ NODE_ENV=development PORT=3000 SIMULATED_DELAY=2000 +AUTH_SECRET=MY_AUTH_SECRET \ No newline at end of file diff --git a/mock-server/package-lock.json b/mock-server/package-lock.json index 4f7405f..adcadd5 100644 --- a/mock-server/package-lock.json +++ b/mock-server/package-lock.json @@ -9,14 +9,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "cookie-parser": "^1.4.7", "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", "mongodb": "^6.10.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "winston": "^3.16.0" }, "devDependencies": { - "@types/express": "^5.0.0", + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.1", + "@types/jsonwebtoken": "^9.0.9", "@types/prompts": "^2.4.9", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.7", @@ -779,6 +783,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz", + "integrity": "sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -786,15 +800,14 @@ "dev": true }, "node_modules/@types/express": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", - "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.2.tgz", + "integrity": "sha512-BtjL3ZwbCQriyb0DGw+Rt12qAXPiBTPs815lsUvtt1Grk0vLRMZNMUZ741d5rjk+UQOxfDiBZ3dxpX00vSkK3g==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/qs": "*", "@types/serve-static": "*" } }, @@ -823,6 +836,17 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -830,6 +854,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", @@ -1282,6 +1313,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1495,6 +1532,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -1668,6 +1727,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2877,6 +2945,67 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3023,16 +3152,58 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", diff --git a/mock-server/package.json b/mock-server/package.json index afba141..66821ea 100644 --- a/mock-server/package.json +++ b/mock-server/package.json @@ -24,14 +24,18 @@ "author": "", "license": "ISC", "dependencies": { + "cookie-parser": "^1.4.7", "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", "mongodb": "^6.10.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "winston": "^3.16.0" }, "devDependencies": { - "@types/express": "^5.0.0", + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.1", + "@types/jsonwebtoken": "^9.0.9", "@types/prompts": "^2.4.9", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.7", diff --git a/mock-server/src/common/models/credentials.model.ts b/mock-server/src/common/models/credentials.model.ts new file mode 100644 index 0000000..5d34c46 --- /dev/null +++ b/mock-server/src/common/models/credentials.model.ts @@ -0,0 +1,4 @@ +export interface UserCredentials { + email: string; + contraseña: string; +} diff --git a/mock-server/src/common/models/index.ts b/mock-server/src/common/models/index.ts index a3be7da..36358b2 100644 --- a/mock-server/src/common/models/index.ts +++ b/mock-server/src/common/models/index.ts @@ -1,2 +1,4 @@ export * from './collection.model.js'; export * from './lookup.model.js'; +export * from './credentials.model.js'; +export * from './user-session.model.js'; diff --git a/mock-server/src/common/models/user-session.model.ts b/mock-server/src/common/models/user-session.model.ts new file mode 100644 index 0000000..208b2ab --- /dev/null +++ b/mock-server/src/common/models/user-session.model.ts @@ -0,0 +1,6 @@ +import { Lookup } from './lookup.model.js'; + +export interface UserSession { + id: string; + rol: Lookup; +} diff --git a/mock-server/src/core/constants/env.constants.ts b/mock-server/src/core/constants/env.constants.ts index 16e4f90..e16e6c5 100644 --- a/mock-server/src/core/constants/env.constants.ts +++ b/mock-server/src/core/constants/env.constants.ts @@ -2,4 +2,5 @@ export const ENV = { IS_PRODUCTION: process.env.NODE_ENV === 'production', PORT: Number(process.env.PORT), SIMULATED_DELAY: +process.env.SIMULATED_DELAY, + AUTH_SECRET: process.env.AUTH_SECRET, }; diff --git a/mock-server/src/core/servers/rest-api.server.ts b/mock-server/src/core/servers/rest-api.server.ts index abd344e..b7d1fa9 100644 --- a/mock-server/src/core/servers/rest-api.server.ts +++ b/mock-server/src/core/servers/rest-api.server.ts @@ -1,9 +1,11 @@ import { ENV } from '#core/constants/env.constants.js'; +import cookieParser from 'cookie-parser'; import express from 'express'; export const createRestApiServer = () => { const app = express(); app.use(express.json()); + app.use(cookieParser()); app.use((req, res, next) => { setTimeout(next, ENV.SIMULATED_DELAY || 2000); }); diff --git a/mock-server/src/dals/user/index.ts b/mock-server/src/dals/user/index.ts index 96a8a4e..fae8fe2 100644 --- a/mock-server/src/dals/user/index.ts +++ b/mock-server/src/dals/user/index.ts @@ -1 +1,2 @@ export * from './user.model.js'; +export * from './user.repository.js'; diff --git a/mock-server/src/dals/user/user.repository.ts b/mock-server/src/dals/user/user.repository.ts index 71de253..cbf3259 100644 --- a/mock-server/src/dals/user/user.repository.ts +++ b/mock-server/src/dals/user/user.repository.ts @@ -1,5 +1,5 @@ import { paginateItems } from '#common/helpers/index.js'; -import { CollectionQuery } from '#common/models/index.js'; +import { CollectionQuery, UserCredentials } from '#common/models/index.js'; import { db } from '#dals/mock.data.js'; import * as model from './user.model.js'; @@ -27,4 +27,6 @@ export const userRepository = { } return index !== -1; }, + getUserByCredentials: async (userCredentials: UserCredentials) => + db.users.find(user => user.email === userCredentials.email && user.contraseña === userCredentials.contraseña), }; diff --git a/mock-server/src/global-types.d.ts b/mock-server/src/global-types.d.ts new file mode 100644 index 0000000..a1b037b --- /dev/null +++ b/mock-server/src/global-types.d.ts @@ -0,0 +1,13 @@ +declare namespace Express { + export interface UserSession { + id: string; + rol: { + id: string; + nombre: string; + }; + } + + export interface Request { + userSession?: UserSession; + } +} diff --git a/mock-server/src/index.ts b/mock-server/src/index.ts index 6575fe8..1c1f8db 100644 --- a/mock-server/src/index.ts +++ b/mock-server/src/index.ts @@ -5,6 +5,7 @@ import { createRestApiServer } from '#core/servers/index.js'; import { userApi } from '#pods/user/index.js'; import { expedienteApi } from '#pods/expediente/index.js'; import { lookupApi } from '#pods/lookup/index.js'; +import { securityApi } from '#pods/security/security.rest-api.js'; const app = createRestApiServer(); @@ -12,8 +13,9 @@ app.use(logRequestMiddleware(logger)); app.use('/api/user', userApi); app.use('/api/lookup', lookupApi); - app.use('/api/expediente', expedienteApi); +app.use('/api/security', securityApi); + app.use(logErrorRequestMiddleware(logger)); diff --git a/mock-server/src/pods/security/index.ts b/mock-server/src/pods/security/index.ts new file mode 100644 index 0000000..c58b956 --- /dev/null +++ b/mock-server/src/pods/security/index.ts @@ -0,0 +1 @@ +export * from './security.rest-api.js'; diff --git a/mock-server/src/pods/security/security.middlewares.ts b/mock-server/src/pods/security/security.middlewares.ts new file mode 100644 index 0000000..375225b --- /dev/null +++ b/mock-server/src/pods/security/security.middlewares.ts @@ -0,0 +1,32 @@ +import { RequestHandler } from 'express'; +import jwt from 'jsonwebtoken'; +import { UserSession } from '#common/models/index.js'; +import { ENV } from '#core/constants/index.js'; + +const verify = (token: string, secret: string): Promise => + new Promise((resolve, reject) => { + jwt.verify(token, secret, (error, userSession: UserSession) => { + if (error) { + reject(error); + } + + if (userSession) { + resolve(userSession); + } else { + reject(); + } + }); + }); + +export const authenticationMiddleware: RequestHandler = async (req, res, next) => { + try { + const [, token] = req.cookies.authorization?.split(' ') || []; + const userSession = await verify(token, ENV.AUTH_SECRET); + req.userSession = userSession; + next(); + } catch (error) { + console.error(error); + res.clearCookie('authorization'); + res.sendStatus(401); + } +}; diff --git a/mock-server/src/pods/security/security.rest-api.ts b/mock-server/src/pods/security/security.rest-api.ts new file mode 100644 index 0000000..0a30a16 --- /dev/null +++ b/mock-server/src/pods/security/security.rest-api.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { UserCredentials, UserSession } from '#common/models/index.js'; +import { userRepository } from '#dals/user/index.js'; +import { authenticationMiddleware } from './security.middlewares.js'; +import { ENV } from '#core/constants/env.constants.js'; + +export const securityApi = Router(); + +securityApi + .post('/login', async (req, res, next) => { + try { + const body = req.body as UserCredentials; + const user = await userRepository.getUserByCredentials(body); + + if (user) { + const userSession: UserSession = { + id: user._id.toHexString(), + rol: user.rol, + }; + const token = jwt.sign(userSession, ENV.AUTH_SECRET, { + expiresIn: '1d', + algorithm: 'HS256', + }); + res.cookie('authorization', `Bearer ${token}`, { + httpOnly: true, + }); + res.sendStatus(204); + } else { + res.sendStatus(401); + res.clearCookie('authorization'); + } + } catch (error) { + next(error); + } + }) + .get('/whoami', authenticationMiddleware, async (req, res, next) => { + try { + const user = await userRepository.getUserById(req.userSession?.id); + + if (user) { + res + .status(200) + .send({ id: user._id.toHexString(), nombre: user.nombre, apellido: user.apellido, rol: user.rol }); + } else { + res.sendStatus(401); + } + } catch (error) { + next(error); + } + }) + .post('/logout', authenticationMiddleware, async (req, res, next) => { + try { + res.clearCookie('authorization'); + res.sendStatus(204); + } catch (error) { + next(error); + } + }); diff --git a/src/common/components/appbar/appbar.component.tsx b/src/common/components/appbar/appbar.component.tsx index 90fc9c2..3ead9f8 100644 --- a/src/common/components/appbar/appbar.component.tsx +++ b/src/common/components/appbar/appbar.component.tsx @@ -1,32 +1,56 @@ -import React from 'react'; - +import React, { useRef } from 'react'; import { AppBar as MUIAppbar, IconButton, Toolbar, Typography, Avatar } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import MenuOpenIcon from '@mui/icons-material/MenuOpen'; - import * as classes from './appbar.styles'; +import { useAuth } from '../../../core/auth'; +import { AvatarMenu } from '../avatar-menu'; interface AppBarProps { isDrawerOpen: boolean; + isListOpen: boolean; onToggleDrawer: () => void; + onAvatarMenuAction: (action: 'toggle' | 'close') => void; } export const AppBar: React.FC = props => { - const { isDrawerOpen, onToggleDrawer } = props; + const { isDrawerOpen, isListOpen, onToggleDrawer, onAvatarMenuAction } = props; + + const { user, logout } = useAuth(); + + const avatarRef = useRef(null); + + const getInitials = (nombre: string, apellido: string): string => { + const first = nombre?.trim()?.[0] ?? ''; + const last = apellido?.trim()?.[0] ?? ''; + return `${first}${last}`.toUpperCase(); + }; return ( - - -
- - {isDrawerOpen ? : } - - - GEX - -
- PM -
-
+ <> + + +
+ + {isDrawerOpen ? : } + + + GEX + +
+ onAvatarMenuAction('toggle')} sx={{ cursor: 'pointer' }}> + {getInitials(user.nombre, user.apellido)} + +
+
+ + onAvatarMenuAction('close')} + userName={`${user.nombre} ${user.apellido}`} + onLogout={() => logout()} + /> + ); }; diff --git a/src/common/components/avatar-menu/avatar-menu.component.tsx b/src/common/components/avatar-menu/avatar-menu.component.tsx new file mode 100644 index 0000000..9840e06 --- /dev/null +++ b/src/common/components/avatar-menu/avatar-menu.component.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { + Popper, + ClickAwayListener, + Paper, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemButton, +} from '@mui/material'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import LogoutIcon from '@mui/icons-material/Logout'; + +interface Props { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + userName: string; + onLogout: () => void; +} + +export const AvatarMenu: React.FC = props => { + const { anchorEl, open, onClose, userName, onLogout } = props; + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/common/components/avatar-menu/index.ts b/src/common/components/avatar-menu/index.ts new file mode 100644 index 0000000..45cda8b --- /dev/null +++ b/src/common/components/avatar-menu/index.ts @@ -0,0 +1 @@ +export * from './avatar-menu.component'; diff --git a/src/common/components/navigation-button/navigation-button.component.tsx b/src/common/components/navigation-button/navigation-button.component.tsx index c0d71cf..cece7ad 100644 --- a/src/common/components/navigation-button/navigation-button.component.tsx +++ b/src/common/components/navigation-button/navigation-button.component.tsx @@ -8,14 +8,17 @@ interface Props { text: string; params?: Record; variant?: ButtonProps['variant']; + fullWidth?: boolean; } export const NavigationButton: React.FC = props => { - const { path, params, text, variant = 'contained' } = props; + const { path, params, text, variant = 'contained', fullWidth } = props; return ( - + ); }; diff --git a/src/core/auth/api/auth.api-model.ts b/src/core/auth/api/auth.api-model.ts new file mode 100644 index 0000000..f36cdf5 --- /dev/null +++ b/src/core/auth/api/auth.api-model.ts @@ -0,0 +1,13 @@ +import { Lookup } from '#common/models'; + +export interface UserCredentials { + email: string; + contraseña: string; +} + +export interface User { + id: string; + nombre: string; + apellido: string; + rol: Lookup; +} diff --git a/src/core/auth/api/auth.api.ts b/src/core/auth/api/auth.api.ts new file mode 100644 index 0000000..55801c0 --- /dev/null +++ b/src/core/auth/api/auth.api.ts @@ -0,0 +1,13 @@ +// login, logout, whoami(me) +import axios from 'axios'; +import { User, UserCredentials } from './auth.api-model'; + +export const login = async (userCredentials: UserCredentials): Promise => { + const response = await axios.post('/api/security/login', userCredentials); + return response.data; +}; + +export const whoami = async (): Promise => { + const response = await axios.get('/api/security/whoami', { withCredentials: true }); + return response.data; +}; diff --git a/src/core/auth/api/index.ts b/src/core/auth/api/index.ts new file mode 100644 index 0000000..db154de --- /dev/null +++ b/src/core/auth/api/index.ts @@ -0,0 +1,2 @@ +export * from './auth.api'; +export * from './auth.api-model'; diff --git a/src/core/auth/auth.model.ts b/src/core/auth/auth.model.ts new file mode 100644 index 0000000..82c49b0 --- /dev/null +++ b/src/core/auth/auth.model.ts @@ -0,0 +1,18 @@ +import { User, UserCredentials } from './api/auth.api-model'; + +export interface AuthContextModel { + isAuthenticated: boolean; + user: User; + doLogin: (UserCredentials: UserCredentials) => Promise; + logout: () => Promise; +} + +export const createEmptyUser = (): User => ({ + id: '', + nombre: '', + apellido: '', + rol: { + id: '', + nombre: '', + }, +}); diff --git a/src/core/auth/auth.provider.tsx b/src/core/auth/auth.provider.tsx new file mode 100644 index 0000000..236b182 --- /dev/null +++ b/src/core/auth/auth.provider.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AuthContextModel } from './auth.model'; +import { useLoginMutation, useWhoamiQuery } from './auth.query.hook'; +import { authQueryKeys, queryClient } from '../react-query'; + +const AuthContext = React.createContext(null); + +interface Props { + children: React.ReactNode; +} + +export const AuthProvider: React.FC = props => { + const { children } = props; + + const { doLogin } = useLoginMutation(); + const { user, isAuthenticated, isLoading } = useWhoamiQuery(); + const logout = async () => { + await fetch('/api/security/logout', { method: 'POST', credentials: 'include' }); + await queryClient.invalidateQueries({ queryKey: authQueryKeys.whoami() }); + }; + + const isReady = !isLoading; + + if (!isReady) { + return null; + } + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextModel => { + const context = React.useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/core/auth/auth.query.hook.ts b/src/core/auth/auth.query.hook.ts new file mode 100644 index 0000000..33da914 --- /dev/null +++ b/src/core/auth/auth.query.hook.ts @@ -0,0 +1,52 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { UserCredentials, login, User, whoami } from './api'; +import { createEmptyUser } from './auth.model'; +import { authQueryKeys, queryClient } from '../react-query'; + +interface LoginMutationResult { + doLogin: (userCredentials: UserCredentials) => Promise; + isPending: boolean; +} + +export const useLoginMutation = (): LoginMutationResult => { + const { mutateAsync: doLogin, isPending } = useMutation({ + mutationFn: (userCredentials: UserCredentials) => login(userCredentials), + onSuccess: () => { + queryClient.removeQueries({ queryKey: authQueryKeys.whoami() }); + }, + }); + + return { + doLogin, + isPending, + }; +}; + +interface UseWhoamIQueryResult { + user: User; + isAuthenticated: boolean; + isLoading: boolean; +} + +export const useWhoamiQuery = (): UseWhoamIQueryResult => { + const { + data: user = createEmptyUser(), + isLoading, + isError, + } = useQuery({ + queryKey: authQueryKeys.whoami(), + queryFn: async () => { + const user = await whoami(); + return user; + }, + retry: false, + }); + + const isAuthenticated = !isError && Boolean(user?.id); + + return { + user, + isAuthenticated, + isLoading, + }; +}; diff --git a/src/core/auth/index.ts b/src/core/auth/index.ts new file mode 100644 index 0000000..917e2e7 --- /dev/null +++ b/src/core/auth/index.ts @@ -0,0 +1,3 @@ +export * from './auth.provider'; +export * from './auth.model'; +export * from './api'; diff --git a/src/core/react-query/query-keys.ts b/src/core/react-query/query-keys.ts index 24147ec..bda993a 100644 --- a/src/core/react-query/query-keys.ts +++ b/src/core/react-query/query-keys.ts @@ -18,3 +18,10 @@ export const certificacionesQueryKeys = { all: ['certificaciones'], certificacionCollection: (page?: number, pageSize?: number) => ['certificaciones', page, pageSize], }; + +export const authQueryKeys = { + all: ['auth'], + doLogin: () => ['auth', 'doLogin'], + // todo: add doLogout + whoami: () => ['auth', 'whoami'], +}; diff --git a/src/layouts/app.layout.tsx b/src/layouts/app.layout.tsx index 73cf5f3..3836dc1 100644 --- a/src/layouts/app.layout.tsx +++ b/src/layouts/app.layout.tsx @@ -1,7 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { AppBar, Drawer, SidebarMenu } from '#common/components'; import { useWithTheme } from '#core/theme'; import * as appLayoutClasses from './app.styles'; +import { useToggle } from '#common/hooks/toogle.hook.ts'; +import { useAuth } from '#core/auth'; +import { useNavigate } from '@tanstack/react-router'; interface Props { children: React.ReactNode; @@ -10,13 +13,32 @@ interface Props { export const AppLayout: React.FC = props => { const { children } = props; const classes = useWithTheme(appLayoutClasses); - const [isDrawerOpen, toggleDrawer] = React.useState(false); - const handleToggleDrawer = () => toggleDrawer(!isDrawerOpen); + const { isOpen: isDrawerOpen, onToggle: toggleDrawer } = useToggle(false); + const { isOpen: isListOpen, onToggle: toggleList, setIsOpen: setListOpen } = useToggle(false); + + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const handleAvatarMenu = (action: 'toggle' | 'close') => { + if (action === 'toggle') toggleList(); + if (action === 'close') setListOpen(false); + }; + + useEffect(() => { + if (!isAuthenticated) { + navigate({ to: '/login' }); + } + }, [isAuthenticated, navigate]); return (
- +
diff --git a/src/main.tsx b/src/main.tsx index f824cb0..bd1c154 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,21 +2,25 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { AuthProvider, useAuth } from '#core/auth'; import { RouterProvider } from '@tanstack/react-router'; import { queryClient } from './core/react-query'; import { router } from './core/router'; import { ThemeProvider } from './core/theme'; const App = () => { - return ; + const auth = useAuth(); + return ; }; createRoot(document.getElementById('root')!).render( - - + + + + diff --git a/src/modules/login/index.ts b/src/modules/login/index.ts index ff54d5d..75669d9 100644 --- a/src/modules/login/index.ts +++ b/src/modules/login/index.ts @@ -1 +1,6 @@ +export * from './login.component'; export * from './login.pod'; +export * from './login.styles'; +export * from './login.vm'; +export * from './validations/login.literals'; +export * from './validations/login.validations'; diff --git a/src/modules/login/login.component.tsx b/src/modules/login/login.component.tsx new file mode 100644 index 0000000..39b64eb --- /dev/null +++ b/src/modules/login/login.component.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Button, IconButton, Paper, TableContainer, Typography } from '@mui/material'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import { Form, Formik } from 'formik'; +import { NavigationButton, TextFieldForm } from '#common/components'; +import { useToggle } from '#common/hooks'; +import { UserCredentials } from '#core/auth'; +import { createEmptyCredenciales } from './login.vm'; +import { formValidation } from './validations/login.validations.ts'; +import * as classes from './login.styles'; + +interface Props { + onSubmit: (userCredentials: UserCredentials) => void; +} + +export const Login: React.FC = props => { + const { onSubmit } = props; + const { isOpen: showPassword, onToggle } = useToggle(false); + + return ( +
+ + Inicia sesión en tu cuenta +
+ + {() => ( +
+ + {showPassword ? : } + ), + }, + }} + /> + + + + )} +
+
+ + +
+
+ ); +}; diff --git a/src/modules/login/login.pod.tsx b/src/modules/login/login.pod.tsx index d5321f5..8ecb0b9 100644 --- a/src/modules/login/login.pod.tsx +++ b/src/modules/login/login.pod.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { Link } from '@tanstack/react-router'; -import { Typography } from '@mui/material'; -import * as classes from './login.styles'; +import { useAuth } from '#core/auth'; +import { Login } from './login.component'; +import { UserCredentials } from '#core/auth'; export const LoginPod: React.FC = () => { - return ( -
- Soy la página de login - Navegar a listado de expedientes -
- ); + const { doLogin } = useAuth(); + + const handleSubmit = (userCredentials: UserCredentials) => doLogin(userCredentials); + + return ; }; diff --git a/src/modules/login/login.styles.ts b/src/modules/login/login.styles.ts index 6bc32c9..0a9ea78 100644 --- a/src/modules/login/login.styles.ts +++ b/src/modules/login/login.styles.ts @@ -1,8 +1,22 @@ +import { theme } from '#core/theme/theme'; import { css } from '@emotion/css'; export const root = css` display: flex; flex-direction: column; - gap: 30px; align-items: center; + justify-content: center; + gap: ${theme.spacing(4)}; + padding: ${theme.spacing(6)}; + & > * { + width: 372px; + } +`; + +export const loginContainer = css` + display: flex; + flex-direction: column; + justify-content: center; + gap: 16px; + width: 372px; `; diff --git a/src/modules/login/login.vm.ts b/src/modules/login/login.vm.ts new file mode 100644 index 0000000..1f83d2e --- /dev/null +++ b/src/modules/login/login.vm.ts @@ -0,0 +1,6 @@ +import { UserCredentials } from '#core/auth'; + +export const createEmptyCredenciales = (): UserCredentials => ({ + email: '', + contraseña: '', +}); diff --git a/src/modules/login/validations/login.literals.ts b/src/modules/login/validations/login.literals.ts new file mode 100644 index 0000000..2ab3b38 --- /dev/null +++ b/src/modules/login/validations/login.literals.ts @@ -0,0 +1,10 @@ +const requiredMessage = 'Este campo es obligatorio.'; + +export const validationMessages = { + email: { + required: requiredMessage, + notValid: 'Por favor, introduce un email válido.', + notAvailable: 'Email no disponible en el sistema, introduce otro email.', + }, + contraseña: { required: requiredMessage }, +}; diff --git a/src/modules/login/validations/login.validations.ts b/src/modules/login/validations/login.validations.ts new file mode 100644 index 0000000..4a2929c --- /dev/null +++ b/src/modules/login/validations/login.validations.ts @@ -0,0 +1,26 @@ +import { ValidationSchema, Validators } from '@lemoncode/fonk'; +import { createFormikValidation } from '@lemoncode/fonk-formik'; +import { validationMessages } from './login.literals'; + +const validationSchema: ValidationSchema = { + field: { + email: [ + { + validator: Validators.required, + message: validationMessages.email.required, + }, + { + validator: Validators.email, + message: validationMessages.email.notValid, + }, + ], + contraseña: [ + { + validator: Validators.required, + message: validationMessages.contraseña.required, + }, + ], + }, +}; + +export const formValidation = createFormikValidation(validationSchema); diff --git a/src/scenes/__root.tsx b/src/scenes/__root.tsx index b3267fb..80032ec 100644 --- a/src/scenes/__root.tsx +++ b/src/scenes/__root.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; -import { Outlet, createRootRoute } from '@tanstack/react-router'; +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '#core/router/router.dev-tools'; +import { AuthContextModel } from '#core/auth'; -export const Route = createRootRoute({ +interface Context { + auth: AuthContextModel; +} + +export const Route = createRootRouteWithContext()({ component: () => { return ( <> diff --git a/src/scenes/_auth.tsx b/src/scenes/_auth.tsx index 92c8de7..caab146 100644 --- a/src/scenes/_auth.tsx +++ b/src/scenes/_auth.tsx @@ -1,7 +1,14 @@ -import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { AppLayout } from '#layouts/app.layout'; export const Route = createFileRoute('/_auth')({ + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ + to: '/login', + }); + } + }, component: () => { return ( diff --git a/src/scenes/index.tsx b/src/scenes/index.tsx index 1c159ab..f1ab086 100644 --- a/src/scenes/index.tsx +++ b/src/scenes/index.tsx @@ -1,7 +1,9 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; export const Route = createFileRoute('/')({ - beforeLoad: () => { - throw redirect({ to: '/login' }); + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ to: '/login' }); + } }, }); diff --git a/src/scenes/login.tsx b/src/scenes/login.tsx index 8b1b2aa..65a1186 100644 --- a/src/scenes/login.tsx +++ b/src/scenes/login.tsx @@ -1,8 +1,15 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect } from '@tanstack/react-router'; import { AuthLayout } from '#layouts/'; import { LoginPod } from '#modules/login'; export const Route = createFileRoute('/login')({ + beforeLoad: ({ context }) => { + if (context.auth.isAuthenticated) { + throw redirect({ + to: '/expedientes', + }); + } + }, component: () => { return (