diff --git a/capstone/backend/db/migrations/20230901033649_create_table_users.sql b/capstone/backend/db/migrations/20230901033649_create_table_users.sql new file mode 100644 index 0000000..98fba49 --- /dev/null +++ b/capstone/backend/db/migrations/20230901033649_create_table_users.sql @@ -0,0 +1,13 @@ +-- migrate:up +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE, + hash_password VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); + +CREATE INDEX idx_email ON users (email); + +-- migrate:down +DROP TABLE users; diff --git a/capstone/backend/environment.js b/capstone/backend/environment.js new file mode 100644 index 0000000..6497c56 --- /dev/null +++ b/capstone/backend/environment.js @@ -0,0 +1,16 @@ +// codes in here are inspired from: https://dev.to/remix-run-br/type-safe-environment-variables-on-both-client-and-server-with-remix-54l5 +// It's a type-safe way of accessing environment variables. +// App will not run if process.env doesn't meet the zod schema + +import { z } from "zod"; + +const environmentSchema = z.object({ + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + JWT_SECRET: z.string().min(1), +}); + +const environment = environmentSchema.parse(process.env); + +export { environment }; diff --git a/capstone/backend/middlewares/autenticated.js b/capstone/backend/middlewares/autenticated.js new file mode 100644 index 0000000..488743b --- /dev/null +++ b/capstone/backend/middlewares/autenticated.js @@ -0,0 +1,11 @@ +import tokenValidator from "../services/tokenValidator.js"; + +export default function authenticated(req, res, next) { + const { isValid, ...props } = tokenValidator(req.cookies.token); + + if (isValid) { + next(); + } else { + return res.status(401).send({ error: props.error }); + } +} diff --git a/capstone/backend/package-lock.json b/capstone/backend/package-lock.json index 78b8818..65a0f9f 100644 --- a/capstone/backend/package-lock.json +++ b/capstone/backend/package-lock.json @@ -9,11 +9,14 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^2.4.3", "camelcase-keys": "^9.0.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dbmate": "^2.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "postgres": "^3.3.5", "zod": "^3.22.2" @@ -136,6 +139,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -159,6 +167,11 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -234,6 +247,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "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", @@ -304,6 +337,14 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "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==", + "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", @@ -495,6 +536,97 @@ "node": ">= 0.10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "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==" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "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==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/map-obj": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", @@ -736,6 +868,20 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -859,6 +1005,11 @@ "node": ">= 0.8" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/zod": { "version": "3.22.2", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.2.tgz", diff --git a/capstone/backend/package.json b/capstone/backend/package.json index a6f5ab0..d1647f7 100644 --- a/capstone/backend/package.json +++ b/capstone/backend/package.json @@ -13,11 +13,14 @@ "author": "", "license": "ISC", "dependencies": { + "bcryptjs": "^2.4.3", "camelcase-keys": "^9.0.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dbmate": "^2.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "postgres": "^3.3.5", "zod": "^3.22.2" diff --git a/capstone/backend/requests/auth.http b/capstone/backend/requests/auth.http new file mode 100644 index 0000000..6b36c88 --- /dev/null +++ b/capstone/backend/requests/auth.http @@ -0,0 +1,25 @@ +POST http://localhost:8081/api/auth/sign-up +Content-Type: application/json + +{ + "email": "johndoe3@gmail.com", + "password": "abcdefghh123" +} + +### + +POST http://localhost:8081/api/auth/sign-in +Content-Type: application/json + +{ + "email": "johndoe3@gmail.com", + "password": "abcdefghh123" +} + +### + +POST http://localhost:8081/api/auth/sign-out + +### + +GET http://localhost:8081/api/auth/verify \ No newline at end of file diff --git a/capstone/backend/routes/auth.js b/capstone/backend/routes/auth.js new file mode 100644 index 0000000..d2cec81 --- /dev/null +++ b/capstone/backend/routes/auth.js @@ -0,0 +1,123 @@ +// https://itsgosho2.medium.com/how-to-transfer-http-only-cookies-with-express-back-end-and-the-fetch-api-2035f0ac48d9 +// https://keeplearning.dev/nodejs-jwt-authentication-with-http-only-cookie-5d8a966ac059 +import express, { response } from "express"; +import { z } from "zod"; +import camelcaseKeys from "camelcase-keys"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { environment } from "../environment.js"; +import { sql } from "../db.js"; +import tokenValidator from "../services/tokenValidator.js"; + +const { JWT_SECRET } = environment; + +const userSchema = z.object({ + email: z.string().min(1).email(), + password: z + .string() + .min(8, { message: "Should be at least 8 characters long" }), +}); + +const authRouter = express.Router(); + +authRouter.post("/sign-up", async (req, res) => { + const result = await userSchema.safeParseAsync(req.body); + + if (!result.success) { + return res.status(400).json( + result.error.errors.map((err) => ({ + field: err.path.join("."), + message: err.message, + })) + ); + } + + const { data } = result; + const hashPassword = await bcrypt.hash(data.password, 10); + + try { + const [createdUser] = + await sql`INSERT INTO users (email, hash_password) VALUES (${data.email}, ${hashPassword}) RETURNING id, email, created_at, updated_at`; + + const token = generateToken(createdUser); + + return res + .status(201) + .cookie("token", token, { httpOnly: true }) + .send(camelcaseKeys(createdUser)); + } catch (e) { + // JavaScript will actually create an Error object with two properties: name and message. + if ( + e.message === + 'duplicate key value violates unique constraint "users_email_key"' + ) { + return res.status(409).json({ + error: "email is not available or is already in use", + }); + } + + throw e; + } +}); + +authRouter.post("/sign-in", async (req, res) => { + const result = await userSchema.safeParseAsync(req.body); + + if (!result.success) { + return res.status(400).json( + result.error.errors.map((err) => ({ + field: err.path.join("."), + message: err.message, + })) + ); + } + const { email, password } = result.data; + + let [foundUser] = await sql`SELECT * FROM users WHERE email = ${email}`; + + if (!foundUser) { + return res.status(404).json({ error: "Email doesn't exists" }); + } + foundUser = camelcaseKeys(foundUser); + + const isPasswordMatch = await bcrypt.compare( + password, + foundUser.hashPassword + ); + + if (!isPasswordMatch) { + return res.status(401).json({ error: "Credentials Doesn't Match" }); + } + + const token = generateToken(foundUser); + + return res.status(200).cookie("token", token, { httpOnly: true }).json({ + id: foundUser.id, + email: foundUser.email, + createdAt: foundUser.createdAt, + updatedAt: foundUser.updatedAt, + }); +}); + +authRouter.get("/verify", (req, res) => { + const { isValid, ...props } = tokenValidator(req.cookies.token); + + if (isValid) { + return res.json(props); + } else { + return res.status(401).json({ error: props.error }); + } +}); + +authRouter.post("/sign-out", (req, res) => { + return res.status(204).clearCookie("token", { httpOnly: true }).send(); +}); + +function generateToken(user, expiresIn = "7d") { + const payload = { + email: user.email, + }; + return jwt.sign(payload, JWT_SECRET, { expiresIn }); +} + +export default authRouter; diff --git a/capstone/backend/server.js b/capstone/backend/server.js index 04938b4..e38e433 100644 --- a/capstone/backend/server.js +++ b/capstone/backend/server.js @@ -1,7 +1,10 @@ import express from "express"; import morgan from "morgan"; import cors from "cors"; +import cookieParser from "cookie-parser"; import todosRouter from "./routes/todo.js"; +import authRouter from "./routes/auth.js"; +import authenticated from "./middlewares/autenticated.js"; const app = express(); @@ -15,13 +18,18 @@ const server = app.listen(8081, function () { app.use( cors({ origin: ["http://localhost:5173"], + credentials: true, }) ); app.use(morgan("dev")); app.use(express.json()); - -app.use("/api/todos", todosRouter); +app.use(cookieParser()); app.get("/", (req, res) => { res.send("Hello world"); }); + +app.use("/api/auth", authRouter); + +app.use(authenticated); +app.use("/api/todos", todosRouter); diff --git a/capstone/backend/services/tokenValidator.js b/capstone/backend/services/tokenValidator.js new file mode 100644 index 0000000..9b888c5 --- /dev/null +++ b/capstone/backend/services/tokenValidator.js @@ -0,0 +1,24 @@ +import jwt from "jsonwebtoken"; +import { environment } from "../environment.js"; + +const { JWT_SECRET } = environment; + +export default function tokenValidator(token) { + try { + return { + isValid: true, + ...jwt.verify(token, JWT_SECRET), + }; + } catch (e) { + // JWT Errors: https://github.com/auth0/node-jsonwebtoken#errors--codes + if (e.name === "TokenExpiredError") { + return { isValid: false, error: "Token has already expired" }; + } + + if (e.name === "JsonWebTokenError") { + return { isValid: false, error: "Token is required" }; + } + + throw e; + } +} diff --git a/capstone/frontend/package-lock.json b/capstone/frontend/package-lock.json index 3592f41..b23556d 100644 --- a/capstone/frontend/package-lock.json +++ b/capstone/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "axios": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0" @@ -1143,6 +1144,11 @@ "has-symbols": "^1.0.3" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1155,6 +1161,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", + "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1274,6 +1290,17 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1345,6 +1372,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1908,6 +1943,25 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -1917,6 +1971,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2681,6 +2748,25 @@ "yallist": "^3.0.2" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2990,6 +3076,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", diff --git a/capstone/frontend/package.json b/capstone/frontend/package.json index 0aece86..61dc7b2 100644 --- a/capstone/frontend/package.json +++ b/capstone/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0" diff --git a/capstone/frontend/src/hooks/useIsAuthenticated.js b/capstone/frontend/src/hooks/useIsAuthenticated.js new file mode 100644 index 0000000..01e264e --- /dev/null +++ b/capstone/frontend/src/hooks/useIsAuthenticated.js @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; + +async function checkIfAuthenticated() { + try { + const response = await axios({ + method: "get", + url: "/api/auth/verify", + }); + return response.status === 200; + } catch (e) { + if (e.response.status === 401) { + return false; + } + throw e; + } +} + +export default function useIsAuthenticated() { + const navigate = useNavigate(); + + useEffect(() => { + async function init() { + const isAuthenticated = await checkIfAuthenticated(); + !isAuthenticated && navigate("/"); + } + init(); + }, [navigate]); + + return {}; +} diff --git a/capstone/frontend/src/main.jsx b/capstone/frontend/src/main.jsx index 973d721..0cb03c7 100644 --- a/capstone/frontend/src/main.jsx +++ b/capstone/frontend/src/main.jsx @@ -6,11 +6,12 @@ import Todos2, { loader as todos2Loader, action as todos2Action, } from "./pages/Todos2"; +import Homepage from "./pages/Homepage"; const router = createBrowserRouter([ { path: "/", - element:
Hello world!
, + element: , }, { path: "/todos", diff --git a/capstone/frontend/src/pages/Homepage.jsx b/capstone/frontend/src/pages/Homepage.jsx new file mode 100644 index 0000000..31658f2 --- /dev/null +++ b/capstone/frontend/src/pages/Homepage.jsx @@ -0,0 +1,72 @@ +import { useRef, useState } from "react"; +import axios from "axios"; +import { useNavigate } from "react-router-dom"; + +export default function Homepage() { + const navigate = useNavigate(); + const emailRef = useRef(); + const passwordRef = useRef(); + const [errors, setErrors] = useState([]); + + async function onLoginClick() { + try { + const response = await axios({ + method: "post", + url: "/api/auth/sign-in", + data: { + email: emailRef.current.value, + password: passwordRef.current.value, + }, + }); + + const authenticatedUser = response.data; + localStorage.setItem( + "authenticatedUser", + JSON.stringify(authenticatedUser) + ); + navigate("/todos"); + } catch (err) { + console.log(err); + const { data } = err.response; + + if (Array.isArray(data)) { + return setErrors(data.map((err) => `${err.field} - ${err.message}`)); + } + + if ("error" in data) { + setErrors([data.error]); + } + + throw err; + } + } + + return ( + <> +
+ + + + + + +
+ + ); +} diff --git a/capstone/frontend/src/pages/Todos.jsx b/capstone/frontend/src/pages/Todos.jsx index 5b3d0dc..20abd61 100644 --- a/capstone/frontend/src/pages/Todos.jsx +++ b/capstone/frontend/src/pages/Todos.jsx @@ -1,12 +1,19 @@ import { useEffect, useRef, useState } from "react"; +import axios from "axios"; +import { useNavigate } from "react-router-dom"; +import useIsAuthenticated from "../hooks/useIsAuthenticated"; export const loader = async () => { - const response = await fetch("http://localhost:8081/api/todos"); + const response = await fetch("http://localhost:8081/api/todos", { + credentials: "include", + }); const todos = await response.json(); return { todos }; }; export default function Todos() { + useIsAuthenticated(); + const navigate = useNavigate(); const [todos, setTodos] = useState([]); useEffect(() => { @@ -60,9 +67,24 @@ export default function Todos() { }); }; + async function onSignOutClick() { + await axios({ + method: "post", + url: "/api/auth/sign-out", + }); + navigate("/"); + } + return ( <> -

Todos

+
+

Todos

+
+ +
+
{todos.map((todo) => (