commit 74c7d4e4292895f73338c769995bfe3c7b3c20d1
parent 5f5d54f3288654d814a8cba8e0febed458bf20bf
Author: maydayv7 <maydayv7@gmail.com>
Date: Fri, 3 Oct 2025 02:53:33 +0530
feat: Implement user authentication + backend demo
Diffstat:
27 files changed, 813 insertions(+), 664 deletions(-)
diff --git a/README.md b/README.md
@@ -1,19 +1,98 @@
# Matiru
-To run the app:
+For a detailed overview about this project, read [this](./docs/SOLUTION.md).
+The model is described [here](./docs/MODEL.md).
+
+---
+
+To run the app, follow all sections in order:
+
+### A. Backend
+
+Get the blockchain network up and running with the `chaincode`, along with our `backend` ->
+
+1. Install Chaincode dependencies:
+
+```
+cd chaincode
+npm install
+```
+
+2. Install Docker, Docker Compose and run the following:
+
+```
+git clone https://github.com/hyperledger/fabric-samples.git
+curl -sSLO https://raw.githubusercontent.com/hyperledger/fabric/main/scripts/install-fabric.sh && chmod +x install-fabric.sh
+# Install Fabric binaries and Docker images
+./install-fabric.sh
+```
+
+3. Start Fabric `test-network` and create channel:
+
+```
+cd fabric-samples/test-network
+# 'test-network' with CA enabled and channel "mychannel"
+./network.sh up createChannel -ca
+```
+
+4. Deploy Chaincode:
+
+```
+# Chaincode name "produce"
+./network.sh deployCC -ccn produce -ccp /path/to/CHAINCODE -ccl javascript
+```
+
+5. Sample Wallet Creation:
+
+```
+# From fabric-samples/test-network
+cp organizations/peerOrganizations/org1.example.com/connection-org1.json /path/to/MATIRU/backend/connection-org1.json
+cd /path/to/MATIRU/backend
+mkdir -p wallet
+node scripts/addToWallet.js org1 /path/to/fabric-samples/TEST-NETWORK/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp ./wallet USER_NAME
+```
+
+6. The `.env` file must be created in `backend` like so:
-1. Get the blockchain network up and running with the `chaincode`
-2. Start the `backend`
-3. Start the `frontend`: Install [Expo Go](https://expo.dev/go) on your phone and scan the QR Code
+```
+# Backend server
+PORT=4000
+
+# JWT
+JWT_SECRET=some_secret
+TOKEN_EXPIRES_IN=1h
+
+# Fabric Connection
+CCP_PATH=connection-org1.json
+WALLET_PATH=./wallet
+CHANNEL=mychannel
+CHAINCODE=produce
+IDENTITY=USER_NAME
+AS_LOCALHOST=true
+```
-For 2 and 3, `cd` into the respective directory, then run:
+7. Start `backend`:
```
npm install
+npm run create-users
npm start
```
----
+### B. Frontend
-For a detailed overview about this project, read [this](./docs/SOLUTION.md).
-The model is described [here](./docs/MODEL.md).
+To start the `frontend` ->
+
+```
+cd frontend
+npm install
+```
+
+Then find your computer's IP address (using `ifconfig`) and execute the following:
+
+```
+echo 'export const API_BASE = "http://IP_ADDRESS:4000/api";' >> config.js
+npm start
+```
+
+Install [Expo Go](https://expo.dev/go) on your phone and scan the QR Code to view the mobile app
diff --git a/backend/.gitignore b/backend/.gitignore
@@ -0,0 +1,3 @@
+users.json
+wallet
+connection-*
diff --git a/backend/addToWallet.js b/backend/addToWallet.js
@@ -0,0 +1,97 @@
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const { Wallets } = require("fabric-network");
+
+async function main() {
+ try {
+ const args = process.argv.slice(2);
+ if (args.length < 4) {
+ console.log(
+ "Usage: node addToWallet.js <org> <userFolder> <walletDir> <identityLabel>"
+ );
+ process.exit(1);
+ }
+ const [org, userFolder, walletDir, identityLabel] = args;
+
+ // Typical path for the identity in test-network:
+ // fabric-samples/test-network/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp
+ const userMspPath = path.resolve(userFolder);
+ if (!fs.existsSync(userMspPath)) {
+ console.error("User MSP folder not found:", userMspPath);
+ process.exit(1);
+ }
+
+ const certPath = path.join(userMspPath, "signcerts");
+ const keyPath = path.join(userMspPath, "keystore");
+
+ // Read certificate file (first file in signcerts)
+ const certFiles = fs.readdirSync(certPath);
+ if (certFiles.length === 0) {
+ console.error("No cert files found in", certPath);
+ process.exit(1);
+ }
+ const cert = fs.readFileSync(path.join(certPath, certFiles[0])).toString();
+
+ // Read private key file (first file in keystore)
+ const keyFiles = fs.readdirSync(keyPath);
+ if (keyFiles.length === 0) {
+ console.error("No key files found in", keyPath);
+ process.exit(1);
+ }
+ const key = fs.readFileSync(path.join(keyPath, keyFiles[0])).toString();
+
+ // Read MSP config (to extract MSP ID)
+ // Try parent folder path like .../User1@org1.example.com/msp/config.yaml or check for Org MSP folder
+ const parent = path.dirname(userMspPath);
+ // For org1 user path example, MSP ID usually: Org1MSP
+ // Try to get it from the folder structure: organizations/peerOrganizations/org1.example.com/msp
+ let orgMspFolder = null;
+ let node = parent;
+ while (node !== path.parse(node).root) {
+ if (fs.existsSync(path.join(node, "msp"))) {
+ orgMspFolder = path.join(node, "msp");
+ break;
+ }
+ node = path.dirname(node);
+ }
+
+ // Fallback: if not found, try to derive from org argument (org1 -> Org1MSP)
+ let mspId = null;
+ if (orgMspFolder) {
+ // derive MSP ID from folder name e.g., org1.example.com -> Org1MSP
+ const orgFolder = path.basename(path.dirname(orgMspFolder)); // org1.example.com
+ // crude mapping: take first token and uppercase first letter + "MSP"
+ const prefix = orgFolder.split(".")[0]; // org1
+ mspId = prefix.charAt(0).toUpperCase() + prefix.slice(1) + "MSP"; // Org1MSP
+ } else {
+ mspId = org.charAt(0).toUpperCase() + org.slice(1) + "MSP";
+ }
+
+ // Create wallet directory
+ const walletPath = path.resolve(walletDir);
+ const wallet = await Wallets.newFileSystemWallet(walletPath);
+
+ // Create identity object
+ const identity = {
+ credentials: {
+ certificate: cert,
+ privateKey: key,
+ },
+ mspId: mspId,
+ type: "X.509",
+ };
+
+ await wallet.put(identityLabel, identity);
+ console.log(
+ `Successfully added identity ${identityLabel} to wallet at ${walletPath}`
+ );
+ console.log(`mspId used: ${mspId}`);
+ process.exit(0);
+ } catch (err) {
+ console.error("Error importing identity to wallet:", err);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/backend/createUsers.js b/backend/createUsers.js
@@ -0,0 +1,34 @@
+const fs = require("fs");
+const path = require("path");
+const bcrypt = require("bcryptjs");
+
+const users = [
+ { id: "farmer1", username: "farmer1", password: "password", role: "Farmer" },
+ {
+ id: "distributor1",
+ username: "dist1",
+ password: "password",
+ role: "Distributor",
+ },
+ { id: "retailer1", username: "ret1", password: "password", role: "Retailer" },
+ {
+ id: "inspector1",
+ username: "insp1",
+ password: "password",
+ role: "Inspector",
+ },
+];
+
+(async () => {
+ const out = users.map((u) => ({
+ id: u.id,
+ username: u.username,
+ passwordHash: bcrypt.hashSync(u.password, 10),
+ role: u.role,
+ }));
+ const dest = path.join(__dirname, "users.json");
+ fs.writeFileSync(dest, JSON.stringify(out, null, 2));
+ console.log("users.json written to", dest);
+ console.log("Demo accounts: (username / password)");
+ out.forEach((u) => console.log(`${u.username} / password (role: ${u.role})`));
+})();
diff --git a/backend/package-lock.json b/backend/package-lock.json
@@ -8,11 +8,14 @@
"name": "matiru-backend",
"version": "1.0.0",
"dependencies": {
+ "bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
+ "cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"fabric-network": "^2.2.20",
"fs-extra": "^11.3.2",
+ "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1"
}
},
@@ -198,6 +201,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
+ "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
@@ -230,6 +242,12 @@
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
"license": "MIT"
},
+ "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",
@@ -368,6 +386,19 @@
"node": ">=6.6.0"
}
},
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/cycle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
@@ -437,6 +468,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",
@@ -986,6 +1026,28 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
"node_modules/jsrsasign": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.9.0.tgz",
@@ -995,6 +1057,27 @@
"url": "https://github.com/kjur/jsrsasign#donations"
}
},
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "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": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1007,6 +1090,48 @@
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
+ "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/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -1214,6 +1339,15 @@
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1454,6 +1588,18 @@
"integrity": "sha512-nZi59hW3Sl5P3+wOO89eHBAAGwmCPd2aE1+dLZV5MO+ItQctIvAqihzaAXIQhvtH4KJPxM080HsnqltR2y8cWg==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
diff --git a/backend/package.json b/backend/package.json
@@ -3,14 +3,18 @@
"version": "1.0.0",
"main": "server.js",
"scripts": {
- "start": "node server.js"
+ "start": "node server.js",
+ "create-users": "node createUsers.js"
},
"dependencies": {
+ "bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
+ "cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"fabric-network": "^2.2.20",
"fs-extra": "^11.3.2",
+ "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1"
}
}
diff --git a/backend/scripts/createLedgerUsers.js b/backend/scripts/createLedgerUsers.js
@@ -1,96 +0,0 @@
-"use strict";
-const fs = require("fs");
-const path = require("path");
-const { Gateway, Wallets } = require("fabric-network");
-require("dotenv").config();
-
-async function main() {
- try {
- const ccpPath = path.resolve(
- process.env.CCP_PATH || "./connection-org1.json"
- );
- const ccp = JSON.parse(fs.readFileSync(ccpPath, "utf8"));
- const walletPath = path.resolve(process.env.WALLET_PATH || "./wallet");
- const wallet = await Wallets.newFileSystemWallet(walletPath);
-
- const identity = process.env.IDENTITY || "appUser";
- const id = await wallet.get(identity);
- if (!id) {
- console.error(
- `Identity ${identity} not found in wallet. Run registerUsers.js first.`
- );
- process.exit(1);
- }
-
- const gateway = new Gateway();
- await gateway.connect(ccp, {
- wallet,
- identity,
- discovery: { enabled: true, asLocalhost: true },
- });
- const network = await gateway.getNetwork(
- process.env.CHANNEL || "mychannel"
- );
- const contract = network.getContract(process.env.CHAINCODE || "producecc");
-
- const profiles = [
- {
- role: "Farmer",
- id: "farmer1",
- name: "Farmer One",
- location: "Village A",
- },
- {
- role: "Distributor",
- id: "distributor1",
- name: "Distributor One",
- location: "Hub B",
- },
- {
- role: "Retailer",
- id: "retailer1",
- name: "Retail Shop 1",
- location: "Town C",
- },
- {
- role: "Inspector",
- id: "inspector1",
- name: "Inspector A",
- location: "District X",
- authority: "Govt",
- },
- {
- role: "Consumer",
- id: "consumer1",
- name: "Consumer A",
- location: "City Y",
- },
- ];
-
- for (const p of profiles) {
- const details = JSON.stringify({
- id: p.id,
- name: p.name,
- location: p.location,
- walletId: `${p.id}-wallet`,
- certification: p.certification || [],
- authority: p.authority || "",
- });
- console.log(`Registering user on ledger: ${p.role}-${p.id}`);
- const tx = await contract.submitTransaction(
- "registerUser",
- p.role,
- details
- );
- console.log(`Registered on ledger: ${tx.toString()}`);
- }
-
- await gateway.disconnect();
- console.log("All ledger user profiles created.");
- } catch (err) {
- console.error("Error createLedgerUsers:", err);
- process.exit(1);
- }
-}
-
-main();
diff --git a/backend/scripts/enrollAdmin.js b/backend/scripts/enrollAdmin.js
@@ -1,56 +0,0 @@
-// Run once after starting test-network
-"use strict";
-const FabricCAServices = require("fabric-ca-client");
-const { Wallets } = require("fabric-network");
-const fs = require("fs");
-const path = require("path");
-require("dotenv").config();
-
-async function main() {
- try {
- const ccpPath = path.resolve(
- process.env.CCP_PATH || "./connection-org1.json"
- );
- const ccp = JSON.parse(fs.readFileSync(ccpPath, "utf8"));
-
- // pick first CA in connection profile
- const caName = Object.keys(ccp.certificateAuthorities)[0];
- const caInfo = ccp.certificateAuthorities[caName];
-
- const ca = new FabricCAServices(
- caInfo.url,
- { trustedRoots: caInfo.tlsCACerts.pem, verify: false },
- caInfo.caName
- );
- const walletPath = path.resolve(process.env.WALLET_PATH || "./wallet");
- const wallet = await Wallets.newFileSystemWallet(walletPath);
-
- // Check if admin already enrolled
- const identity = await wallet.get("admin");
- if (identity) {
- console.log("Admin identity already exists in the wallet");
- return;
- }
-
- // Default in test-network: admin/adminpw
- const enrollmentID = "admin";
- const enrollmentSecret = "adminpw";
-
- const enrollment = await ca.enroll({ enrollmentID, enrollmentSecret });
- const x509Identity = {
- credentials: {
- certificate: enrollment.certificate,
- privateKey: enrollment.key.toBytes(),
- },
- mspId: "Org1MSP",
- type: "X.509",
- };
- await wallet.put("admin", x509Identity);
- console.log("Successfully enrolled admin and imported into wallet");
- } catch (err) {
- console.error("Error enrolling admin:", err);
- process.exit(1);
- }
-}
-
-main();
diff --git a/backend/scripts/registerUsers.js b/backend/scripts/registerUsers.js
@@ -1,117 +0,0 @@
-"use strict";
-const FabricCAServices = require("fabric-ca-client");
-const { Wallets } = require("fabric-network");
-const path = require("path");
-const fs = require("fs");
-require("dotenv").config();
-
-async function main() {
- try {
- const ccpPath = path.resolve(
- process.env.CCP_PATH || "./connection-org1.json"
- );
- const ccp = JSON.parse(fs.readFileSync(ccpPath, "utf8"));
-
- const caName = Object.keys(ccp.certificateAuthorities)[0];
- const caInfo = ccp.certificateAuthorities[caName];
- const ca = new FabricCAServices(
- caInfo.url,
- { trustedRoots: caInfo.tlsCACerts.pem, verify: false },
- caInfo.caName
- );
-
- const walletPath = path.resolve(process.env.WALLET_PATH || "./wallet");
- const wallet = await Wallets.newFileSystemWallet(walletPath);
-
- // Ensure admin is enrolled
- const adminIdentity = await wallet.get("admin");
- if (!adminIdentity) {
- console.error(
- "Admin identity not found in wallet. Run enrollAdmin.js first."
- );
- process.exit(1);
- }
-
- const provider = wallet
- .getProviderRegistry()
- .getProvider(adminIdentity.type);
- const adminUser = await provider.getUserContext(adminIdentity, "admin");
-
- // Users to create
- const users = [
- {
- id: "farmer1",
- role: "Farmer",
- attrs: [{ name: "role", value: "Farmer", ecert: true }],
- },
- {
- id: "distributor1",
- role: "Distributor",
- attrs: [{ name: "role", value: "Distributor", ecert: true }],
- },
- {
- id: "retailer1",
- role: "Retailer",
- attrs: [{ name: "role", value: "Retailer", ecert: true }],
- },
- {
- id: "inspector1",
- role: "Inspector",
- attrs: [{ name: "role", value: "Inspector", ecert: true }],
- },
- {
- id: "consumer1",
- role: "Consumer",
- attrs: [{ name: "role", value: "Consumer", ecert: true }],
- },
- {
- id: "appUser",
- role: "App",
- attrs: [{ name: "role", value: "App", ecert: true }],
- }, // backend client identity
- ];
-
- for (const u of users) {
- const exists = await wallet.get(u.id);
- if (exists) {
- console.log(`Identity ${u.id} already exists in the wallet, skipping`);
- continue;
- }
-
- // register user with CA
- const secret = await ca.register(
- {
- enrollmentID: u.id,
- role: "client",
- attrs: u.attrs,
- affiliation: "org1.department1",
- },
- adminUser
- );
-
- // enroll user
- const enrollment = await ca.enroll({
- enrollmentID: u.id,
- enrollmentSecret: secret,
- });
-
- const x509Identity = {
- credentials: {
- certificate: enrollment.certificate,
- privateKey: enrollment.key.toBytes(),
- },
- mspId: "Org1MSP",
- type: "X.509",
- };
- await wallet.put(u.id, x509Identity);
- console.log(
- `Successfully registered and enrolled user ${u.id} with role ${u.role}`
- );
- }
- } catch (err) {
- console.error("Error registerUsers:", err);
- process.exit(1);
- }
-}
-
-main();
diff --git a/backend/server.js b/backend/server.js
@@ -2,39 +2,98 @@
require("dotenv").config();
const express = require("express");
const bodyParser = require("body-parser");
+const cors = require("cors");
const morgan = require("morgan");
const fs = require("fs");
const path = require("path");
+const jwt = require("jsonwebtoken");
+const bcrypt = require("bcryptjs");
const { Gateway, Wallets } = require("fabric-network");
const app = express();
+app.use(cors());
app.use(bodyParser.json());
app.use(morgan("dev"));
-const ccpPath = path.resolve(process.env.CCP_PATH);
-const walletPath = path.resolve(process.env.WALLET_PATH);
+const CCP_PATH = process.env.CCP_PATH;
+const WALLET_PATH = process.env.WALLET_PATH;
const CHANNEL = process.env.CHANNEL;
const CHAINCODE = process.env.CHAINCODE;
const IDENTITY = process.env.IDENTITY;
+const AS_LOCALHOST = process.env.AS_LOCALHOST === "true";
+const JWT_SECRET = process.env.JWT_SECRET;
async function getContract() {
- const ccp = JSON.parse(fs.readFileSync(ccpPath, "utf8"));
- const wallet = await Wallets.newFileSystemWallet(walletPath);
+ if (!CCP_PATH || !WALLET_PATH || !CHANNEL || !CHAINCODE || !IDENTITY) {
+ throw new Error(
+ "Missing Fabric environment variables (CCP_PATH/WALLET_PATH/CHANNEL/CHAINCODE/IDENTITY)"
+ );
+ }
+ const ccp = JSON.parse(fs.readFileSync(path.resolve(CCP_PATH), "utf8"));
+ const wallet = await Wallets.newFileSystemWallet(path.resolve(WALLET_PATH));
const gateway = new Gateway();
await gateway.connect(ccp, {
wallet,
identity: IDENTITY,
- discovery: {
- enabled: true,
- asLocalhost: process.env.AS_LOCALHOST === "true",
- },
+ discovery: { enabled: true, asLocalhost: AS_LOCALHOST },
});
const network = await gateway.getNetwork(CHANNEL);
const contract = network.getContract(CHAINCODE);
return { contract, gateway };
}
-app.post("/api/registerProduce", async (req, res) => {
+/**
+ * Auth Route (prototype)
+ * Uses backend/users.json for demo authentication
+ */
+app.post("/api/auth/login", async (req, res) => {
+ try {
+ const { username, password } = req.body || {};
+ if (!username || !password)
+ return res.status(400).json({ error: "username and password required" });
+ const usersPath = path.join(__dirname, "users.json");
+ if (!fs.existsSync(usersPath))
+ return res
+ .status(500)
+ .json({ error: "users.json missing -> run 'npm run create-users'" });
+ const users = JSON.parse(fs.readFileSync(usersPath, "utf8"));
+ const user = users.find((u) => u.username === username);
+ if (!user) return res.status(401).json({ error: "invalid credentials" });
+ const ok = await bcrypt.compare(password, user.passwordHash);
+ if (!ok) return res.status(401).json({ error: "invalid credentials" });
+ const token = jwt.sign(
+ { id: user.id, role: user.role, username: user.username },
+ JWT_SECRET,
+ { expiresIn: process.env.TOKEN_EXPIRES_IN || "1h" }
+ );
+ res.json({ token, id: user.id, role: user.role, username: user.username });
+ } catch (err) {
+ console.error("auth error", err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Middleware
+function authenticateMiddleware(req, res, next) {
+ if (req.path === "/auth/login") return next();
+ const header = req.headers["authorization"];
+ if (!header)
+ return res.status(401).json({ error: "missing authorization header" });
+ const token = header.split(" ")[1];
+ if (!token) return res.status(401).json({ error: "missing token" });
+ jwt.verify(token, JWT_SECRET, (err, payload) => {
+ if (err) return res.status(403).json({ error: "invalid token" });
+ req.user = payload; // { id, role, username }
+ next();
+ });
+}
+
+app.use("/api", authenticateMiddleware);
+
+// Functions
+const router = express.Router();
+
+router.post("/registerProduce", async (req, res) => {
try {
const { farmerId, details } = req.body;
const { contract, gateway } = await getContract();
@@ -51,7 +110,7 @@ app.post("/api/registerProduce", async (req, res) => {
}
});
-app.post("/api/updateLocation", async (req, res) => {
+router.post("/updateLocation", async (req, res) => {
try {
const { produceId, actorId, newLocation } = req.body;
const { contract, gateway } = await getContract();
@@ -69,7 +128,7 @@ app.post("/api/updateLocation", async (req, res) => {
}
});
-app.post("/api/inspectProduce", async (req, res) => {
+router.post("/inspectProduce", async (req, res) => {
try {
const { produceId, inspectorId, qualityUpdate } = req.body;
const { contract, gateway } = await getContract();
@@ -87,7 +146,7 @@ app.post("/api/inspectProduce", async (req, res) => {
}
});
-app.post("/api/transferOwnership", async (req, res) => {
+router.post("/transferOwnership", async (req, res) => {
try {
const { produceId, newOwnerId, qty, salePrice } = req.body;
const { contract, gateway } = await getContract();
@@ -106,7 +165,7 @@ app.post("/api/transferOwnership", async (req, res) => {
}
});
-app.post("/api/updateDetails", async (req, res) => {
+router.post("/updateDetails", async (req, res) => {
try {
const { produceId, actorId, details } = req.body;
const { contract, gateway } = await getContract();
@@ -124,7 +183,7 @@ app.post("/api/updateDetails", async (req, res) => {
}
});
-app.post("/api/markAsUnavailable", async (req, res) => {
+router.post("/markAsUnavailable", async (req, res) => {
try {
const { produceId, actorId, reason, newStatus } = req.body;
const { contract, gateway } = await getContract();
@@ -143,7 +202,7 @@ app.post("/api/markAsUnavailable", async (req, res) => {
}
});
-app.post("/api/splitProduce", async (req, res) => {
+router.post("/splitProduce", async (req, res) => {
try {
const { produceId, qty, ownerId } = req.body;
const { contract, gateway } = await getContract();
@@ -161,7 +220,7 @@ app.post("/api/splitProduce", async (req, res) => {
}
});
-app.post("/api/recordPayment", async (req, res) => {
+router.post("/recordPayment", async (req, res) => {
try {
const {
produceId,
@@ -187,7 +246,7 @@ app.post("/api/recordPayment", async (req, res) => {
}
});
-app.get("/api/getProduce/:id", async (req, res) => {
+router.get("/getProduce/:id", async (req, res) => {
try {
const { contract, gateway } = await getContract();
const result = await contract.evaluateTransaction(
@@ -202,7 +261,7 @@ app.get("/api/getProduce/:id", async (req, res) => {
}
});
-app.get("/api/getOwner/:ownerId", async (req, res) => {
+router.get("/getProduceByOwner/:ownerId", async (req, res) => {
try {
const { contract, gateway } = await getContract();
const result = await contract.evaluateTransaction(
@@ -217,7 +276,7 @@ app.get("/api/getOwner/:ownerId", async (req, res) => {
}
});
-app.post("/api/registerUser", async (req, res) => {
+router.post("/registerUser", async (req, res) => {
try {
const { role, details } = req.body;
const { contract, gateway } = await getContract();
@@ -234,7 +293,7 @@ app.post("/api/registerUser", async (req, res) => {
}
});
-app.get("/api/getUser/:userKey", async (req, res) => {
+router.get("/getUser/:userKey", async (req, res) => {
try {
const { contract, gateway } = await getContract();
const result = await contract.evaluateTransaction(
@@ -249,5 +308,7 @@ app.get("/api/getUser/:userKey", async (req, res) => {
}
});
+app.use("/api", router);
+
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Backend server listening on ${PORT}`));
diff --git a/chaincode/produce-contract/collections_config.json b/chaincode/collections_config.json
diff --git a/chaincode/produce-contract/index.js b/chaincode/index.js
diff --git a/chaincode/produce-contract/package-lock.json b/chaincode/package-lock.json
diff --git a/chaincode/produce-contract/package.json b/chaincode/package.json
diff --git a/frontend/.gitignore b/frontend/.gitignore
@@ -0,0 +1 @@
+config.js
diff --git a/frontend/App.js b/frontend/App.js
@@ -1,17 +1,9 @@
-import {
- ImageBackground,
- ScrollView,
- StatusBar,
- Text,
- TouchableOpacity,
- View,
-} from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
-import { SafeAreaView } from "react-native-safe-area-context";
-import { MaterialCommunityIcons } from "@expo/vector-icons";
-import styles, { colors } from "./styles";
+import { AuthProvider } from "./AuthContext";
+
+import HomeScreen from "./screens/Home";
import LoginScreen from "./screens/Login";
import FarmerScreen from "./screens/Farmer";
import DistributorScreen from "./screens/Distributor";
@@ -21,65 +13,15 @@ import SearchScreen from "./screens/Search";
const Stack = createStackNavigator();
-function HomeScreen({ navigation }) {
+export default function AppWrapper() {
return (
- <ImageBackground
- source={require("./assets/background.png")}
- style={styles.bg}
- >
- <StatusBar
- translucent={false}
- backgroundColor={colors.darkGreen}
- barStyle="light-content"
- />
-
- <SafeAreaView style={{ flex: 1 }}>
- <ScrollView contentContainerStyle={styles.overlay}>
- <View style={styles.topContent}>
- <Text style={styles.welcome}>Welcome to Matiru!</Text>
-
- <TouchableOpacity
- style={[
- styles.bigButton,
- { backgroundColor: "#444", marginBottom: 30 },
- ]}
- onPress={() =>
- navigation.navigate("Search", { fromRole: "Consumer" })
- }
- >
- <MaterialCommunityIcons name="magnify" size={24} color="white" />
- <Text style={styles.bigButtonText}>Global Search</Text>
- </TouchableOpacity>
-
- <Text style={styles.subtitle}> Continue As </Text>
-
- {[
- { role: "Farmer", icon: "tractor" },
- { role: "Distributor", icon: "truck" },
- { role: "Retailer", icon: "store" },
- { role: "Inspector", icon: "shield-check" },
- ].map((r) => (
- <TouchableOpacity
- key={r.role}
- style={styles.bigButton}
- onPress={() => navigation.navigate("Login", { role: r.role })}
- >
- <MaterialCommunityIcons name={r.icon} size={24} color="white" />
- <Text style={styles.bigButtonText}>{r.role}</Text>
- </TouchableOpacity>
- ))}
- </View>
-
- <View style={styles.bottomContent}>
- <Text style={styles.brand}>Matiru.</Text>
- </View>
- </ScrollView>
- </SafeAreaView>
- </ImageBackground>
+ <AuthProvider>
+ <App />
+ </AuthProvider>
);
}
-export default function App() {
+function App() {
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
diff --git a/frontend/AuthContext.js b/frontend/AuthContext.js
@@ -0,0 +1,37 @@
+import { createContext, useEffect, useState } from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+export const AuthContext = createContext();
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ const raw = await AsyncStorage.getItem("matiru_session");
+ if (raw) {
+ try {
+ setUser(JSON.parse(raw));
+ } catch (e) {
+ console.log(e);
+ }
+ }
+ })();
+ }, []);
+
+ const login = async (session) => {
+ setUser(session);
+ await AsyncStorage.setItem("matiru_session", JSON.stringify(session));
+ };
+
+ const logout = async () => {
+ setUser(null);
+ await AsyncStorage.removeItem("matiru_session");
+ };
+
+ return (
+ <AuthContext.Provider value={{ user, login, logout }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
diff --git a/frontend/config.js b/frontend/config.js
@@ -1 +0,0 @@
-export const API_BASE = "http://localhost:4000/api";
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -3013,6 +3014,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.4",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.4.tgz",
@@ -6256,6 +6269,15 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -7028,6 +7050,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
@@ -8,6 +8,7 @@
"dependencies": {
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
diff --git a/frontend/screens/Distributor.js b/frontend/screens/Distributor.js
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useContext, useState } from "react";
import {
Alert,
ScrollView,
@@ -14,9 +14,12 @@ import Scanner from "../components/Scanner";
import LocationPicker from "../components/LocationPicker";
import { API_BASE } from "../config";
import styles from "../styles";
+import { AuthContext } from "../AuthContext";
export default function DistributorScreen({ navigation, route }) {
- const { userId } = route.params;
+ const { user } = useContext(AuthContext);
+ const userId = route.params?.userId || user?.id;
+ const token = user?.token;
const [active, setActive] = useState(null);
// Common
@@ -39,7 +42,10 @@ export default function DistributorScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/updateLocation`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
@@ -58,7 +64,10 @@ export default function DistributorScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/transferOwnership`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
newOwnerId,
@@ -78,13 +87,11 @@ export default function DistributorScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/markAsUnavailable`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- produceId,
- actorId: userId,
- reason,
- newStatus,
- }),
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ produceId, actorId: userId, reason, newStatus }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
@@ -96,13 +103,16 @@ export default function DistributorScreen({ navigation, route }) {
const updateStorageConditions = async () => {
try {
- const res = await fetch(`${API_BASE}/updateStorageConditions`, {
+ const res = await fetch(`${API_BASE}/updateDetails`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
- storageConditions: storageConditions.split(","),
+ details: { storageConditions: storageConditions.split(",") },
}),
});
const data = await res.json();
diff --git a/frontend/screens/Farmer.js b/frontend/screens/Farmer.js
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useContext, useState } from "react";
import {
Alert,
ScrollView,
@@ -14,9 +14,13 @@ import Scanner from "../components/Scanner";
import LocationPicker from "../components/LocationPicker";
import { API_BASE } from "../config";
import styles from "../styles";
+import { AuthContext } from "../AuthContext";
export default function FarmerScreen({ navigation, route }) {
- const { userId } = route.params;
+ const { user } = useContext(AuthContext);
+ const userId = route.params?.userId || user?.id;
+ const token = user?.token;
+
const [active, setActive] = useState(null);
// Common
@@ -40,7 +44,10 @@ export default function FarmerScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/registerProduce`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
farmerId: userId,
details: {
@@ -51,8 +58,11 @@ export default function FarmerScreen({ navigation, route }) {
harvestDate,
quality,
expiryDate,
- storageConditions: storageConditions.split(","),
+ storageConditions: storageConditions
+ ? storageConditions.split(",")
+ : [],
location,
+ farmerName: user?.username || "",
},
}),
});
@@ -68,13 +78,18 @@ export default function FarmerScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/updateDetails`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
details: {
pricePerUnit: parseFloat(pricePerUnit),
- storageConditions: storageConditions.split(","),
+ storageConditions: storageConditions
+ ? storageConditions.split(",")
+ : [],
},
}),
});
@@ -90,7 +105,10 @@ export default function FarmerScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/splitProduce`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
qty: parseFloat(splitQty),
@@ -99,7 +117,7 @@ export default function FarmerScreen({ navigation, route }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Split", JSON.stringify(data.result, null, 2));
+ Alert.alert("Split", JSON.stringify(data.split, null, 2));
} catch (err) {
Alert.alert("Error", err.message);
}
@@ -109,7 +127,10 @@ export default function FarmerScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/updateLocation`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
diff --git a/frontend/screens/Home.js b/frontend/screens/Home.js
@@ -0,0 +1,93 @@
+// screens/Home.js
+import React from "react";
+import {
+ ImageBackground,
+ ScrollView,
+ Text,
+ TouchableOpacity,
+ StatusBar,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import styles, { colors } from "../styles";
+
+export default function HomeScreen({ navigation }) {
+ return (
+ <ImageBackground
+ source={require("../assets/background.png")}
+ style={styles.bg}
+ >
+ <StatusBar
+ translucent={false}
+ backgroundColor={colors.darkGreen}
+ barStyle="light-content"
+ />
+
+ <SafeAreaView style={{ flex: 1 }}>
+ <ScrollView contentContainerStyle={styles.overlay}>
+ <View style={styles.topContent}>
+ <Text style={styles.welcome}>Welcome to Matiru!</Text>
+
+ <TouchableOpacity
+ style={[
+ styles.bigButton,
+ { backgroundColor: "#444", marginBottom: 30 },
+ ]}
+ onPress={() => navigation.navigate("Search")}
+ >
+ <MaterialCommunityIcons name="magnify" size={24} color="white" />
+ <Text style={styles.bigButtonText}>Global Search</Text>
+ </TouchableOpacity>
+
+ <Text style={styles.subtitle}> Continue As </Text>
+
+ <TouchableOpacity
+ style={styles.bigButton}
+ onPress={() => navigation.navigate("Login", { role: "Farmer" })}
+ >
+ <MaterialCommunityIcons name="tractor" size={24} color="white" />
+ <Text style={styles.bigButtonText}>Farmer</Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={styles.bigButton}
+ onPress={() =>
+ navigation.navigate("Login", { role: "Distributor" })
+ }
+ >
+ <MaterialCommunityIcons name="truck" size={24} color="white" />
+ <Text style={styles.bigButtonText}>Distributor</Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={styles.bigButton}
+ onPress={() => navigation.navigate("Login", { role: "Retailer" })}
+ >
+ <MaterialCommunityIcons name="store" size={24} color="white" />
+ <Text style={styles.bigButtonText}>Retailer</Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={styles.bigButton}
+ onPress={() =>
+ navigation.navigate("Login", { role: "Inspector" })
+ }
+ >
+ <MaterialCommunityIcons
+ name="shield-check"
+ size={24}
+ color="white"
+ />
+ <Text style={styles.bigButtonText}>Inspector</Text>
+ </TouchableOpacity>
+ </View>
+
+ <View style={styles.bottomContent}>
+ <Text style={styles.brand}>Matiru.</Text>
+ </View>
+ </ScrollView>
+ </SafeAreaView>
+ </ImageBackground>
+ );
+}
diff --git a/frontend/screens/Inspector.js b/frontend/screens/Inspector.js
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useContext, useState } from "react";
import {
Alert,
ScrollView,
@@ -11,88 +11,37 @@ import { SafeAreaView } from "react-native-safe-area-context";
import ScreenHeader from "../components/ScreenHeader";
import ActionButton from "../components/ActionButton";
import Scanner from "../components/Scanner";
-import LocationPicker from "../components/LocationPicker";
import { API_BASE } from "../config";
import styles from "../styles";
+import { AuthContext } from "../AuthContext";
export default function InspectorScreen({ navigation, route }) {
- const { userId } = route.params;
+ const { user } = useContext(AuthContext);
+ const userId = route.params?.userId || user?.id;
+ const token = user?.token;
const [active, setActive] = useState(null);
- // Common
const [produceId, setProduceId] = useState("");
- const [location, setLocation] = useState("");
-
- // inspectProduce
const [quality, setQuality] = useState("");
const [expiryDate, setExpiryDate] = useState("");
- const [storageConditions, setStorageConditions] = useState("");
- const [failed, setFailed] = useState(false);
- const [reason, setReason] = useState("");
-
- // markAsUnavailable
- const [unavailReason, setUnavailReason] = useState("");
- const [newStatus, setNewStatus] = useState("");
const inspectProduce = async () => {
try {
const res = await fetch(`${API_BASE}/inspectProduce`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
inspectorId: userId,
- qualityUpdate: {
- quality,
- expiryDate,
- storageConditions: storageConditions.split(","),
- failed,
- reason,
- },
+ qualityUpdate: { quality, expiryDate },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Inspection Complete", JSON.stringify(data.produce, null, 2));
- } catch (err) {
- Alert.alert("Error", err.message);
- }
- };
-
- const updateLocation = async () => {
- try {
- const res = await fetch(`${API_BASE}/updateLocation`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- produceId,
- actorId: userId,
- newLocation: location,
- }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Location Updated", JSON.stringify(data.produce, null, 2));
- } catch (err) {
- Alert.alert("Error", err.message);
- }
- };
-
- const markAsUnavailable = async () => {
- try {
- const res = await fetch(`${API_BASE}/markAsUnavailable`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- produceId,
- actorId: userId,
- reason: unavailReason,
- newStatus,
- }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Marked Unavailable", JSON.stringify(data.produce, null, 2));
+ Alert.alert("Inspected", JSON.stringify(data.produce, null, 2));
} catch (err) {
Alert.alert("Error", err.message);
}
@@ -108,20 +57,10 @@ export default function InspectorScreen({ navigation, route }) {
<ScrollView>
<View style={styles.actionGrid}>
<ActionButton
- icon="check-decagram"
+ icon="shield-check"
text="Inspect Produce"
onPress={() => setActive("inspect")}
/>
- <ActionButton
- icon="map-marker"
- text="Update Location"
- onPress={() => setActive("location")}
- />
- <ActionButton
- icon="cancel"
- text="Mark Unavailable"
- onPress={() => setActive("remove")}
- />
</View>
{active === "inspect" && (
@@ -129,7 +68,7 @@ export default function InspectorScreen({ navigation, route }) {
<Scanner value={produceId} onChange={setProduceId} />
<TextInput
style={styles.input}
- placeholder="Quality (e.g., Grade A)"
+ placeholder="Quality"
value={quality}
onChangeText={setQuality}
/>
@@ -139,33 +78,6 @@ export default function InspectorScreen({ navigation, route }) {
value={expiryDate}
onChangeText={setExpiryDate}
/>
- <TextInput
- style={styles.input}
- placeholder="Storage Conditions (comma separated)"
- value={storageConditions}
- onChangeText={setStorageConditions}
- />
- <TextInput
- style={styles.input}
- placeholder="Reason (if failed)"
- value={reason}
- onChangeText={setReason}
- />
- <TouchableOpacity
- style={[
- styles.primaryButton,
- {
- backgroundColor: failed
- ? "red"
- : styles.primaryButton.backgroundColor,
- },
- ]}
- onPress={() => setFailed(!failed)}
- >
- <Text style={styles.buttonText}>
- {failed ? "Mark as Passed" : "Mark as Failed"}
- </Text>
- </TouchableOpacity>
<TouchableOpacity
style={styles.primaryButton}
onPress={inspectProduce}
@@ -174,43 +86,6 @@ export default function InspectorScreen({ navigation, route }) {
</TouchableOpacity>
</View>
)}
-
- {active === "location" && (
- <View>
- <Scanner value={produceId} onChange={setProduceId} />
- <LocationPicker value={location} onChange={setLocation} />
- <TouchableOpacity
- style={styles.primaryButton}
- onPress={updateLocation}
- >
- <Text style={styles.buttonText}>Update Location</Text>
- </TouchableOpacity>
- </View>
- )}
-
- {active === "remove" && (
- <View>
- <Scanner value={produceId} onChange={setProduceId} />
- <TextInput
- style={styles.input}
- placeholder="Reason"
- value={unavailReason}
- onChangeText={setUnavailReason}
- />
- <TextInput
- style={styles.input}
- placeholder="New Status (Failed Inspection, Removed...)"
- value={newStatus}
- onChangeText={setNewStatus}
- />
- <TouchableOpacity
- style={styles.primaryButton}
- onPress={markAsUnavailable}
- >
- <Text style={styles.buttonText}>Mark as Unavailable</Text>
- </TouchableOpacity>
- </View>
- )}
</ScrollView>
</SafeAreaView>
);
diff --git a/frontend/screens/Login.js b/frontend/screens/Login.js
@@ -1,20 +1,49 @@
-import { useState } from "react";
+import { useState, useContext } from "react";
import { Text, TextInput, TouchableOpacity, Alert } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import styles, { colors } from "../styles";
+import { AuthContext } from "../AuthContext";
+import { API_BASE } from "../config";
export default function LoginScreen({ route, navigation }) {
- const { role } = route.params;
+ const { role } = route.params || {};
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
+ const { login } = useContext(AuthContext);
- const handleLogin = () => {
+ const handleLogin = async () => {
if (!username || !password) {
Alert.alert("Error", "Enter username and password");
return;
}
- navigation.replace(role, { userId: `${role.toLowerCase()}1` });
+ try {
+ const res = await fetch(`${API_BASE}/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `Server ${res.status}`);
+ }
+ const data = await res.json(); // { token, id, role, username }
+ if (role && data.role !== role) {
+ return Alert.alert(
+ "Error",
+ `Account role mismatch. Expected ${role}, got ${data.role}`
+ );
+ }
+ await login({
+ token: data.token,
+ id: data.id,
+ role: data.role,
+ username: data.username,
+ });
+ navigation.replace(data.role, { userId: data.id });
+ } catch (err) {
+ Alert.alert("Login failed", err.message);
+ }
};
return (
@@ -26,6 +55,7 @@ export default function LoginScreen({ route, navigation }) {
placeholder="Username"
value={username}
onChangeText={setUsername}
+ autoCapitalize="none"
/>
<TextInput
style={styles.input}
diff --git a/frontend/screens/Retailer.js b/frontend/screens/Retailer.js
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useContext, useState } from "react";
import {
Alert,
ScrollView,
@@ -14,29 +14,31 @@ import Scanner from "../components/Scanner";
import LocationPicker from "../components/LocationPicker";
import { API_BASE } from "../config";
import styles from "../styles";
+import { AuthContext } from "../AuthContext";
export default function RetailerScreen({ navigation, route }) {
- const { userId } = route.params;
+ const { user } = useContext(AuthContext);
+ const userId = route.params?.userId || user?.id;
+ const token = user?.token;
const [active, setActive] = useState(null);
- // Common
const [produceId, setProduceId] = useState("");
const [location, setLocation] = useState("");
+ const [pricePerUnit, setPricePerUnit] = useState("");
+ const [storageConditions, setStorageConditions] = useState("");
- // transferOwnership
const [newOwnerId, setNewOwnerId] = useState("");
const [qty, setQty] = useState("");
const [salePrice, setSalePrice] = useState("");
- // markAsUnavailable
- const [reason, setReason] = useState("");
- const [newStatus, setNewStatus] = useState("");
-
const updateLocation = async () => {
try {
const res = await fetch(`${API_BASE}/updateLocation`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
@@ -55,7 +57,10 @@ export default function RetailerScreen({ navigation, route }) {
try {
const res = await fetch(`${API_BASE}/transferOwnership`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
newOwnerId,
@@ -65,27 +70,32 @@ export default function RetailerScreen({ navigation, route }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Sale Recorded", JSON.stringify(data.result, null, 2));
+ Alert.alert("Transferred", JSON.stringify(data.result, null, 2));
} catch (err) {
Alert.alert("Error", err.message);
}
};
- const markAsUnavailable = async () => {
+ const updateDetails = async () => {
try {
- const res = await fetch(`${API_BASE}/markAsUnavailable`, {
+ const res = await fetch(`${API_BASE}/updateDetails`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
body: JSON.stringify({
produceId,
actorId: userId,
- reason,
- newStatus,
+ details: {
+ pricePerUnit: parseFloat(pricePerUnit),
+ storageConditions: storageConditions.split(","),
+ },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Marked Unavailable", JSON.stringify(data.produce, null, 2));
+ Alert.alert("Updated Details", JSON.stringify(data.produce, null, 2));
} catch (err) {
Alert.alert("Error", err.message);
}
@@ -106,14 +116,14 @@ export default function RetailerScreen({ navigation, route }) {
onPress={() => setActive("location")}
/>
<ActionButton
- icon="cash-register"
- text="Record Sale"
+ icon="cash"
+ text="Sell / Transfer"
onPress={() => setActive("transfer")}
/>
<ActionButton
- icon="cancel"
- text="Mark Unavailable"
- onPress={() => setActive("remove")}
+ icon="update"
+ text="Update Details"
+ onPress={() => setActive("update")}
/>
</View>
@@ -135,13 +145,13 @@ export default function RetailerScreen({ navigation, route }) {
<Scanner value={produceId} onChange={setProduceId} />
<TextInput
style={styles.input}
- placeholder="Customer/User ID"
+ placeholder="New Owner ID"
value={newOwnerId}
onChangeText={setNewOwnerId}
/>
<TextInput
style={styles.input}
- placeholder="Quantity Sold"
+ placeholder="Quantity"
keyboardType="numeric"
value={qty}
onChangeText={setQty}
@@ -157,31 +167,32 @@ export default function RetailerScreen({ navigation, route }) {
style={styles.primaryButton}
onPress={transferOwnership}
>
- <Text style={styles.buttonText}>Record Sale</Text>
+ <Text style={styles.buttonText}>Confirm Sale</Text>
</TouchableOpacity>
</View>
)}
- {active === "remove" && (
+ {active === "update" && (
<View>
<Scanner value={produceId} onChange={setProduceId} />
<TextInput
style={styles.input}
- placeholder="Reason"
- value={reason}
- onChangeText={setReason}
+ placeholder="New Price Per Unit"
+ keyboardType="numeric"
+ value={pricePerUnit}
+ onChangeText={setPricePerUnit}
/>
<TextInput
style={styles.input}
- placeholder="New Status (Expired, Spoiled...)"
- value={newStatus}
- onChangeText={setNewStatus}
+ placeholder="Storage Conditions (comma separated)"
+ value={storageConditions}
+ onChangeText={setStorageConditions}
/>
<TouchableOpacity
style={styles.primaryButton}
- onPress={markAsUnavailable}
+ onPress={updateDetails}
>
- <Text style={styles.buttonText}>Mark as Unavailable</Text>
+ <Text style={styles.buttonText}>Update</Text>
</TouchableOpacity>
</View>
)}
diff --git a/frontend/screens/Search.js b/frontend/screens/Search.js
@@ -14,37 +14,23 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
import ScreenHeader from "../components/ScreenHeader";
-import Scanner from "../components/Scanner";
import { API_BASE } from "../config";
-import { MaterialCommunityIcons } from "@expo/vector-icons";
import styles, { colors } from "../styles";
-const tabs = [
- { key: "produce", label: "Produce", icon: "leaf" },
- { key: "owner", label: "Owner", icon: "account" },
- { key: "user", label: "User", icon: "account-badge" },
-];
-
export default function SearchScreen({ navigation }) {
- const [activeTab, setActiveTab] = useState("produce");
- const [produceId, setProduceId] = useState("");
- const [ownerId, setOwnerId] = useState("");
- const [userKey, setUserKey] = useState("");
+ const [tab, setTab] = useState("Produce");
+ const [input, setInput] = useState("");
const [result, setResult] = useState(null);
- const fetchResult = async (type, id) => {
+ const fetchResult = async () => {
try {
- if (!id) {
- Alert.alert("Error", "Please enter an ID");
- return;
- }
- const url =
- type === "produce"
- ? `${API_BASE}/getProduce/${id}`
- : type === "owner"
- ? `${API_BASE}/getProduceByOwner/${id}`
- : `${API_BASE}/getUser/${id}`;
+ let url = "";
+ if (tab === "Produce") url = `${API_BASE}/getProduce/${input}`;
+ if (tab === "Owner") url = `${API_BASE}/getProduceByOwner/${input}`;
+ if (tab === "User") url = `${API_BASE}/getUser/${input}`;
+
const res = await fetch(url);
if (!res.ok) throw new Error(`Server ${res.status}`);
const data = await res.json();
@@ -59,116 +45,70 @@ export default function SearchScreen({ navigation }) {
<ScreenHeader
title="Global Search"
navigation={navigation}
- role="Search"
- showBack={true}
hideSearchButton={true}
/>
- <Text
- style={{
- marginVertical: 12,
- color: colors.darkGreen,
- textAlign: "center",
- }}
- >
- To view information, select a tab and enter the ID
+ <Text style={{ marginBottom: 12, color: colors.darkGreen }}>
+ Search by Produce ID, Owner ID, or User Key
</Text>
- <View
- style={{
- flexDirection: "row",
- justifyContent: "space-around",
- marginBottom: 16,
- }}
- >
- {tabs.map((t) => (
+ <View style={{ flexDirection: "row", justifyContent: "space-around" }}>
+ {["Produce", "Owner", "User"].map((t) => (
<TouchableOpacity
- key={t.key}
+ key={t}
style={{
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 12,
- backgroundColor:
- activeTab === t.key ? colors.darkGreen : colors.lightGreen,
+ backgroundColor: tab === t ? colors.darkGreen : colors.lightGreen,
}}
onPress={() => {
- setActiveTab(t.key);
+ setTab(t);
setResult(null);
+ setInput("");
}}
>
<MaterialCommunityIcons
- name={t.icon}
+ name={
+ t === "Produce"
+ ? "leaf"
+ : t === "Owner"
+ ? "account"
+ : "account-badge"
+ }
size={20}
- color={activeTab === t.key ? "white" : colors.darkGreen}
+ color={tab === t ? "white" : colors.darkGreen}
/>
<Text
style={{
- color: activeTab === t.key ? "white" : colors.darkGreen,
+ color: tab === t ? "white" : colors.darkGreen,
marginLeft: 6,
fontWeight: "600",
}}
>
- {t.label}
+ {t}
</Text>
</TouchableOpacity>
))}
</View>
- <ScrollView>
- {activeTab === "produce" ? (
- <Scanner value={produceId} onChange={setProduceId} />
- ) : activeTab === "owner" ? (
- <TextInput
- style={styles.input}
- placeholder="Enter Owner ID"
- value={ownerId}
- onChangeText={setOwnerId}
- />
- ) : (
- <TextInput
- style={styles.input}
- placeholder="Enter User Key"
- value={userKey}
- onChangeText={setUserKey}
- />
- )}
-
- <TouchableOpacity
- style={styles.primaryButton}
- onPress={() =>
- fetchResult(
- activeTab,
- activeTab === "produce"
- ? produceId
- : activeTab === "owner"
- ? ownerId
- : userKey
- )
- }
- >
- <Text style={styles.buttonText}>
- Search {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
- </Text>
- </TouchableOpacity>
+ <TextInput
+ style={[styles.input, { marginTop: 20 }]}
+ placeholder={`Enter ${tab} ID`}
+ value={input}
+ onChangeText={setInput}
+ />
+ <TouchableOpacity style={styles.primaryButton} onPress={fetchResult}>
+ <Text style={styles.buttonText}>Search</Text>
+ </TouchableOpacity>
+ <ScrollView style={{ marginTop: 20 }}>
{result && (
- <View
- style={{
- marginTop: 20,
- padding: 10,
- backgroundColor: "white",
- borderRadius: 10,
- }}
- >
- <Text style={styles.subtitle}>Search Results:</Text>
- <ScrollView horizontal>
- <Text style={{ fontSize: 12 }}>
- {JSON.stringify(result, null, 2)}
- </Text>
- </ScrollView>
- </View>
+ <Text style={{ color: colors.darkGreen }}>
+ {JSON.stringify(result, null, 2)}
+ </Text>
)}
</ScrollView>
</SafeAreaView>