matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce
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:
MREADME.md | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Abackend/.gitignore | 3+++
Abackend/addToWallet.js | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/createUsers.js | 34++++++++++++++++++++++++++++++++++
Mbackend/package-lock.json | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/package.json | 6+++++-
Dbackend/scripts/createLedgerUsers.js | 96-------------------------------------------------------------------------------
Dbackend/scripts/enrollAdmin.js | 56--------------------------------------------------------
Dbackend/scripts/registerUsers.js | 117-------------------------------------------------------------------------------
Mbackend/server.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Rchaincode/produce-contract/collections_config.json -> chaincode/collections_config.json | 0
Rchaincode/produce-contract/index.js -> chaincode/index.js | 0
Rchaincode/produce-contract/package-lock.json -> chaincode/package-lock.json | 0
Rchaincode/produce-contract/package.json -> chaincode/package.json | 0
Afrontend/.gitignore | 1+
Mfrontend/App.js | 74++++++++------------------------------------------------------------------
Afrontend/AuthContext.js | 37+++++++++++++++++++++++++++++++++++++
Dfrontend/config.js | 1-
Mfrontend/package-lock.json | 34++++++++++++++++++++++++++++++++++
Mfrontend/package.json | 1+
Mfrontend/screens/Distributor.js | 38++++++++++++++++++++++++--------------
Mfrontend/screens/Farmer.js | 39++++++++++++++++++++++++++++++---------
Afrontend/screens/Home.js | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/screens/Inspector.js | 151+++++++------------------------------------------------------------------------
Mfrontend/screens/Login.js | 38++++++++++++++++++++++++++++++++++----
Mfrontend/screens/Retailer.js | 79+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mfrontend/screens/Search.js | 140+++++++++++++++++++++++--------------------------------------------------------
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>