From 6937b4e1146d6941a1d5d047a88bcefcaed479ba Mon Sep 17 00:00:00 2001 From: Sewmina Date: Thu, 1 Oct 2026 13:05:10 +0530 Subject: [PATCH] SMS gone, approvals in --- .env.example | 3 +- package-lock.json | 51 +--- package.json | 7 +- .../migration.sql | 34 +++ prisma/schema.prisma | 28 +- prisma/seed.js | 12 +- scripts/api-check.js | 96 +++++-- src/lib/passwords.js | 14 + src/routes/admin.js | 8 +- src/routes/auth.js | 239 +++++++++++++----- src/services/smsService.js | 33 --- 11 files changed, 350 insertions(+), 175 deletions(-) create mode 100644 prisma/migrations/20261001070000_password_login/migration.sql create mode 100644 src/lib/passwords.js delete mode 100644 src/services/smsService.js diff --git a/.env.example b/.env.example index 36f9ccc..ece4861 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ DATABASE_URL="file:../data/app.db" JWT_SECRET="replace-with-a-long-random-string" -TEXTLK_API_TOKEN= -TEXTLK_SENDER_ID=TextLKDemo SEED_ADMIN_NAME="Super Admin" SEED_ADMIN_PHONE="0715127342" +SEED_ADMIN_PASSWORD="change-this-password" PORT=3000 diff --git a/package-lock.json b/package-lock.json index d9462f7..2bdbd67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,14 @@ "dependencies": { "@prisma/client": "^6.16.2", "axios": "^1.12.2", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { + "jsonwebtoken": "^9.0.2", "prisma": "^6.16.2" - } + }, + "devDependencies": {} }, "node_modules/@prisma/client": { "version": "6.19.3", @@ -46,7 +46,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -59,14 +58,12 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -80,14 +77,12 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -99,7 +94,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -109,7 +103,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/accepts": { @@ -184,6 +177,15 @@ "proxy-from-env": "^2.1.0" } }, + "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/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -227,7 +229,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -285,7 +286,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -301,7 +301,6 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -323,14 +322,12 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -402,7 +399,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", - "devOptional": true, "funding": [ { "type": "ko-fi", @@ -422,7 +418,6 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -447,7 +442,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/destroy": { @@ -505,7 +499,6 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -516,7 +509,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -641,14 +633,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -789,7 +779,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -941,7 +930,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1126,14 +1114,12 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, "license": "MIT" }, "node_modules/nypm": { "version": "0.6.10", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.10.tgz", "integrity": "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -1151,7 +1137,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -1179,7 +1164,6 @@ "version": "2.0.12", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "devOptional": true, "license": "MIT" }, "node_modules/on-finished": { @@ -1213,21 +1197,18 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, "license": "MIT" }, "node_modules/pkg-types": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", @@ -1239,14 +1220,12 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "devOptional": true, "license": "MIT" }, "node_modules/prisma": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1298,7 +1277,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -1355,7 +1333,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -1366,7 +1343,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -1550,7 +1526,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 81a69bf..ee909b3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "src/index.js", "scripts": { "dev": "node --watch src/index.js", - "start": "node src/index.js", + "start": "prisma migrate deploy && node prisma/seed.js && node src/index.js", "seed": "node prisma/seed.js", "check": "node scripts/api-check.js", "postinstall": "prisma generate" @@ -16,12 +16,11 @@ "dependencies": { "@prisma/client": "^6.16.2", "axios": "^1.12.2", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { + "jsonwebtoken": "^9.0.2", "prisma": "^6.16.2" }, "overrides": { diff --git a/prisma/migrations/20261001070000_password_login/migration.sql b/prisma/migrations/20261001070000_password_login/migration.sql new file mode 100644 index 0000000..d1dcbc2 --- /dev/null +++ b/prisma/migrations/20261001070000_password_login/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "LoginRequest" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "secretHash" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "decidedById" TEXT, + "decidedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "LoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "LoginRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "role" TEXT NOT NULL, + "dealerId" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt") SELECT "createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c59fad..c934d24 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,16 +14,30 @@ model User { role String dealerId String? dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id]) - currentOtp String? - otpExpiresAt DateTime? + passwordHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - assignments DealerAssignment[] @relation("SalesRepAssignments") - stageLogs StageLog[] - transfers StockTransfer[] - events Event[] - servicePoints ServicePoint[] + assignments DealerAssignment[] @relation("SalesRepAssignments") + stageLogs StageLog[] + transfers StockTransfer[] + events Event[] + servicePoints ServicePoint[] + loginRequests LoginRequest[] @relation("DealerLoginRequests") + loginDecisions LoginRequest[] @relation("LoginDecisions") +} + +model LoginRequest { + id String @id @default(cuid()) + userId String + user User @relation("DealerLoginRequests", fields: [userId], references: [id], onDelete: Cascade) + status String + secretHash String + expiresAt DateTime + decidedById String? + decidedBy User? @relation("LoginDecisions", fields: [decidedById], references: [id]) + decidedAt DateTime? + createdAt DateTime @default(now()) } model Dealer { diff --git a/prisma/seed.js b/prisma/seed.js index 5e2f39a..a4abfad 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -3,24 +3,30 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); const prisma = require('../src/db'); const { isValidPhone, normalizePhone } = require('../src/lib/phone'); +const { hashPassword } = require('../src/lib/passwords'); async function main() { const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim(); const phone = normalizePhone(process.env.SEED_ADMIN_PHONE); + const password = String(process.env.SEED_ADMIN_PASSWORD || ''); if (!isValidPhone(phone)) { throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number'); } + if (password.length < 8) { + throw new Error('SEED_ADMIN_PASSWORD must be at least 8 characters'); + } + const passwordHash = await hashPassword(password); const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } }); const admin = existingAdmin ? await prisma.user.update({ where: { id: existingAdmin.id }, - data: { name, phone, role: 'superAdmin', dealerId: null }, + data: { name, phone, role: 'superAdmin', dealerId: null, passwordHash }, }) : await prisma.user.upsert({ where: { phone }, - update: { name, role: 'superAdmin', dealerId: null }, - create: { name, phone, role: 'superAdmin' }, + update: { name, role: 'superAdmin', dealerId: null, passwordHash }, + create: { name, phone, role: 'superAdmin', passwordHash }, }); console.log(`Seeded super admin ${admin.name} (${admin.phone})`); } diff --git a/scripts/api-check.js b/scripts/api-check.js index 0fa7149..02cbd58 100644 --- a/scripts/api-check.js +++ b/scripts/api-check.js @@ -22,6 +22,7 @@ execSync('npx prisma db push --skip-generate --accept-data-loss', { env: process.env, }); +const bcrypt = require('bcryptjs'); const app = require('../src/app'); const prisma = require('../src/db'); const { ageBadge } = require('../src/lib/stockRules'); @@ -61,31 +62,30 @@ async function run(port) { assert(ageBadge(45) === 'yellow', '45 days is yellow'); assert(ageBadge(46) === 'red', '46 days is red'); - const unknown = await request(port, 'POST', '/api/auth/send-otp', { + const unknown = await request(port, 'POST', '/api/auth/dealer-login', { body: { phone: '94770000099' }, }); assert(unknown.status === 404, `unknown phone should be 404, got ${unknown.status}`); + const adminHash = await bcrypt.hash('admin-pass-123', 10); await prisma.user.create({ - data: { name: 'Super Admin', phone: '94770000001', role: 'superAdmin' }, + data: { + name: 'Super Admin', + phone: '94770000001', + role: 'superAdmin', + passwordHash: adminHash, + }, }); - const sent = await request(port, 'POST', '/api/auth/send-otp', { - body: { phone: '94770000001' }, + const badPassword = await request(port, 'POST', '/api/auth/staff-login', { + body: { phone: '94770000001', password: 'wrong-password' }, }); - assert(sent.status === 200 && sent.json.success === true, 'OTP send failed'); + assert(badPassword.status === 401, 'wrong password should be rejected'); - const adminRow = await prisma.user.findUnique({ where: { phone: '94770000001' } }); - const wrongCode = adminRow.currentOtp === '000000' ? '111111' : '000000'; - const badOtp = await request(port, 'POST', '/api/auth/verify-otp', { - body: { phone: '94770000001', otp: wrongCode }, + const verified = await request(port, 'POST', '/api/auth/staff-login', { + body: { phone: '94770000001', password: 'admin-pass-123' }, }); - assert(badOtp.status === 401, 'wrong OTP should be rejected'); - - const verified = await request(port, 'POST', '/api/auth/verify-otp', { - body: { phone: '94770000001', otp: adminRow.currentOtp }, - }); - assert(verified.status === 200 && verified.json.token, 'OTP verify failed'); + assert(verified.status === 200 && verified.json.token, 'staff login failed'); const adminToken = verified.json.token; const me = await request(port, 'GET', '/api/auth/me', { token: adminToken }); @@ -112,6 +112,7 @@ async function run(port) { body: { name: 'Nimal Perera', phone: '94771111111', + password: 'rep-pass-123', dealerIds: [dealerA.json.data.id, dealerB.json.data.id], }, }); @@ -123,19 +124,64 @@ async function run(port) { }); assert(dealerUser.status === 201, 'dealer user create failed'); - async function login(phone) { - const otpSend = await request(port, 'POST', '/api/auth/send-otp', { body: { phone } }); - assert(otpSend.status === 200, `otp send failed for ${phone}`); - const row = await prisma.user.findUnique({ where: { phone } }); - const otpVerify = await request(port, 'POST', '/api/auth/verify-otp', { - body: { phone, otp: row.currentOtp }, + async function staffLogin(phone, password) { + const result = await request(port, 'POST', '/api/auth/staff-login', { + body: { phone, password }, }); - assert(otpVerify.status === 200, `otp verify failed for ${phone}`); - return otpVerify.json.token; + assert(result.status === 200 && result.json.token, `staff login failed for ${phone}`); + return result.json.token; } - const repToken = await login('94771111111'); - const dealerToken = await login('94772222222'); + async function approveDealer(phone, token) { + const started = await request(port, 'POST', '/api/auth/dealer-login', { body: { phone } }); + assert(started.status === 201, `dealer login failed for ${phone}: ${started.status}`); + const approved = await request( + port, + 'POST', + `/api/auth/login-requests/${started.json.requestId}/approve`, + { token } + ); + assert(approved.status === 200, `approve failed for ${phone}: ${approved.status}`); + const ready = await request( + port, + 'GET', + `/api/auth/dealer-login/${started.json.requestId}?secret=${encodeURIComponent(started.json.secret)}` + ); + assert(ready.status === 200 && ready.json.token, `dealer token missing for ${phone}`); + return ready.json.token; + } + + const repToken = await staffLogin('94771111111', 'rep-pass-123'); + + const dealerStaff = await request(port, 'POST', '/api/auth/staff-login', { + body: { phone: '94772222222', password: 'rep-pass-123' }, + }); + assert(dealerStaff.status === 401, 'dealer should not use staff login'); + + const otherDealer = await request(port, 'POST', '/api/admin/dealer-users', { + token: adminToken, + body: { name: 'Jaffna Desk', phone: '94773333333', dealerId: dealerC.json.data.id }, + }); + assert(otherDealer.status === 201, 'second dealer user create failed'); + const outsideRequest = await request(port, 'POST', '/api/auth/dealer-login', { + body: { phone: '94773333333' }, + }); + assert(outsideRequest.status === 201, 'outside dealer login failed'); + const repQueue = await request(port, 'GET', '/api/auth/login-requests', { token: repToken }); + assert( + repQueue.status === 200 && + !repQueue.json.data.some((item) => item.id === outsideRequest.json.requestId), + 'sales rep should not see an unassigned dealer login' + ); + const repDenied = await request( + port, + 'POST', + `/api/auth/login-requests/${outsideRequest.json.requestId}/approve`, + { token: repToken } + ); + assert(repDenied.status === 403, 'sales rep should not approve an unassigned dealer'); + + const dealerToken = await approveDealer('94772222222', adminToken); const forbiddenStock = await request(port, 'POST', '/api/stocks', { token: dealerToken, diff --git a/src/lib/passwords.js b/src/lib/passwords.js new file mode 100644 index 0000000..578ef11 --- /dev/null +++ b/src/lib/passwords.js @@ -0,0 +1,14 @@ +const bcrypt = require('bcryptjs'); + +const ROUNDS = 10; + +function hashPassword(password) { + return bcrypt.hash(password, ROUNDS); +} + +function checkPassword(password, passwordHash) { + if (!passwordHash) return false; + return bcrypt.compare(password, passwordHash); +} + +module.exports = { hashPassword, checkPassword }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 1da1360..738929b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2,6 +2,7 @@ const express = require('express'); const prisma = require('../db'); const { asyncHandler, httpError } = require('../lib/errors'); const { isValidPhone, normalizePhone } = require('../lib/phone'); +const { hashPassword } = require('../lib/passwords'); const { canAccessDealer } = require('../lib/access'); const { userPublicSelect, publicUser } = require('../lib/users'); const { requireAuth, requireRole } = require('../middleware/auth'); @@ -60,9 +61,13 @@ router.post( asyncHandler(async (req, res) => { const name = String(req.body?.name || '').trim(); const phone = normalizePhone(req.body?.phone); + const password = String(req.body?.password || ''); const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : []; if (!name || !isValidPhone(phone)) { - throw httpError(400, 'Name and a 947XXXXXXXX phone are required'); + throw httpError(400, 'Name and a Sri Lankan mobile number are required'); + } + if (password.length < 8) { + throw httpError(400, 'Password must be at least 8 characters'); } if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) { throw httpError(400, 'dealerIds must be a list of ids'); @@ -78,6 +83,7 @@ router.post( name, phone, role: 'salesRep', + passwordHash: await hashPassword(password), assignments: { create: uniqueIds.map((dealerId) => ({ dealerId })), }, diff --git a/src/routes/auth.js b/src/routes/auth.js index edda7f2..a44b906 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -4,16 +4,18 @@ const jwt = require('jsonwebtoken'); const prisma = require('../db'); const { asyncHandler, httpError } = require('../lib/errors'); const { isValidPhone, normalizePhone } = require('../lib/phone'); +const { checkPassword } = require('../lib/passwords'); const { publicUser, userPublicSelect } = require('../lib/users'); -const { assignedDealerIds } = require('../lib/access'); -const { sendSmsOtp } = require('../services/smsService'); -const { requireAuth } = require('../middleware/auth'); +const { assignedDealerIds, canAccessDealer } = require('../lib/access'); +const { requireAuth, requireRole } = require('../middleware/auth'); const router = express.Router(); -const OTP_TTL_MS = 5 * 60 * 1000; +const REQUEST_TTL_MS = 5 * 60 * 1000; -function generateOtp() { - return String(crypto.randomInt(0, 1000000)).padStart(6, '0'); +function signToken(user) { + return jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { + expiresIn: '10y', + }); } async function accessibleDealers(user) { @@ -33,81 +35,194 @@ async function accessibleDealers(user) { }); } -router.post( - '/send-otp', - asyncHandler(async (req, res) => { - const phone = normalizePhone(req.body?.phone); - if (!isValidPhone(phone)) { - throw httpError(400, 'Phone must be in 947XXXXXXXX format'); - } +function hashSecret(secret) { + return crypto.createHash('sha256').update(secret).digest('hex'); +} - const user = await prisma.user.findUnique({ where: { phone } }); - if (!user) { - return res.status(404).json({ success: false, message: 'Not Registered' }); - } +function secretsMatch(storedHash, secret) { + const given = Buffer.from(hashSecret(secret)); + const stored = Buffer.from(storedHash); + return stored.length === given.length && crypto.timingSafeEqual(stored, given); +} - const otp = generateOtp(); - const otpExpiresAt = new Date(Date.now() + OTP_TTL_MS); - await prisma.user.update({ - where: { id: user.id }, - data: { currentOtp: otp, otpExpiresAt }, - }); - - try { - await sendSmsOtp(phone, otp); - } catch (error) { - await prisma.user.update({ - where: { id: user.id }, - data: { currentOtp: null, otpExpiresAt: null }, - }); - throw httpError(502, error.message || 'Failed to send OTP SMS'); - } - - return res.json({ success: true, message: 'OTP sent via SMS' }); - }) -); +async function expireStaleRequests() { + await prisma.loginRequest.updateMany({ + where: { status: 'pending', expiresAt: { lt: new Date() } }, + data: { status: 'expired' }, + }); +} router.post( - '/verify-otp', + '/staff-login', asyncHandler(async (req, res) => { const phone = normalizePhone(req.body?.phone); - const otp = String(req.body?.otp || '').trim(); - if (!isValidPhone(phone) || !/^\d{6}$/.test(otp)) { - throw httpError(400, 'Phone and a 6-digit OTP are required'); + const password = String(req.body?.password || ''); + if (!isValidPhone(phone) || !password) { + throw httpError(400, 'Phone and password are required'); } const user = await prisma.user.findUnique({ where: { phone }, - select: { - ...userPublicSelect, - currentOtp: true, - otpExpiresAt: true, + select: { ...userPublicSelect, passwordHash: true }, + }); + const allowed = user && (user.role === 'superAdmin' || user.role === 'salesRep'); + const matches = allowed && (await checkPassword(password, user.passwordHash)); + if (!matches) { + throw httpError(401, 'Invalid phone or password'); + } + + const dealers = await accessibleDealers(user); + return res.json({ token: signToken(user), user: publicUser(user, dealers) }); + }) +); + +router.post( + '/dealer-login', + asyncHandler(async (req, res) => { + const phone = normalizePhone(req.body?.phone); + if (!isValidPhone(phone)) { + throw httpError(400, 'Phone must be a Sri Lankan mobile number'); + } + + const user = await prisma.user.findUnique({ where: { phone } }); + if (!user || user.role !== 'dealer') { + return res.status(404).json({ success: false, message: 'Not Registered' }); + } + + await prisma.loginRequest.updateMany({ + where: { userId: user.id, status: 'pending' }, + data: { status: 'expired' }, + }); + + const secret = crypto.randomBytes(32).toString('hex'); + const request = await prisma.loginRequest.create({ + data: { + userId: user.id, + status: 'pending', + secretHash: hashSecret(secret), + expiresAt: new Date(Date.now() + REQUEST_TTL_MS), }, }); - if (!user || !user.currentOtp || !user.otpExpiresAt) { - throw httpError(401, 'Invalid OTP'); - } - if (user.otpExpiresAt.getTime() < Date.now()) { - throw httpError(401, 'OTP expired'); + + return res.status(201).json({ requestId: request.id, secret }); + }) +); + +router.get( + '/dealer-login/:id', + asyncHandler(async (req, res) => { + const secret = String(req.query.secret || ''); + const request = await prisma.loginRequest.findUnique({ + where: { id: req.params.id }, + include: { user: { select: userPublicSelect } }, + }); + if (!request || !secret || !secretsMatch(request.secretHash, secret)) { + throw httpError(404, 'Login request not found'); } - const stored = Buffer.from(user.currentOtp); - const given = Buffer.from(otp); - const matches = stored.length === given.length && crypto.timingSafeEqual(stored, given); - if (!matches) { - throw httpError(401, 'Invalid OTP'); + if (request.status === 'pending' && request.expiresAt.getTime() < Date.now()) { + await prisma.loginRequest.update({ + where: { id: request.id }, + data: { status: 'expired' }, + }); + return res.json({ status: 'expired' }); } - await prisma.user.update({ - where: { id: user.id }, - data: { currentOtp: null, otpExpiresAt: null }, + if (request.status !== 'approved') { + return res.json({ status: request.status }); + } + + const consumed = await prisma.loginRequest.updateMany({ + where: { id: request.id, status: 'approved' }, + data: { status: 'consumed' }, + }); + if (consumed.count !== 1) { + return res.json({ status: 'consumed' }); + } + + const dealers = await accessibleDealers(request.user); + return res.json({ + status: 'approved', + token: signToken(request.user), + user: publicUser(request.user, dealers), + }); + }) +); + +router.get( + '/login-requests', + requireAuth, + requireRole('superAdmin', 'salesRep'), + asyncHandler(async (req, res) => { + await expireStaleRequests(); + const where = { status: 'pending' }; + if (req.user.role === 'salesRep') { + const ids = assignedDealerIds(req.user); + where.user = { dealerId: { in: ids.length ? ids : ['__none__'] } }; + } + + const requests = await prisma.loginRequest.findMany({ + where, + include: { user: { include: { dealer: true } } }, + orderBy: { createdAt: 'asc' }, }); - const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { - expiresIn: '10y', + return res.json({ + success: true, + data: requests.map((request) => ({ + id: request.id, + status: request.status, + createdAt: request.createdAt, + expiresAt: request.expiresAt, + user: { + id: request.user.id, + name: request.user.name, + phone: request.user.phone, + dealerId: request.user.dealerId, + }, + dealerName: request.user.dealer ? request.user.dealer.name : null, + })), }); - const dealers = await accessibleDealers(user); - return res.json({ token, user: publicUser(user, dealers) }); + }) +); + +async function decideRequest(req, status) { + await expireStaleRequests(); + const request = await prisma.loginRequest.findUnique({ + where: { id: req.params.id }, + include: { user: true }, + }); + if (!request || request.status !== 'pending') { + throw httpError(404, 'Login request not found'); + } + if (!canAccessDealer(req.user, request.user.dealerId)) { + throw httpError(403, 'Forbidden'); + } + + await prisma.loginRequest.update({ + where: { id: request.id }, + data: { status, decidedById: req.user.id, decidedAt: new Date() }, + }); + return { id: request.id, status }; +} + +router.post( + '/login-requests/:id/approve', + requireAuth, + requireRole('superAdmin', 'salesRep'), + asyncHandler(async (req, res) => { + const result = await decideRequest(req, 'approved'); + return res.json({ success: true, data: result }); + }) +); + +router.post( + '/login-requests/:id/reject', + requireAuth, + requireRole('superAdmin', 'salesRep'), + asyncHandler(async (req, res) => { + const result = await decideRequest(req, 'rejected'); + return res.json({ success: true, data: result }); }) ); diff --git a/src/services/smsService.js b/src/services/smsService.js deleted file mode 100644 index 7a4375b..0000000 --- a/src/services/smsService.js +++ /dev/null @@ -1,33 +0,0 @@ -const axios = require('axios'); - -const sendSmsOtp = async (phoneNumber, otpCode) => { - if (!process.env.TEXTLK_API_TOKEN) { - console.log(`DEV OTP for ${phoneNumber}: ${otpCode}`); - return { dev: true }; - } - - try { - const response = await axios.post( - 'https://app.text.lk/api/v3/sms/send', - { - recipient: phoneNumber, - sender_id: process.env.TEXTLK_SENDER_ID || 'TextLKDemo', - type: 'plain', - message: `Your login OTP code is: ${otpCode}. Valid for 5 minutes.`, - }, - { - headers: { - Authorization: `Bearer ${process.env.TEXTLK_API_TOKEN}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - } - ); - return response.data; - } catch (error) { - console.error('Text.lk SMS Error:', error.response?.data || error.message); - throw new Error('Failed to send OTP SMS'); - } -}; - -module.exports = { sendSmsOtp };