Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b941865ae | |||
| a3c565b6ee | |||
| ea677286f0 | |||
| 8317118348 | |||
| 0e87f5a26a | |||
| 60c2af2fde | |||
| 2e91841329 | |||
| 1fee676176 | |||
| 50b6cf93a1 | |||
| 01c25e7fc8 | |||
| 7daaf51e64 | |||
| c0d950b439 | |||
| 3c5766533e | |||
| 7596b11966 | |||
| 0efa1bfa38 | |||
| 2ba44f15ee | |||
| f6caf39b5a | |||
| 009fafa6fa | |||
| fe84ae5c66 | |||
| 70ae7ee939 | |||
| 36a0e2c42c | |||
| 41f3fee83c |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
data
|
||||
*.zip
|
||||
test
|
||||
README.md
|
||||
docker-compose.yml
|
||||
vboxstock-unraid.xml
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: mfwadejr/vseebox-stockroom
|
||||
IMAGE_NAME: mfwadejr/vboxstock
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
FROM node:22-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/mfwadejr/vseebox-stockroom"
|
||||
LABEL org.opencontainers.image.source="https://github.com/mfwadejr/vboxstock"
|
||||
LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker"
|
||||
WORKDIR /app
|
||||
COPY package.json server.mjs ./
|
||||
COPY public ./public
|
||||
RUN mkdir -p /data && chown -R node:node /app /data
|
||||
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production
|
||||
USER node
|
||||
COPY vboxstock-entrypoint.sh /usr/local/bin/vboxstock-entrypoint
|
||||
RUN apk add --no-cache su-exec \
|
||||
&& chmod +x /usr/local/bin/vboxstock-entrypoint \
|
||||
&& mkdir -p /data
|
||||
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production PUID=99 PGID=100
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/vboxstock-entrypoint"]
|
||||
CMD ["node", "server.mjs"]
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# vSeeBox Stockroom
|
||||
<p align="center"><img src="public/assets/vboxstock-icon-512.png" width="150" alt="vBoxStock cardboard box and inventory chart icon"></p>
|
||||
|
||||
# vBoxStock
|
||||
|
||||
A small, self-hosted inventory and sales tracker for people who buy, stock, and resell vSeeBox devices.
|
||||
|
||||
Stockroom replaces spreadsheets and handwritten lists with one browser-based place to track each physical unit from receipt through sale. It records the identifiers that matter for electronics inventory—UID, serial number, and MAC address—along with condition, cost, customer, payment, shipping, and support history.
|
||||
vBoxStock replaces spreadsheets and handwritten lists with one browser-based place to track each physical unit from receipt through sale. It records the identifiers that matter for electronics inventory—UID, serial number, and MAC address—along with condition, cost, customer, payment, shipping, and support history.
|
||||
|
||||
It was developed with Unraid in mind, but Unraid is not required. Stockroom is a standard OCI/Docker container and can run on a Docker-compatible Linux server, NAS, home lab, or cloud VM. The application is self-contained: the web server and SQLite database live inside one image, while durable data is stored in a mounted `/data` directory.
|
||||
It was developed with Unraid in mind, but Unraid is not required. vBoxStock is a standard OCI/Docker container and can run on a Docker-compatible Linux server, NAS, home lab, or cloud VM. The application is self-contained: the web server and SQLite database live inside one image, while durable data is stored in a mounted `/data` directory.
|
||||
|
||||

|
||||

|
||||
|
||||
## Why Stockroom exists
|
||||
## Why vBoxStock exists
|
||||
|
||||
General inventory tools can be larger and more complicated than a small reseller needs. Stockroom focuses on a straightforward workflow:
|
||||
General inventory tools can be larger and more complicated than a small reseller needs. vBoxStock focuses on a straightforward workflow:
|
||||
|
||||
1. Receive an individually identifiable device into inventory.
|
||||
2. Record its cost, model, condition, and notes.
|
||||
@@ -38,11 +40,11 @@ General inventory tools can be larger and more complicated than a small reseller
|
||||
|
||||
### Secure sign-in
|
||||
|
||||

|
||||

|
||||
|
||||
### User administration, backups, and audit history
|
||||
|
||||

|
||||

|
||||
|
||||
## Accounts and security
|
||||
|
||||
@@ -51,7 +53,7 @@ On a new installation—or an upgraded installation with no configured accounts
|
||||
- **Username:** `admin`
|
||||
- **Password:** `admin`
|
||||
|
||||
Stockroom immediately requires a new password and blocks access to application data until it is changed. Passwords must contain at least 8 characters and are stored as salted `scrypt` hashes, never as readable text.
|
||||
vBoxStock immediately requires a new password and blocks access to application data until it is changed. Passwords must contain at least 8 characters and are stored as salted `scrypt` hashes, never as readable text.
|
||||
|
||||
| Capability | Admin | Read-Only |
|
||||
| --- | :---: | :---: |
|
||||
@@ -64,23 +66,25 @@ Stockroom immediately requires a new password and blocks access to application d
|
||||
|
||||
Additional protections include HTTP-only SameSite session cookies, a 12-hour inactivity timeout, login throttling, required password confirmation before a restore, automatic session invalidation after a restore, and protection against disabling or deleting the final enabled administrator.
|
||||
|
||||
For use outside a trusted private network, place Stockroom behind an HTTPS reverse proxy. The application does not provide TLS certificates directly.
|
||||
For use outside a trusted private network, place vBoxStock behind an HTTPS reverse proxy. The application does not provide TLS certificates directly.
|
||||
|
||||
## Quick start with Docker
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name vseebox-stockroom \
|
||||
--name vboxstock \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-e TZ=America/New_York \
|
||||
-e PUID=99 \
|
||||
-e PGID=100 \
|
||||
-v /your/persistent/path:/data \
|
||||
ghcr.io/mfwadejr/vseebox-stockroom:latest
|
||||
ghcr.io/mfwadejr/vboxstock:latest
|
||||
```
|
||||
|
||||
Open `http://YOUR-SERVER-IP:3000`, sign in with the initial credentials above, and change the password when prompted.
|
||||
|
||||
The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced.
|
||||
The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced. `PUID` and `PGID` determine which host user and group own the mounted data. The defaults are Unraid's `nobody:users` IDs, `99:100`.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -99,25 +103,40 @@ docker compose up -d
|
||||
|
||||
## Unraid
|
||||
|
||||
Use the included `stockroom-unraid.xml` template or create a container with these settings:
|
||||
Use the included `vboxstock-unraid.xml` template or create a container with these settings:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Repository | `ghcr.io/mfwadejr/vseebox-stockroom:latest` |
|
||||
| Repository | `ghcr.io/mfwadejr/vboxstock:latest` |
|
||||
| WebUI port | `3000` |
|
||||
| Container data path | `/data` |
|
||||
| Suggested Unraid host path | `/mnt/user/appdata/vseebox-stockroom` |
|
||||
| Suggested Unraid host path | `/mnt/user/appdata/vboxstock` |
|
||||
| Network mode | `bridge` |
|
||||
| Timezone (`TZ`) | `America/New_York` |
|
||||
| PUID | `99` |
|
||||
| PGID | `100` |
|
||||
|
||||
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin.
|
||||
Add `TZ`, `PUID`, and `PGID` as Unraid container variables:
|
||||
|
||||
| Name | Key | Value |
|
||||
| --- | --- | --- |
|
||||
| Timezone | `TZ` | `America/New_York` |
|
||||
| User ID | `PUID` | `99` |
|
||||
| Group ID | `PGID` | `100` |
|
||||
|
||||
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin. At startup, the container creates `/data/backups`, applies the configured `PUID` and `PGID` ownership to `/data`, and then runs the application with those IDs.
|
||||
|
||||
## Data and backups
|
||||
|
||||
Stockroom uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`:
|
||||
vBoxStock uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`:
|
||||
|
||||
- `/data/stockroom.db` — active application database
|
||||
- `/data/vboxstock.db` — active application database
|
||||
- `/data/backups/` — locally retained database snapshots
|
||||
|
||||
`/data` is the path inside the container. When `/mnt/user/appdata/vboxstock` is mapped directly to `/data`, the Unraid host folder contains `vboxstock.db` and `backups/`; it does not contain another nested folder named `data`.
|
||||
|
||||
A fresh installation creates an empty inventory, sales history, and customer list. Only the initial `admin` account is created. Replacing or upgrading the container preserves existing records because `vboxstock.db` remains in the mounted host folder. Removing or changing the `/data` mapping starts a separate empty database, so keep that mapping consistent across upgrades.
|
||||
|
||||
The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced.
|
||||
|
||||
Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow.
|
||||
@@ -127,20 +146,21 @@ Backups contain customer information and password hashes. Store downloaded copie
|
||||
If every administrator is inaccessible, run this on the Docker host, replacing the container name, username, and temporary password if needed:
|
||||
|
||||
```sh
|
||||
docker exec -it vseebox-stockroom node server.mjs reset-admin admin NewPassword123
|
||||
docker exec -it vboxstock node server.mjs reset-admin admin NewPassword123
|
||||
```
|
||||
|
||||
The account is enabled as an administrator and must change the supplied password on its next login. The reset is recorded in the audit log. Because command arguments may briefly appear in process listings, the password can instead be supplied through an environment variable:
|
||||
|
||||
```sh
|
||||
docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vseebox-stockroom node server.mjs reset-admin admin
|
||||
docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vboxstock node server.mjs reset-admin admin
|
||||
```
|
||||
|
||||
## Container details
|
||||
|
||||
- Image: `ghcr.io/mfwadejr/vseebox-stockroom:latest`
|
||||
- Image: `ghcr.io/mfwadejr/vboxstock:latest`
|
||||
- Application port: `3000/tcp`
|
||||
- Persistent volume: `/data`
|
||||
- Runtime ownership: configurable with `PUID` and `PGID` (`99:100` by default)
|
||||
- Health check: `GET /api/health`
|
||||
- Runtime: Node.js 22
|
||||
- Database: SQLite
|
||||
@@ -148,7 +168,7 @@ docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vseebox-stockroom node se
|
||||
|
||||
## Intended scope
|
||||
|
||||
Stockroom is designed for a single reseller or small team operating one shared installation. It is not an accounting platform, payment processor, shipping-label service, or public storefront. Payment details are records of how a sale was accepted; Stockroom does not connect to Cash, Venmo, or PayPal or move money itself.
|
||||
vBoxStock is designed for a single reseller or small team operating one shared installation. It is not an accounting platform, payment processor, shipping-label service, or public storefront. Payment details are records of how a sale was accepted; vBoxStock does not connect to Cash, Venmo, or PayPal or move money itself.
|
||||
|
||||
## Updating and versioning
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
stockroom:
|
||||
image: ghcr.io/mfwadejr/vseebox-stockroom:latest
|
||||
container_name: vseebox-stockroom
|
||||
vboxstock:
|
||||
image: ghcr.io/mfwadejr/vboxstock:latest
|
||||
container_name: vboxstock
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -9,3 +9,5 @@ services:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- TZ=America/New_York
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vseebox-stockroom",
|
||||
"version": "2.0.1",
|
||||
"name": "vboxstock",
|
||||
"version": "3.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22.13.0" },
|
||||
|
||||
@@ -9,6 +9,10 @@ const esc = (v = "") => String(v).replace(/[&<>'"]/g, c => ({ "&":"&", "<":"
|
||||
const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
|
||||
const primaryId = p => ids(p)[0]?.[1] || "No identifier";
|
||||
function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; }
|
||||
const systemTheme=window.matchMedia("(prefers-color-scheme: dark)");
|
||||
function savedTheme(){try{return localStorage.getItem("vboxstock-theme")||"system"}catch{return "system"}}
|
||||
function applyTheme(choice=savedTheme()){const resolved=choice==="system"?(systemTheme.matches?"dark":"light"):choice;document.documentElement.dataset.theme=resolved;document.querySelector('meta[name="theme-color"]').content=resolved==="dark"?"#0d1320":"#f5f7fb";if($("#themeSelect"))$("#themeSelect").value=choice;}
|
||||
function decorateResponsiveTable(){const labels=[...document.querySelectorAll("#thead th")].map(th=>th.textContent.trim()||"Actions");document.querySelectorAll("#rows tr").forEach(row=>[...row.children].forEach((cell,index)=>cell.dataset.label=labels[index]||"Details"));}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const writing=options.method&&options.method!=="GET"; if(writing)storageStatus("saving","Saving to database");
|
||||
@@ -52,6 +56,7 @@ function render() {
|
||||
const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE);
|
||||
$("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>UID / SN / MAC</th><th>Sold</th><th>Payment</th><th>Sale price</th><th></th></tr>";
|
||||
$("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join("");
|
||||
decorateResponsiveTable();
|
||||
$("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded.";
|
||||
$("#pagination").innerHTML=all.length?`<span>Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:"";
|
||||
}
|
||||
@@ -91,6 +96,7 @@ async function load(){ state.products=await api("/api/products"); render(); }
|
||||
async function renderAdmin(){
|
||||
const panel=$("#adminPanel"); panel.innerHTML='<div class="admin-loading">Loading backups…</div>';
|
||||
try{const [backups,users,audit]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit")]);panel.innerHTML=`<div class="admin-page"><section class="admin-section"><div><h2>User accounts</h2><p>Create administrators or read-only accounts. New and reset passwords must be changed at next login.</p></div><form id="createUserForm" class="inline-user-form"><label>Username<input name="username" required minlength="3" autocomplete="off"></label><label>Temporary password<input name="password" type="password" required minlength="8" autocomplete="new-password"></label><label>Role<select name="role"><option value="readonly">Read-Only</option><option value="admin">Admin</option></select></label><button class="primary">Create user</button></form><div class="user-list">${users.map(u=>`<article><div><strong>${esc(u.username)}</strong><small>${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}</small></div><div><button class="secondary" data-role-user="${u.id}" data-role="${u.role}">${u.role==="admin"?"Make Read-Only":"Make Admin"}</button><button class="secondary" data-reset-user="${u.id}" data-name="${esc(u.username)}">Reset password</button><button class="secondary" data-toggle-user="${u.id}" data-enabled="${u.enabled}">${u.enabled?"Disable":"Enable"}</button><button class="delete-backup" data-delete-user="${u.id}" data-name="${esc(u.username)}">Delete</button></div></article>`).join("")}</div></section><section class="admin-section"><div><h2>Database backups</h2><p>Create snapshots inside <code>/data/backups</code>, download an off-server copy, or restore a previous database.</p></div><div class="admin-actions"><button class="primary" id="createBackup">Create backup now</button><label class="upload-backup">Restore uploaded backup<input id="restoreUpload" type="file" accept=".db,application/vnd.sqlite3"></label></div><div class="backup-warning"><strong>Restore replaces the active database and all user accounts.</strong> Your current password is required. Everyone will be signed out afterward.</div><div class="backup-list">${backups.length?backups.map(b=>`<article><div><strong>${esc(b.name)}</strong><small>${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB</small></div><div><a class="button secondary" href="/api/admin/backups/${encodeURIComponent(b.name)}/download">Download</a><button class="secondary" data-restore-backup="${esc(b.name)}">Restore</button><button class="delete-backup" data-delete-backup="${esc(b.name)}">Delete</button></div></article>`).join(""):"<p>No local backups yet.</p>"}</div></section><section class="admin-section"><div><h2>Audit log</h2><p>The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.</p></div><div class="audit-list">${audit.map(a=>`<article><strong>${esc(a.username)}</strong><span>${esc(a.action.replaceAll("_"," "))}</span><small>${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}</small></article>`).join("")||"<p>No audit events yet.</p>"}</div></section></div>`;
|
||||
panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'<div class="admin-intro"><img src="/assets/vboxstock-icon-512.png" alt=""><div><span class="admin-kicker">vBoxStock</span><h2>Administration</h2><p>Manage access, protect your data, and review account activity.</p></div></div>');
|
||||
$("#createUserForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/users",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});toast("User created.");renderAdmin();}catch(error){toast(error.message);}};
|
||||
document.querySelectorAll("[data-role-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.roleUser}`,{method:"PATCH",body:JSON.stringify({role:b.dataset.role==="admin"?"readonly":"admin"})});toast("User role updated.");renderAdmin();}catch(e){toast(e.message);}});
|
||||
document.querySelectorAll("[data-toggle-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.toggleUser}`,{method:"PATCH",body:JSON.stringify({enabled:b.dataset.enabled!=="true"})});toast("User status updated.");renderAdmin();}catch(e){toast(e.message);}});
|
||||
@@ -104,16 +110,16 @@ async function renderAdmin(){
|
||||
}
|
||||
async function restoreUpload(file,password){const panel=$("#adminPanel");try{storageStatus("saving","Restoring database");const response=await fetch("/api/admin/restore-upload",{method:"POST",headers:{"content-type":"application/octet-stream","x-confirm-password":password},body:file});const data=await response.json();if(!response.ok)throw new Error(data.error||"Restore failed");panel.innerHTML='<div class="restart-message"><h2>Restore complete</h2><p>The container is restarting. Everyone has been signed out.</p></div>';}catch(e){storageStatus("critical","Database error");toast(e.message);}}
|
||||
function showLogin(message=""){
|
||||
state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Sign in</h1><p>Use your Stockroom account to continue.</p>${message?`<div class="auth-message">${esc(message)}</div>`:""}<form id="loginForm"><label>Username<input name="username" autocomplete="username" autofocus required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button class="primary auth-submit">Sign in</button></form>`;
|
||||
state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Sign in</h1><p>Use your vBoxStock account to continue.</p>${message?`<div class="auth-message">${esc(message)}</div>`:""}<form id="loginForm"><label>Username<input name="username" autocomplete="username" autofocus required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button class="primary auth-submit">Sign in</button></form>`;
|
||||
$("#loginForm").onsubmit=async e=>{e.preventDefault();const button=e.currentTarget.querySelector("button");button.disabled=true;try{const user=await api("/api/auth/login",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});user.mustChangePassword?showPasswordChange(user):await enterApp(user);}catch(error){showLogin(error.message);}};
|
||||
}
|
||||
function showPasswordChange(user){
|
||||
state.user=user;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Change your password</h1><p>Your temporary password must be replaced before you can use Stockroom.</p><form id="passwordForm"><label>Current password<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>New password<input name="newPassword" type="password" minlength="8" autocomplete="new-password" required></label><label>Confirm new password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label><small>Passwords must contain at least 8 characters.</small><button class="primary auth-submit">Save password</button></form><button class="secondary auth-logout" id="changeLogout">Log out</button>`;
|
||||
state.user=user;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Change your password</h1><p>Your temporary password must be replaced before you can use vBoxStock.</p><form id="passwordForm"><label>Current password<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>New password<input name="newPassword" type="password" minlength="8" autocomplete="new-password" required></label><label>Confirm new password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label><small>Passwords must contain at least 8 characters.</small><button class="primary auth-submit">Save password</button></form><button class="secondary auth-logout" id="changeLogout">Log out</button>`;
|
||||
$("#passwordForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/auth/change-password",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await enterApp({...user,mustChangePassword:false});}catch(error){toast(error.message);}};$("#changeLogout").onclick=logout;
|
||||
}
|
||||
async function enterApp(user){state.user=user;document.body.className=user.role==="admin"?"role-admin":"role-readonly";const account=$("#currentUser"),menu=$(".user-menu");account.innerHTML=`<strong>${esc(user.username)}</strong><small>${user.role==="admin"?"Admin":"Read-Only"}</small>`;account.style.width="96px";account.style.textAlign="right";menu.style.marginLeft="auto";menu.style.minWidth="210px";menu.style.justifyContent="flex-end";const adminTab=document.querySelector('[data-tab="admin"]');adminTab.hidden=user.role!=="admin";adminTab.style.display=user.role==="admin"?"":"none";document.querySelectorAll(".edit-only").forEach(el=>{el.hidden=user.role!=="admin";el.style.display=user.role==="admin"?"":"none";});if(user.role!=="admin"&&state.tab==="admin")state.tab="available";await load();}
|
||||
async function enterApp(user){state.user=user;document.body.className=user.role==="admin"?"role-admin":"role-readonly";const account=$("#currentUser"),menu=$(".user-menu");account.innerHTML=`<small>Logged in as</small><strong>${esc(user.username)}</strong><small>${user.role==="admin"?"Admin":"Read-Only"}</small>`;account.style.width="104px";account.style.textAlign="right";menu.style.marginLeft="auto";menu.style.minWidth="280px";menu.style.justifyContent="flex-end";const adminTab=document.querySelector('[data-tab="admin"]');adminTab.hidden=user.role!=="admin";adminTab.style.display=user.role==="admin"?"":"none";document.querySelectorAll(".edit-only").forEach(el=>{el.hidden=user.role!=="admin";el.style.display=user.role==="admin"?"":"none";});if(user.role!=="admin"&&state.tab==="admin")state.tab="available";await load();}
|
||||
async function logout(){try{await api("/api/auth/logout",{method:"POST",body:"{}"});}catch{}showLogin();}
|
||||
async function initialize(){try{const response=await fetch("/api/auth/me"),data=await response.json();if(!response.ok)return showLogin();data.mustChangePassword?showPasswordChange(data):await enterApp(data);}catch{showLogin("Unable to connect to Stockroom.");}}
|
||||
async function initialize(){try{const response=await fetch("/api/auth/me"),data=await response.json();if(!response.ok)return showLogin();data.mustChangePassword?showPasswordChange(data):await enterApp(data);}catch{showLogin("Unable to connect to vBoxStock.");}}
|
||||
document.querySelectorAll("[data-tab]").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab;state.page=1;render();});
|
||||
$("#search").oninput=e=>{state.query=e.target.value;state.page=1;render();};
|
||||
$("[data-open=receive]").onclick=receiveForm; $("[data-open=sell]").onclick=()=>sellForm();
|
||||
@@ -121,5 +127,8 @@ $("#pagination").onclick=e=>{if(!e.target.dataset.page)return;state.page+=e.targ
|
||||
$("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");};
|
||||
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
|
||||
$("#logout").onclick=logout;
|
||||
$("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);};
|
||||
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
|
||||
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
||||
applyTheme();
|
||||
initialize();
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1,5 +1,5 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>vSeeBox Stockroom</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"></head>
|
||||
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><b>S</b><span>Stockroom</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><b>S</b><span>Stockroom</span></div><input id="search" type="search" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive">+ Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><button class="secondary" id="logout">Log out</button></div></header>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
|
||||
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive">+ Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header>
|
||||
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
|
||||
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
|
||||
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vBoxStock",
|
||||
"short_name": "vBoxStock",
|
||||
"description": "Inventory, sales, and customer tracking for independent vSeeBox resellers.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f7fb",
|
||||
"theme_color": "#2563eb",
|
||||
"icons": [
|
||||
{ "src": "/assets/vboxstock-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
:root{
|
||||
color-scheme:light;
|
||||
--page:#f5f7fb;--surface:#fff;--surface-soft:#f8fafc;--surface-table:#fbfcfe;
|
||||
--text:#172033;--text-strong:#172033;--muted:#68758b;--muted-2:#7b8799;
|
||||
--border:#e3e7ef;--border-strong:#d7deea;--blue:#2563eb;--blue-soft:#edf3ff;
|
||||
--blue-text:#1d4ed8;--green:#16a34a;--green-soft:#dcfce7;--danger:#c62828;
|
||||
--danger-soft:#fff0f0;--warning:#9a3412;--warning-soft:#fff7ed;--shadow:#17203324;
|
||||
}
|
||||
html[data-theme="dark"]{
|
||||
color-scheme:dark;
|
||||
--page:#0d1320;--surface:#151d2c;--surface-soft:#111827;--surface-table:#121a28;
|
||||
--text:#dce5f4;--text-strong:#f5f8ff;--muted:#a7b3c7;--muted-2:#929fb4;
|
||||
--border:#2b374b;--border-strong:#3a4961;--blue:#4f83ff;--blue-soft:#1c315d;
|
||||
--blue-text:#91b5ff;--green:#44cf7b;--green-soft:#153b2a;--danger:#ff8585;
|
||||
--danger-soft:#4a2027;--warning:#f3b166;--warning-soft:#472f1e;--shadow:#0008;
|
||||
}
|
||||
html[data-theme],body{background:var(--page);color:var(--text)}body,header,.records,.stats article,.auth-card,dialog{transition:background-color .18s ease,color .18s ease,border-color .18s ease}
|
||||
header,.records,.stats article,.auth-card,dialog{background:var(--surface);border-color:var(--border);color:var(--text)}
|
||||
.brand+input,#search,label input,label select,label textarea,.secondary,.upload-backup,.backup-list a.button,.user-menu select{background:var(--surface-soft);border-color:var(--border-strong);color:var(--text)}
|
||||
.intro>span{background:var(--surface);border-color:var(--border);color:var(--muted)}
|
||||
.stats span,.stats small,.records nav>span,dialog>div>p,.admin-page p,.auth-card>div>p,.muted,td small,.backup-list article small,.user-list small,.audit-list small{color:var(--muted)}
|
||||
th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.backup-list article,.user-list article,.audit-list article{border-color:var(--border)}
|
||||
.records nav .tab,.customer-link{color:var(--muted)}.records nav .tab.active{color:var(--blue);border-color:var(--blue)}.tab i{background:var(--surface-soft)}
|
||||
.row-actions button,.customer-purchases button{background:var(--blue-soft);color:var(--blue-text)}.row-actions .danger,.customer-notes article>button,.delete-backup,.backup-list .delete-backup{background:var(--danger-soft);color:var(--danger)}
|
||||
.pill{background:var(--green-soft);color:var(--green)}.detail-card{background:var(--surface-soft);border-color:var(--border)}
|
||||
.customer-notes article,.backup-list,.user-list,.audit-list{border-color:var(--border)}
|
||||
.note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)}
|
||||
.auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)}
|
||||
dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)}
|
||||
#toast{background:var(--text-strong);color:var(--surface);box-shadow:0 12px 30px var(--shadow)}
|
||||
#currentUser{background:var(--surface-soft);border:1px solid var(--border-strong);border-radius:9px;padding:6px 10px;line-height:1.15}.theme-control{display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;margin:0 0 0 4px;padding-left:12px;border-left:1px solid var(--border-strong);color:var(--muted);font-size:11px}.user-menu select{width:92px;border:1px solid var(--border-strong);border-radius:9px;padding:9px 26px 9px 9px;font-size:12px;font-weight:650}
|
||||
#pagination button{background:var(--surface-soft);border-color:var(--border-strong);color:var(--text)}#pagination button:not(:disabled):hover{background:var(--blue-soft);color:var(--blue-text)}#pagination button:disabled{background:var(--surface-soft);color:var(--text);opacity:1;cursor:not-allowed}
|
||||
button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid color-mix(in srgb,var(--blue) 45%,transparent);outline-offset:2px}
|
||||
.brand{font-weight:750;white-space:nowrap}.brand img{width:42px;height:42px;object-fit:contain;filter:drop-shadow(0 2px 4px var(--shadow))}.auth-brand img{width:62px;height:62px}.admin-intro{display:flex;align-items:center;gap:14px;padding:4px 0 2px}.admin-intro img{width:54px;height:54px;object-fit:contain}.admin-intro h2{margin:0}.admin-intro p{margin:4px 0 0}.admin-kicker{display:block;color:var(--blue-text);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
|
||||
@media (pointer:coarse){button,.button,.upload-backup,.user-menu select{min-height:44px}}
|
||||
@media (max-width:1050px){
|
||||
header{gap:14px;padding-inline:3vw}.brand span{display:none}.actions{gap:6px}.actions button{padding-inline:10px}.user-menu{padding-left:10px;gap:7px}.user-menu select{width:105px}
|
||||
.inline-user-form{grid-template-columns:1fr 1fr}.audit-list article{grid-template-columns:120px 160px 1fr}
|
||||
}
|
||||
@media (max-width:820px){
|
||||
header{position:relative;display:grid;grid-template-columns:auto 1fr auto;height:auto;padding:12px max(14px,env(safe-area-inset-right)) 12px max(14px,env(safe-area-inset-left));gap:10px}
|
||||
header .brand{grid-column:1}.user-menu{grid-column:2/4;grid-row:1;margin-left:auto!important;min-width:0!important;padding-left:0;border-left:0}.user-menu #currentUser{display:grid!important;width:auto!important;min-width:78px;padding:5px 7px}.theme-control{grid-template-columns:1fr;margin-left:0;padding-left:6px}.theme-control>span{font-size:10px;text-align:center}
|
||||
header #search{grid-column:1/-1;grid-row:2;order:initial}.actions{grid-column:1/-1;grid-row:3;width:100%;margin:0}.actions button{font-size:14px!important;flex:1}.actions button::first-letter{font-size:inherit!important}
|
||||
main{width:min(100% - 24px,1400px);margin:14px auto 28px}.intro h1{font-size:23px}.intro>span{font-size:12px}.stats{gap:10px;margin:14px 0}.stats article{padding:16px}.stats strong{font-size:26px}
|
||||
.records{overflow:visible}.records nav{display:grid;grid-template-columns:1fr 1fr;overflow:visible;height:auto;padding:0 10px;gap:0}.records nav .tab{width:100%;height:50px;white-space:nowrap;padding-inline:6px;font-size:12px}.storage-status{grid-column:1/-1;min-width:100%;margin:0!important;padding:10px 4px!important;border-top:1px solid var(--border)}
|
||||
.table-wrap{overflow:visible}table{display:block;min-width:0}thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}tbody{display:grid;gap:10px;padding:10px;background:var(--page)}tr{display:grid;background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}td{display:grid;grid-template-columns:minmax(92px,34%) 1fr;gap:10px;align-items:start;padding:11px 13px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}td:last-child{border-bottom:0}td::before{content:attr(data-label);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-2)}td:empty{display:none}.row-actions{flex-wrap:wrap}.row-actions button{flex:1;min-width:95px}
|
||||
#empty{background:var(--surface);padding:45px 20px}#pagination{background:var(--surface);padding:14px 12px}.address-grid{grid-template-columns:1fr}.form-row,.scan{grid-template-columns:1fr}
|
||||
dialog{width:calc(100vw - 20px);max-height:calc(100dvh - 20px);padding:22px 18px;margin:10px}.form-actions{flex-wrap:wrap}.form-actions button{min-width:110px}
|
||||
#adminPanel{padding:15px 12px}.admin-page{gap:26px}.admin-section{gap:13px}.inline-user-form{grid-template-columns:1fr}.user-list article,.backup-list article{align-items:stretch}.user-list article>div:last-child,.backup-list article>div:last-child{display:grid;grid-template-columns:1fr 1fr}.user-list button,.backup-list button,.backup-list a.button{justify-content:center;text-align:center}.audit-list article{grid-template-columns:1fr;gap:3px}.audit-list{max-height:55vh}
|
||||
.auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline}
|
||||
}
|
||||
@media (max-width:430px){
|
||||
.user-menu{width:100%;justify-content:flex-end!important;gap:5px}.user-menu select{width:64px;max-width:64px;padding-inline:6px}.user-menu #currentUser{min-width:72px!important}.user-menu #currentUser small{font-size:9px}.user-menu #logout{padding-inline:8px}
|
||||
.intro{align-items:flex-start}.intro h1{margin:8px 0}.intro>span{padding:7px 8px}.stats{grid-template-columns:1fr}.stats article{grid-template-columns:1fr auto}.stats article strong{grid-column:2;grid-row:1/3}.stats article small{grid-column:1}
|
||||
td{grid-template-columns:82px 1fr}.backup-list article>div:last-child,.user-list article>div:last-child{grid-template-columns:1fr}
|
||||
}
|
||||
@media (forced-colors:active){button,.button,input,select,textarea,.records,.stats article{border:1px solid CanvasText}.storage-status i{forced-color-adjust:none}}
|
||||
@@ -6,7 +6,7 @@ import { backup, DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
||||
const databasePath=join(dataDir,"stockroom.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
|
||||
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
|
||||
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
|
||||
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
||||
let db=new DatabaseSync(databasePath);
|
||||
@@ -30,8 +30,6 @@ function validPassword(value){if(String(value||"").length<8)throw new Error("Pas
|
||||
function cleanUsername(value){const name=String(value||"").trim();if(!/^[a-zA-Z0-9._-]{3,40}$/.test(name))throw new Error("Username must be 3–40 characters using letters, numbers, periods, dashes, or underscores.");return name}
|
||||
initializeDatabase();
|
||||
try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)}
|
||||
const seed=[["Logan Crabtree","273D000000021255","A0:BB:3E:02:12:55"],["Marvin Wade","273D00000002194E","A0:BB:3E:02:19:4E"],["Logan Crabtree","273D00000002185F","A0:BB:3E:02:18:5F"],["Logan Crabtree","273D00000002193A","A0:BB:3E:02:19:3A"],["Mark Milburn","273D0000000218BD","A0:BB:3E:02:18:BD"],["Matt Avila","273D0000000219D5","A0:BB:3E:02:19:D5"],["Andy Nguyen","273D0000000219FD","A0:BB:3E:02:19:FD"],["Andy Nguyen","273D000000021181","A0:BB:3E:02:11:81"],["Daniel Wade","273D00000002125A","A0:BB:3E:02:12:5A"],["Marvin Wade","273D0000000219D4","A0:BB:3E:02:19:D4"]];
|
||||
if(db.prepare("SELECT COUNT(*) count FROM products").get().count===0){const insert=db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,status,sold_at,customer_name) VALUES (?,?,?,?,?,?,?,?,?,?)");db.exec("BEGIN");try{seed.forEach(([name,sn,mac])=>insert.run(crypto.randomUUID(),"",sn,mac,"V3 Plus","New","2024-08-20","sold","2024-08-20",name));db.exec("COMMIT")}catch(e){db.exec("ROLLBACK");throw e}}
|
||||
if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()}
|
||||
const findCustomerByName=db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)");
|
||||
for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){let customer=findCustomerByName.get(old.name);if(!customer){const id=crypto.randomUUID();addCustomer.run(id,old.name,old.phone||"","","","","","","");customer={id}}db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name)}
|
||||
@@ -47,16 +45,16 @@ async function rawBody(req){const chunks=[];let size=0;for await(const chunk of
|
||||
const clientIp=req=>String(req.headers["x-forwarded-for"]||req.socket.remoteAddress||"").split(",")[0].trim();
|
||||
function cookieMap(req){return Object.fromEntries(String(req.headers.cookie||"").split(";").filter(Boolean).map(part=>{const i=part.indexOf("=");return[part.slice(0,i).trim(),decodeURIComponent(part.slice(i+1))]}))}
|
||||
const tokenKey=token=>createHash("sha256").update(token).digest("hex");
|
||||
function sessionCookie(req,token,maxAge=SESSION_IDLE_MS/1000){return `stockroom_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${req.headers["x-forwarded-proto"]==="https"?"; Secure":""}`}
|
||||
function currentSession(req){const token=cookieMap(req).stockroom_session;if(!token)return null;const key=tokenKey(token),session=sessions.get(key);if(!session)return null;if(Date.now()-session.lastSeen>SESSION_IDLE_MS){sessions.delete(key);return null}const user=db.prepare("SELECT id,username,role,enabled,must_change_password AS mustChangePassword FROM users WHERE id=?").get(session.userId);if(!user?.enabled){sessions.delete(key);return null}session.lastSeen=Date.now();return{...session,user,tokenKey:key}}
|
||||
function sessionCookie(req,token,maxAge=SESSION_IDLE_MS/1000){return `vboxstock_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${req.headers["x-forwarded-proto"]==="https"?"; Secure":""}`}
|
||||
function currentSession(req){const token=cookieMap(req).vboxstock_session;if(!token)return null;const key=tokenKey(token),session=sessions.get(key);if(!session)return null;if(Date.now()-session.lastSeen>SESSION_IDLE_MS){sessions.delete(key);return null}const user=db.prepare("SELECT id,username,role,enabled,must_change_password AS mustChangePassword FROM users WHERE id=?").get(session.userId);if(!user?.enabled){sessions.delete(key);return null}session.lastSeen=Date.now();return{...session,user,tokenKey:key}}
|
||||
function audit(req,user,action,target="",details=""){db.prepare("INSERT INTO audit_log (user_id,username,action,target,details,ip_address) VALUES (?,?,?,?,?,?)").run(user?.id||null,user?.username||"system",action,target,details,clientIp(req))}
|
||||
function invalidateUserSessions(id){for(const[key,value]of sessions)if(value.userId===id)sessions.delete(key)}
|
||||
function requireOrigin(req){if(["GET","HEAD","OPTIONS"].includes(req.method))return;const origin=req.headers.origin;if(origin){const expected=`${req.headers["x-forwarded-proto"]||"http"}://${req.headers.host}`;if(origin!==expected)throw Object.assign(new Error("Invalid request origin."),{status:403})}}
|
||||
function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session}
|
||||
const backupName=(prefix="stockroom")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
|
||||
const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
|
||||
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
|
||||
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
|
||||
function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid Stockroom database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
|
||||
function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
|
||||
async function restoreFrom(path,res,req,user){validateBackup(path);const target=path.split("/").pop();await createBackup("pre-restore");audit(req,user,"database_restore",target);db.exec("PRAGMA wal_checkpoint(TRUNCATE)");db.close();await copyFile(path,databasePath);await unlink(`${databasePath}-wal`).catch(()=>{});await unlink(`${databasePath}-shm`).catch(()=>{});await writeFile(restoreMarker,JSON.stringify({username:user.username,target,details:"Database restored; all sessions invalidated",ipAddress:clientIp(req)}));sessions.clear();json(res,200,{ok:true,restarting:true});setTimeout(()=>process.exit(0),250)}
|
||||
function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})}
|
||||
function productInput(v){const model=String(v.model||""),condition=String(v.condition||"");if(!allowedModels.has(model)||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("Model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
|
||||
@@ -79,7 +77,7 @@ async function adminApi(req,res,url,session){
|
||||
if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})}
|
||||
if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all());
|
||||
if(url.pathname==="/api/admin/backups"&&req.method==="GET"){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return json(res,200,result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)))}
|
||||
if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("stockroom");audit(req,user,"backup_created",name);return json(res,201,{name})}
|
||||
if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})}
|
||||
if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)}
|
||||
const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/);
|
||||
if(backupMatch){const name=decodeURIComponent(backupMatch[1]),action=backupMatch[2],path=safeBackup(name);await stat(path);if(action==="download"&&req.method==="GET"){audit(req,user,"backup_downloaded",name);const content=await readFile(path);res.writeHead(200,{"content-type":"application/vnd.sqlite3","content-disposition":`attachment; filename="${name}"`,"content-length":content.length});return res.end(content)}if(action==="restore"&&req.method==="POST"){const v=await body(req);confirmPassword(user,v.password);return restoreFrom(path,res,req,user)}}
|
||||
@@ -105,5 +103,5 @@ async function api(req,res,url){
|
||||
if(req.method==="POST"&&action==="restock"){const v=await body(req);if(!allowedConditions.has(v.condition)||!v.receivedAt)throw new Error("Condition and return date are required.");db.prepare("UPDATE products SET status='available',condition=?,received_at=?,sold_at=NULL,customer_id=NULL,customer_name=NULL,phone=NULL,sale_price=NULL,ship_address1='',ship_address2='',ship_city='',ship_state='',ship_zip='',shipping_notes='',payment_method='',payment_reference='',sale_notes='' WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().get(id))}
|
||||
return json(res,405,{error:"Method not allowed"});
|
||||
}
|
||||
const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml"};
|
||||
createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||"localhost"}`);if(url.pathname.startsWith("/api/"))return await api(req,res,url);const requested=url.pathname==="/"?"index.html":url.pathname.slice(1),file=normalize(join(publicDir,requested));if(!file.startsWith(publicDir)||!(await stat(file)).isFile())throw new Error("NOT_FOUND");const content=await readFile(file);res.writeHead(200,{"content-type":mime[extname(file)]||"application/octet-stream","cache-control":"no-store"});res.end(content)}catch(error){if(error.message==="NOT_FOUND"||error.code==="ENOENT")return json(res,404,{error:"Not found"});const duplicate=String(error.message).includes("UNIQUE constraint failed");json(res,error.status||(duplicate?409:400),{error:duplicate?"That username, UID, SN, or MAC is already recorded.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`Stockroom listening on port ${port}`));
|
||||
const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".webmanifest":"application/manifest+json; charset=utf-8"};
|
||||
createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||"localhost"}`);if(url.pathname.startsWith("/api/"))return await api(req,res,url);const requested=url.pathname==="/"?"index.html":url.pathname.slice(1),file=normalize(join(publicDir,requested));if(!file.startsWith(publicDir)||!(await stat(file)).isFile())throw new Error("NOT_FOUND");const content=await readFile(file);res.writeHead(200,{"content-type":mime[extname(file)]||"application/octet-stream","cache-control":"no-store"});res.end(content)}catch(error){if(error.message==="NOT_FOUND"||error.code==="ENOENT")return json(res,404,{error:"Not found"});const duplicate=String(error.message).includes("UNIQUE constraint failed");json(res,error.status||(duplicate?409:400),{error:duplicate?"That username, UID, SN, or MAC is already recorded.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`vBoxStock listening on port ${port}`));
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>vSeeBox-Stockroom</Name><Repository>ghcr.io/mfwadejr/vseebox-stockroom:latest</Repository><Registry>https://github.com/mfwadejr/vseebox-stockroom/pkgs/container/vseebox-stockroom</Registry>
|
||||
<Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged>
|
||||
<Support></Support><Project></Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview>
|
||||
<Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL></TemplateURL><Icon></Icon>
|
||||
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="Stockroom web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||
<Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vseebox-stockroom" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vseebox-stockroom</Config>
|
||||
<Config Name="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Container timezone" Type="Variable" Display="advanced" Required="false" Mask="false">America/New_York</Config>
|
||||
</Container>
|
||||
@@ -1,2 +1,38 @@
|
||||
import test from "node:test";import assert from "node:assert/strict";import {spawn} from "node:child_process";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {join} from "node:path";
|
||||
test("health, seed, receive, sell and restock",async t=>{const data=await mkdtemp(join(tmpdir(),"stockroom-")),port=31991,p=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});t.after(()=>p.kill());await new Promise((ok,no)=>{p.stdout.on("data",d=>String(d).includes("listening")&&ok());p.on("error",no)});let r=await fetch(`http://127.0.0.1:${port}/api/products`),items=await r.json();assert.equal(items.length,10);r=await fetch(`http://127.0.0.1:${port}/api/products`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({uid:"U1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(r.status,201);const item=await r.json();r=await fetch(`http://127.0.0.1:${port}/api/products/${item.id}/sell`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({customerName:"Test",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123"})});const sale=await r.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");r=await fetch(`http://127.0.0.1:${port}/api/products/${item.id}/restock`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});assert.equal((await r.json()).status,"available")});
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("authentication, roles, inventory, sale, and restock", async t => {
|
||||
const data=await mkdtemp(join(tmpdir(),"vboxstock-")),port=31991;
|
||||
const processHandle=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});
|
||||
t.after(()=>processHandle.kill());
|
||||
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
|
||||
const base=`http://127.0.0.1:${port}`;
|
||||
const request=async(path,options={})=>fetch(base+path,options);
|
||||
const login=async(username,password)=>{const response=await request("/api/auth/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username,password})});return{response,cookie:response.headers.get("set-cookie").split(";")[0]}};
|
||||
const jsonHeaders=cookie=>({"content-type":"application/json",cookie});
|
||||
|
||||
let response=await request("/api/health");assert.equal(response.status,200);
|
||||
response=await request("/api/products");assert.equal(response.status,401);
|
||||
|
||||
const bootstrap=await login("admin","admin");assert.equal(bootstrap.response.status,200);assert.equal((await bootstrap.response.json()).mustChangePassword,true);
|
||||
response=await request("/api/products",{headers:{cookie:bootstrap.cookie}});assert.equal(response.status,403);
|
||||
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(bootstrap.cookie),body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})});assert.equal(response.status,200);
|
||||
const adminCookie=response.headers.get("set-cookie").split(";")[0];
|
||||
|
||||
response=await request("/api/products",{headers:{cookie:adminCookie}});let items=await response.json();assert.equal(items.length,0,"a fresh database must contain no inventory or sales");
|
||||
response=await request("/api/admin/users",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({username:"viewer",password:"viewer123",role:"readonly"})});assert.equal(response.status,201);
|
||||
const viewerLogin=await login("viewer","viewer123");
|
||||
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(viewerLogin.cookie),body:JSON.stringify({currentPassword:"viewer123",newPassword:"viewer456",confirmPassword:"viewer456"})});
|
||||
const viewerCookie=response.headers.get("set-cookie").split(";")[0];
|
||||
response=await request("/api/products",{headers:{cookie:viewerCookie}});assert.equal(response.status,200);
|
||||
response=await request("/api/products",{method:"POST",headers:jsonHeaders(viewerCookie),body:"{}"});assert.equal(response.status,403);
|
||||
response=await request("/api/admin/users",{headers:{cookie:viewerCookie}});assert.equal(response.status,403);
|
||||
|
||||
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,201);const item=await response.json();
|
||||
response=await request(`/api/products/${item.id}/sell`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123"})});const sale=await response.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");
|
||||
response=await request(`/api/products/${item.id}/restock`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});assert.equal((await response.json()).status,"available");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
puid="${PUID:-99}"
|
||||
pgid="${PGID:-100}"
|
||||
|
||||
case "$puid:$pgid" in
|
||||
*[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mkdir -p "${DATA_DIR:-/data}" "${DATA_DIR:-/data}/backups"
|
||||
chown -R "$puid:$pgid" "${DATA_DIR:-/data}"
|
||||
|
||||
exec su-exec "$puid:$pgid" "$@"
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>vBoxStock</Name><Repository>ghcr.io/mfwadejr/vboxstock:latest</Repository><Registry>https://github.com/mfwadejr/vboxstock/pkgs/container/vboxstock</Registry>
|
||||
<Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged>
|
||||
<Support></Support><Project></Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview>
|
||||
<Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL></TemplateURL><Icon></Icon>
|
||||
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="vBoxStock web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||
<Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vboxstock" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vboxstock</Config>
|
||||
<Config Name="User ID" Target="PUID" Default="99" Mode="" Description="Host user ID used to own and write application data. Unraid default is 99 (nobody)." Type="Variable" Display="advanced" Required="true" Mask="false">99</Config>
|
||||
<Config Name="Group ID" Target="PGID" Default="100" Mode="" Description="Host group ID used to own and write application data. Unraid default is 100 (users)." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
|
||||
<Config Name="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Container timezone" Type="Variable" Display="advanced" Required="false" Mask="false">America/New_York</Config>
|
||||
</Container>
|
||||