SMS gone, approvals in

This commit is contained in:
2026-10-01 13:05:10 +05:30
parent 075b6b9968
commit 6937b4e114
11 changed files with 350 additions and 175 deletions
+1 -2
View File
@@ -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
+13 -38
View File
@@ -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"
+3 -4
View File
@@ -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": {
@@ -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;
+21 -7
View File
@@ -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 {
+9 -3
View File
@@ -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})`);
}
+71 -25
View File
@@ -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,
+14
View File
@@ -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 };
+7 -1
View File
@@ -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 })),
},
+177 -62
View File
@@ -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 });
})
);
-33
View File
@@ -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 };