From 648261a6c6921552bb1fe845c8687113c9513881 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 06:10:56 +0000 Subject: [PATCH 1/5] Initial plan From d3f146e07107086b5bcd65e6e80e47383b539e21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 06:19:00 +0000 Subject: [PATCH 2/5] feat: migrate registration, login, and profile flows to Node.js API - Add bcryptjs, jsonwebtoken, pg dependencies to backend - Create backend/src/db.js (PostgreSQL pool via DATABASE_URL) - Create backend/src/routes/users.js with: - POST /resources/api_user.php: register (bcrypt hash) or login (JWT) - GET /resources/api_user.php/id/:id: fetch own profile (Bearer auth) - PUT /resources/api_user.php/id/:id: update profile/password (Bearer auth) - Email enumeration (getAllEmails) and unauthenticated lookup not implemented - Mount user routes in backend/src/index.js - Add DATABASE_URL and JWT_SECRET to backend/.env.example - Expand test suite in backend/src/index.test.js (8 tests, all passing) - Update js/components/app-register.js: use name field, remove age/gender - Update js/components/app-login.js: handle new {id,name,email,token} response - Update js/components/app-profile.js: send Authorization header, adapt to new schema - Update js/components/app-reset-password.js: require auth, remove email-enumeration calls Agent-Logs-Url: https://github.com/Josan88/BookRunner/sessions/75df8ea5-09c5-4995-b931-d149817a40e2 Co-authored-by: Josan88 <124897328+Josan88@users.noreply.github.com> --- backend/.env.example | 6 + backend/package-lock.json | 267 +++++++++++++++++++++++++++- backend/package.json | 5 +- backend/src/db.js | 7 + backend/src/index.js | 3 + backend/src/index.test.js | 130 +++++++++++++- backend/src/routes/users.js | 147 +++++++++++++++ js/components/app-login.js | 5 +- js/components/app-profile.js | 44 ++--- js/components/app-register.js | 21 +-- js/components/app-reset-password.js | 106 +++-------- 11 files changed, 603 insertions(+), 138 deletions(-) create mode 100644 backend/src/db.js create mode 100644 backend/src/routes/users.js 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..7937ef1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0" }, "devDependencies": { "nodemon": "^3.1.0" @@ -62,6 +65,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 +152,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 +311,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", @@ -668,6 +695,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 +989,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 +1091,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 +1232,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 +1376,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 +1485,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..ae5770a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,11 @@ "test": "node --test src/index.test.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "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..ed23940 100644 --- a/backend/src/index.test.js +++ b/backend/src/index.test.js @@ -5,25 +5,137 @@ const assert = require('node:assert/strict'); 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())); }); } +} + +// --------------------------------------------------------------------------- +// 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..ee2c591 --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,147 @@ +'use strict'; + +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const db = require('../db'); + +const router = express.Router(); +const BCRYPT_ROUNDS = 12; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function signToken(user) { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET is not configured'); + return jwt.sign({ sub: 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' }); + } +} + +// --------------------------------------------------------------------------- +// 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', 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 --- + if (name !== undefined && name !== null && name !== '') { + 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', requireAuth, async (req, res) => { + const { id } = req.params; + + if (req.user.sub !== 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', requireAuth, async (req, res) => { + const { id } = req.params; + + if (req.user.sub !== id) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const updates = {}; + const { name, email, password } = req.body ?? {}; + + if (name !== undefined) updates.name = name; + if (email !== undefined) updates.email = email; + if (password !== undefined) { + 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]; + + await db.query( + `UPDATE users SET ${setClauses.join(', ')} WHERE id = $${values.length}`, + values, + ); + + return res.status(200).json({ success: true, affected_rows: 1 }); +}); + +module.exports = router; diff --git a/js/components/app-login.js b/js/components/app-login.js index c1612ba..dced87c 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 || "Username or password incorrect."; } else { this.authState.isLoggedIn = true; this.authState.user = data; - sessionStorage.setItem("user", JSON.stringify(data)); this.$router.push("/product"); } }) 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 = {