diff --git a/.env.example b/.env.example index 80fa17d..73537d7 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ POSTGRES_PASSWORD=bookrunner # DATABASE_URL for running the backend directly (outside Docker). # When using docker compose, the DATABASE_URL is set automatically in compose.yml. DATABASE_URL=postgresql://bookrunner:bookrunner@localhost:5432/bookrunner +# Secret key used to sign JWT tokens. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +JWT_SECRET=change_me_to_a_long_random_secret diff --git a/README.md b/README.md index 8ece1b0..8340d56 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ ## Start the full local stack -1. (Optional) Copy environment variables to override defaults: +1. Copy environment variables and set `JWT_SECRET`: ```bash cp .env.example .env + # Edit .env and replace JWT_SECRET with a long random value. ``` 2. Start frontend + Express backend + PostgreSQL: @@ -18,7 +19,7 @@ docker compose up --build ``` -This is the single command that starts the local stack. +After `.env` is configured, this is the single command that starts the local stack. ## Local URLs @@ -32,18 +33,17 @@ All published ports are loopback-only (`127.0.0.1`) for local development. The frontend is served by nginx and API requests are proxied to the Express backend. -## Current scope (PostgreSQL foundation only) +## Current Scope -This Docker stack establishes the PostgreSQL database foundation: +This Docker stack establishes the PostgreSQL-backed local application: - Frontend container builds and serves static assets -- Express backend boots and responds on `/health` +- Express backend boots, responds on `/health`, and serves the auth/profile API - PostgreSQL service starts, becomes healthy, and runs `bookrunner.sql` when the data volume is first initialized -- Backend receives `DATABASE_URL` for future PostgreSQL-backed API work +- Backend receives `DATABASE_URL` and `JWT_SECRET` for PostgreSQL-backed auth -**Backend data access (auth, cart, orders) is not yet implemented** and is tracked in: +**Backend data access for cart and orders is not yet implemented** and is tracked in: -- #5 (auth) - #6 (cart) - #7 (orders) @@ -54,6 +54,8 @@ This Docker stack establishes the PostgreSQL database foundation: Run the full stack: ```bash +cp .env.example .env +# Edit .env and replace JWT_SECRET with a long random value. docker compose up --build ``` @@ -62,10 +64,11 @@ Expected results: - PostgreSQL becomes healthy (`pg_isready` passes) - Schema tables (`users`, `cart_items`, `orders`, `order_items`) are created from `bookrunner.sql` - Backend `/health` returns `200 {"status":"ok"}` +- Auth/profile requests under `/resources/api_user.php` are handled by the Express backend - Frontend is accessible at `http://localhost:8080` - Backend health is accessible at `http://localhost:3000/health` and via proxy at `http://localhost:8080/health` -> **Note:** PostgreSQL starts and initializes the schema, but backend API routes for auth/cart/orders are not yet connected. Those are implemented in #5, #6, and #7. +> **Note:** PostgreSQL starts and initializes the schema, and auth/profile routes are connected. Cart and order data access are tracked separately in #6 and #7. > **Schema reset:** PostgreSQL init scripts only run when the data volume is empty. If `bookrunner.sql` changes, run `docker compose down -v` before starting the stack again to force a fresh schema initialization. diff --git a/backend/.env.example b/backend/.env.example index f085589..ad478dc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,3 +4,9 @@ HOST=0.0.0.0 # Environment (development | production) NODE_ENV=development + +# PostgreSQL connection string (set automatically in Docker Compose via compose.yml) +DATABASE_URL=postgresql://bookrunner:bookrunner@localhost:5432/bookrunner + +# Secret key used to sign JWT tokens – change this to a long random string in production +JWT_SECRET=change_me_to_a_long_random_secret diff --git a/backend/package-lock.json b/backend/package-lock.json index 8be61c5..53461c5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,8 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "express-rate-limit": "^8.5.1", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0" }, "devDependencies": { "nodemon": "^3.1.0" @@ -62,6 +66,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -140,6 +153,12 @@ "node": ">=8" } }, + "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", @@ -293,6 +312,15 @@ "node": ">= 0.4" } }, + "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", @@ -399,6 +427,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -613,6 +659,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -668,6 +723,97 @@ "node": ">=0.12.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "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/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.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==", + "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.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.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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -871,6 +1017,95 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -884,6 +1119,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -986,7 +1260,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1131,6 +1404,15 @@ "node": ">=10" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1231,6 +1513,15 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/backend/package.json b/backend/package.json index 1d31b36..9f9db41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,12 @@ "test": "node --test src/index.test.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "express-rate-limit": "^8.5.1", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0" }, "devDependencies": { "nodemon": "^3.1.0" diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..f4bdf71 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,7 @@ +'use strict'; + +const { Pool } = require('pg'); + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +module.exports = pool; diff --git a/backend/src/index.js b/backend/src/index.js index 4131b34..fdf3d3d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -3,6 +3,7 @@ require('dotenv').config(); const express = require('express'); +const userRoutes = require('./routes/users'); const app = express(); const DEFAULT_PORT = 3000; @@ -35,6 +36,8 @@ app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok' }); }); +app.use(userRoutes); + if (require.main === module) { app.listen(PORT, HOST, () => { console.log(`BookRunner API running on http://${HOST}:${PORT}`); diff --git a/backend/src/index.test.js b/backend/src/index.test.js index ea90220..9ffab73 100644 --- a/backend/src/index.test.js +++ b/backend/src/index.test.js @@ -1,29 +1,505 @@ 'use strict'; +// Set JWT_SECRET before loading the app so token signing works in tests +process.env.JWT_SECRET = 'test-secret-for-unit-tests'; + const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const db = require('./db'); const app = require('./index'); -test('GET /health returns status ok', async () => { +// --------------------------------------------------------------------------- +// Helper – start a temporary server on a random port and tear it down after +// --------------------------------------------------------------------------- +async function withServer(fn) { const server = app.listen(0, '127.0.0.1'); - await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); }); - const { port } = server.address(); - try { - const response = await fetch(`http://127.0.0.1:${port}/health`); - const payload = await response.json(); - - assert.equal(response.status, 200); - assert.deepEqual(payload, { status: 'ok' }); + await fn(port); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } +} + +// Helper – produce a signed JWT for a given user id +function makeToken(id, email = 'user@example.com') { + return jwt.sign({ sub: String(id), email }, process.env.JWT_SECRET, { expiresIn: '1h' }); +} + +// --------------------------------------------------------------------------- +// Existing health check +// --------------------------------------------------------------------------- + +test('GET /health returns status ok', async () => { + await withServer(async (port) => { + const response = await fetch(`http://127.0.0.1:${port}/health`); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.deepEqual(payload, { status: 'ok' }); + }); +}); + +// --------------------------------------------------------------------------- +// POST /resources/api_user.php – input validation (no database required) +// --------------------------------------------------------------------------- + +test('POST /resources/api_user.php returns 400 when body is empty', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, + ); + assert.equal(response.status, 400); + const payload = await response.json(); + assert.ok(payload.error, 'should return an error message'); + }); +}); + +test('POST /resources/api_user.php returns 400 when password is missing', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'user@example.com' }), + }, + ); + assert.equal(response.status, 400); + }); +}); + +test('POST /resources/api_user.php returns 400 when email is missing', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'secret' }), + }, + ); + assert.equal(response.status, 400); + }); +}); + +test('POST /resources/api_user.php returns 400 when name is present but empty (registration)', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '', email: 'user@example.com', password: 'password123' }), + }, + ); + assert.equal(response.status, 400); + const payload = await response.json(); + assert.ok(payload.error); + }); +}); + +// --------------------------------------------------------------------------- +// POST /resources/api_user.php – registration (mocked db) +// --------------------------------------------------------------------------- + +test('POST /resources/api_user.php registers a new user successfully', async (t) => { + t.mock.method(db, 'query', async (sql) => { + // Email-check SELECT returns empty; INSERT returns nothing meaningful + if (sql.includes('SELECT')) return { rows: [] }; + return { rows: [], rowCount: 1 }; + }); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Alice', email: 'alice@example.com', password: 'password123' }), + }, + ); + assert.equal(response.status, 201); + const payload = await response.json(); + assert.equal(payload.success, true); + }); +}); + +test('POST /resources/api_user.php returns 409 when email already registered', async (t) => { + t.mock.method(db, 'query', async () => { + return { rows: [{ id: 'existing-id' }] }; + }); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Alice', email: 'existing@example.com', password: 'password123' }), + }, + ); + assert.equal(response.status, 409); + const payload = await response.json(); + assert.ok(payload.error); + }); +}); + +// --------------------------------------------------------------------------- +// POST /resources/api_user.php – login (mocked db) +// --------------------------------------------------------------------------- + +test('POST /resources/api_user.php logs in and returns a JWT', async (t) => { + const bcrypt = require('bcryptjs'); + const hash = await bcrypt.hash('correctpassword', 4); // low rounds for speed + + t.mock.method(db, 'query', async () => ({ + rows: [{ id: 'user-uuid-1', name: 'Alice', email: 'alice@example.com', password_hash: hash }], + })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'alice@example.com', password: 'correctpassword' }), + }, + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.id, 'user-uuid-1'); + assert.equal(payload.name, 'Alice'); + assert.ok(payload.token, 'should return a JWT token'); + + // Verify the token is a valid JWT + const decoded = jwt.verify(payload.token, process.env.JWT_SECRET); + assert.equal(decoded.sub, 'user-uuid-1'); + }); +}); + +test('POST /resources/api_user.php returns 401 for wrong password', async (t) => { + const bcrypt = require('bcryptjs'); + const hash = await bcrypt.hash('correctpassword', 4); + + t.mock.method(db, 'query', async () => ({ + rows: [{ id: 'user-uuid-1', name: 'Alice', email: 'alice@example.com', password_hash: hash }], + })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'alice@example.com', password: 'wrongpassword' }), + }, + ); + assert.equal(response.status, 401); + }); +}); + +test('POST /resources/api_user.php returns 401 when user not found', async (t) => { + t.mock.method(db, 'query', async () => ({ rows: [] })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'nobody@example.com', password: 'password' }), + }, + ); + assert.equal(response.status, 401); + }); +}); + +test('POST /resources/api_user.php returns JSON 500 when database query fails', async (t) => { + t.mock.method(db, 'query', async () => { + throw new Error('database unavailable'); + }); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'alice@example.com', password: 'correctpassword' }), + signal: AbortSignal.timeout(1000), + }, + ); + assert.equal(response.status, 500); + const payload = await response.json(); + assert.equal(payload.error, 'Internal server error'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /resources/api_user.php/id/:id – auth required (no database required) +// --------------------------------------------------------------------------- + +test('GET /resources/api_user.php/id/:id returns 401 without Authorization header', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/some-uuid`, + ); + assert.equal(response.status, 401); + }); +}); + +test('GET /resources/api_user.php/id/:id returns 401 with invalid token', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/some-uuid`, + { headers: { Authorization: 'Bearer not-a-valid-jwt' } }, + ); + assert.equal(response.status, 401); + }); +}); + +// --------------------------------------------------------------------------- +// GET /resources/api_user.php/id/:id – authenticated profile fetch (mocked db) +// --------------------------------------------------------------------------- + +test('GET /resources/api_user.php/id/:id returns 403 when token user != path user', async () => { + const token = makeToken('other-uuid'); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/target-uuid`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + assert.equal(response.status, 403); + }); +}); + +test('GET /resources/api_user.php/id/:id returns 200 with own profile', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ + rows: [{ id: userId, name: 'Alice', email: 'alice@example.com', created_at: new Date().toISOString() }], + })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.id, userId); + assert.equal(payload.name, 'Alice'); + }); +}); + +test('GET /resources/api_user.php/id/:id returns 404 when user not found', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ rows: [] })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + assert.equal(response.status, 404); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /resources/api_user.php/id/:id – auth required (no database required) +// --------------------------------------------------------------------------- + +test('PUT /resources/api_user.php/id/:id returns 401 without Authorization header', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/some-uuid`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Alice' }), + }, + ); + assert.equal(response.status, 401); + }); +}); + +test('PUT /resources/api_user.php/id/:id returns 401 with invalid token', async () => { + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/some-uuid`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer bad-token', + }, + body: JSON.stringify({ name: 'Alice' }), + }, + ); + assert.equal(response.status, 401); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /resources/api_user.php/id/:id – profile update (mocked db) +// --------------------------------------------------------------------------- + +test('PUT /resources/api_user.php/id/:id returns 400 for empty name', async () => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: ' ' }), + }, + ); + assert.equal(response.status, 400); + }); +}); + +test('PUT /resources/api_user.php/id/:id returns 400 when no fields provided', async () => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({}), + }, + ); + assert.equal(response.status, 400); + }); +}); + +test('PUT /resources/api_user.php/id/:id updates name successfully', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ rows: [], rowCount: 1 })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: 'Alice Updated' }), + }, + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.success, true); + assert.equal(payload.affected_rows, 1); + }); +}); + +test('PUT /resources/api_user.php/id/:id returns 404 when user not found', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ rows: [], rowCount: 0 })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: 'Alice' }), + }, + ); + assert.equal(response.status, 404); + }); +}); + +test('PUT /resources/api_user.php/id/:id returns 409 when new email already in use', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ rows: [{ id: 'other-user' }] })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ email: 'taken@example.com' }), + }, + ); + assert.equal(response.status, 409); + }); +}); + +test('PUT /resources/api_user.php/id/:id returns 400 for short password', async () => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ password: 'short' }), + }, + ); + assert.equal(response.status, 400); + }); +}); + +test('PUT /resources/api_user.php/id/:id updates password successfully', async (t) => { + const userId = 'user-uuid-1'; + const token = makeToken(userId); + + t.mock.method(db, 'query', async () => ({ rows: [], rowCount: 1 })); + + await withServer(async (port) => { + const response = await fetch( + `http://127.0.0.1:${port}/resources/api_user.php/id/${userId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ password: 'newPassword123' }), + }, + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.success, true); + }); +}); + +test('reset password template shows success before logged-out warning after password change', () => { + const resetComponentPath = path.join(__dirname, '..', '..', 'js', 'components', 'app-reset-password.js'); + const source = fs.readFileSync(resetComponentPath, 'utf8'); + + const submittedBranch = source.indexOf('v-if="submitted"'); + const loggedOutBranch = source.indexOf('v-else-if="!authState.isLoggedIn"'); + + assert.notEqual(submittedBranch, -1, 'template should have an explicit submitted success branch'); + assert.notEqual(loggedOutBranch, -1, 'template should still show a logged-out warning branch'); + assert.ok(submittedBranch < loggedOutBranch, 'submitted branch must be evaluated before logged-out warning'); }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..94fcfef --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,210 @@ +'use strict'; + +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const rateLimit = require('express-rate-limit'); +const db = require('../db'); + +const router = express.Router(); +const BCRYPT_ROUNDS = 12; + +// --------------------------------------------------------------------------- +// Rate limiters +// --------------------------------------------------------------------------- + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' }, +}); + +const profileLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function signToken(user) { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET is not configured'); + return jwt.sign({ sub: String(user.id), email: user.email }, secret, { expiresIn: '24h' }); +} + +function requireAuth(req, res, next) { + const header = req.headers['authorization']; + if (!header || !header.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const token = header.slice(7); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET); + req.user = payload; + next(); + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +function asyncHandler(handler) { + return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); +} + +// --------------------------------------------------------------------------- +// POST /resources/api_user.php +// Body contains `name` (or `username`) + `email` + `password` → register +// Body contains only `email` + `password` → login +// --------------------------------------------------------------------------- + +router.post('/resources/api_user.php', authLimiter, asyncHandler(async (req, res) => { + const { email, password } = req.body ?? {}; + const name = req.body?.name ?? req.body?.username; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + // --- REGISTRATION --- + // Branch on presence of the name/username field (even if empty) to surface the right error. + const isRegistration = 'name' in (req.body ?? {}) || 'username' in (req.body ?? {}); + if (isRegistration) { + if (typeof name !== 'string' || name.trim().length < 1) { + return res.status(400).json({ error: 'Name is required' }); + } + + // Duplicate email check + const existing = await db.query('SELECT id FROM users WHERE email = $1', [email]); + if (existing.rows.length > 0) { + return res.status(409).json({ error: 'Email already registered' }); + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + await db.query( + 'INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3)', + [name.trim(), email, passwordHash], + ); + + return res.status(201).json({ success: true }); + } + + // --- LOGIN --- + const result = await db.query('SELECT * FROM users WHERE email = $1', [email]); + const user = result.rows[0]; + + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + const match = await bcrypt.compare(password, user.password_hash); + if (!match) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + const token = signToken(user); + return res.status(200).json({ id: user.id, name: user.name, email: user.email, token }); +})); + +// --------------------------------------------------------------------------- +// GET /resources/api_user.php/id/:id – fetch profile (authenticated) +// --------------------------------------------------------------------------- + +router.get('/resources/api_user.php/id/:id', profileLimiter, requireAuth, asyncHandler(async (req, res) => { + const { id } = req.params; + + if (String(req.user.sub) !== String(id)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const result = await db.query( + 'SELECT id, name, email, created_at FROM users WHERE id = $1', + [id], + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + return res.status(200).json(result.rows[0]); +})); + +// --------------------------------------------------------------------------- +// PUT /resources/api_user.php/id/:id – update profile (authenticated) +// --------------------------------------------------------------------------- + +router.put('/resources/api_user.php/id/:id', profileLimiter, requireAuth, asyncHandler(async (req, res) => { + const { id } = req.params; + + if (String(req.user.sub) !== String(id)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const updates = {}; + const { name, email, password } = req.body ?? {}; + + if (name !== undefined) { + const trimmedName = typeof name === 'string' ? name.trim() : ''; + if (trimmedName.length < 1) { + return res.status(400).json({ error: 'Name cannot be empty' }); + } + updates.name = trimmedName; + } + + if (email !== undefined) { + if (typeof email !== 'string' || email.trim().length < 1) { + return res.status(400).json({ error: 'Email cannot be empty' }); + } + // Duplicate email check (exclude current user) + const existing = await db.query( + 'SELECT id FROM users WHERE email = $1 AND id != $2', + [email.trim(), id], + ); + if (existing.rows.length > 0) { + return res.status(409).json({ error: 'Email already in use' }); + } + updates.email = email.trim(); + } + + if (password !== undefined) { + if (typeof password !== 'string' || password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + updates.password_hash = await bcrypt.hash(password, BCRYPT_ROUNDS); + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + const setClauses = Object.keys(updates).map((col, i) => `${col} = $${i + 1}`); + setClauses.push(`updated_at = NOW()`); + const values = [...Object.values(updates), id]; + + const result = await db.query( + `UPDATE users SET ${setClauses.join(', ')} WHERE id = $${values.length}`, + values, + ); + + if (result.rowCount === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + return res.status(200).json({ success: true, affected_rows: result.rowCount }); +})); + +router.use((error, _req, res, next) => { + if (res.headersSent) { + return next(error); + } + + return res.status(500).json({ error: 'Internal server error' }); +}); + +module.exports = router; diff --git a/compose.yml b/compose.yml index c3e3d37..ac004c0 100644 --- a/compose.yml +++ b/compose.yml @@ -27,6 +27,7 @@ services: HOST: 0.0.0.0 PORT: 3000 DATABASE_URL: ${DATABASE_URL:-postgresql://bookrunner:bookrunner@db:5432/bookrunner} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set – see .env.example} depends_on: db: condition: service_healthy diff --git a/js/components/app-login.js b/js/components/app-login.js index c1612ba..c5db155 100644 --- a/js/components/app-login.js +++ b/js/components/app-login.js @@ -26,12 +26,11 @@ const Login = { }) .then((res) => res.json()) .then((data) => { - if (!data) { - this.msg = "Username or password incorrect."; + if (!data || data.error) { + this.msg = data?.error || "Email or password incorrect."; } else { this.authState.isLoggedIn = true; this.authState.user = data; - sessionStorage.setItem("user", JSON.stringify(data)); this.$router.push("/product"); } }) @@ -89,7 +88,7 @@ const Login = { required />
- Forgot Password? + To change your password, visit your profile after logging in.
diff --git a/js/components/app-profile.js b/js/components/app-profile.js index d6490d2..2062b45 100644 --- a/js/components/app-profile.js +++ b/js/components/app-profile.js @@ -2,10 +2,8 @@ const Profile = { inject: ["authState"], data() { return { - username: "", + name: "", email: "", - age: "", - gender: "", editMode: false, recentPurchases: [], }; @@ -19,15 +17,16 @@ const Profile = { methods: { fetchProfile() { const userId = this.authState.user?.id; - if (this.authState.isLoggedIn && userId) { - fetch(`resources/api_user.php/id/${userId}`) + const token = this.authState.user?.token; + if (this.authState.isLoggedIn && userId && token) { + fetch(`resources/api_user.php/id/${userId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) .then(res => res.json()) .then(data => { - if (data) { - this.username = data.username || ""; + if (data && !data.error) { + this.name = data.name || ""; this.email = data.email || ""; - this.age = data.age || ""; - this.gender = data.gender || ""; } }); } @@ -52,15 +51,17 @@ const Profile = { saveProfile() { const userId = this.authState.user?.id; - if (this.authState.isLoggedIn && userId) { + const token = this.authState.user?.token; + if (this.authState.isLoggedIn && userId && token) { fetch(`resources/api_user.php/id/${userId}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, body: JSON.stringify({ - username: this.username, + name: this.name, email: this.email, - age: this.age, - gender: this.gender, }), }) .then(res => res.json()) @@ -120,25 +121,12 @@ const Profile = {
- +
-
- - -
-
- - -
diff --git a/js/components/app-register.js b/js/components/app-register.js index 823ffb0..7e937f1 100644 --- a/js/components/app-register.js +++ b/js/components/app-register.js @@ -15,12 +15,10 @@ const Register = {

Create Account


-