Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e91841329 | |||
| 1fee676176 | |||
| 50b6cf93a1 | |||
| 01c25e7fc8 | |||
| 7daaf51e64 | |||
| c0d950b439 | |||
| 3c5766533e | |||
| 7596b11966 | |||
| 0efa1bfa38 | |||
| 2ba44f15ee | |||
| f6caf39b5a | |||
| 009fafa6fa | |||
| fe84ae5c66 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
data
|
||||||
|
*.zip
|
||||||
|
test
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
|
vboxstock-unraid.xml
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: mfwadejr/vseebox-stockroom
|
IMAGE_NAME: mfwadejr/vboxstock
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
FROM node:22-alpine
|
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"
|
LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json server.mjs ./
|
COPY package.json 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.
|
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.
|
1. Receive an individually identifiable device into inventory.
|
||||||
2. Record its cost, model, condition, and notes.
|
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
|
### Secure sign-in
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
## Accounts and security
|
## Accounts and security
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ On a new installation—or an upgraded installation with no configured accounts
|
|||||||
- **Username:** `admin`
|
- **Username:** `admin`
|
||||||
- **Password:** `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 |
|
| Capability | Admin | Read-Only |
|
||||||
| --- | :---: | :---: |
|
| --- | :---: | :---: |
|
||||||
@@ -64,18 +66,18 @@ 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.
|
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
|
## Quick start with Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name vseebox-stockroom \
|
--name vboxstock \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e TZ=America/New_York \
|
-e TZ=America/New_York \
|
||||||
-v /your/persistent/path:/data \
|
-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.
|
Open `http://YOUR-SERVER-IP:3000`, sign in with the initial credentials above, and change the password when prompted.
|
||||||
@@ -99,23 +101,23 @@ docker compose up -d
|
|||||||
|
|
||||||
## Unraid
|
## 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 |
|
| Setting | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository | `ghcr.io/mfwadejr/vseebox-stockroom:latest` |
|
| Repository | `ghcr.io/mfwadejr/vboxstock:latest` |
|
||||||
| WebUI port | `3000` |
|
| WebUI port | `3000` |
|
||||||
| Container data path | `/data` |
|
| Container data path | `/data` |
|
||||||
| Suggested Unraid host path | `/mnt/user/appdata/vseebox-stockroom` |
|
| Suggested Unraid host path | `/mnt/user/appdata/vboxstock` |
|
||||||
| Network mode | `bridge` |
|
| Network mode | `bridge` |
|
||||||
|
|
||||||
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin.
|
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin.
|
||||||
|
|
||||||
## Data and backups
|
## 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/backups/` — locally retained database snapshots
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -127,18 +129,18 @@ 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:
|
If every administrator is inaccessible, run this on the Docker host, replacing the container name, username, and temporary password if needed:
|
||||||
|
|
||||||
```sh
|
```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:
|
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
|
```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
|
## Container details
|
||||||
|
|
||||||
- Image: `ghcr.io/mfwadejr/vseebox-stockroom:latest`
|
- Image: `ghcr.io/mfwadejr/vboxstock:latest`
|
||||||
- Application port: `3000/tcp`
|
- Application port: `3000/tcp`
|
||||||
- Persistent volume: `/data`
|
- Persistent volume: `/data`
|
||||||
- Health check: `GET /api/health`
|
- Health check: `GET /api/health`
|
||||||
@@ -148,7 +150,7 @@ docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vseebox-stockroom node se
|
|||||||
|
|
||||||
## Intended scope
|
## 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
|
## Updating and versioning
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
stockroom:
|
vboxstock:
|
||||||
image: ghcr.io/mfwadejr/vseebox-stockroom:latest
|
image: ghcr.io/mfwadejr/vboxstock:latest
|
||||||
container_name: vseebox-stockroom
|
container_name: vboxstock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vseebox-stockroom",
|
"name": "vboxstock",
|
||||||
"version": "2.1.0",
|
"version": "3.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22.13.0" },
|
"engines": { "node": ">=22.13.0" },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
|
|||||||
const primaryId = p => ids(p)[0]?.[1] || "No identifier";
|
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; }
|
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)");
|
const systemTheme=window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
function savedTheme(){try{return localStorage.getItem("stockroom-theme")||"system"}catch{return "system"}}
|
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 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"));}
|
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"));}
|
||||||
|
|
||||||
@@ -96,6 +96,7 @@ async function load(){ state.products=await api("/api/products"); render(); }
|
|||||||
async function renderAdmin(){
|
async function renderAdmin(){
|
||||||
const panel=$("#adminPanel"); panel.innerHTML='<div class="admin-loading">Loading backups…</div>';
|
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>`;
|
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);}};
|
$("#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-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);}});
|
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);}});
|
||||||
@@ -109,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);}}
|
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=""){
|
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);}};
|
$("#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){
|
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;
|
$("#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 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();});
|
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();};
|
$("#search").oninput=e=>{state.query=e.target.value;state.page=1;render();};
|
||||||
$("[data-open=receive]").onclick=receiveForm; $("[data-open=sell]").onclick=()=>sellForm();
|
$("[data-open=receive]").onclick=receiveForm; $("[data-open=sell]").onclick=()=>sellForm();
|
||||||
@@ -126,7 +127,7 @@ $("#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.");};
|
$("#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();};
|
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
|
||||||
$("#logout").onclick=logout;
|
$("#logout").onclick=logout;
|
||||||
$("#themeSelect").onchange=e=>{try{localStorage.setItem("stockroom-theme",e.target.value)}catch{}applyTheme(e.target.value);};
|
$("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);};
|
||||||
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
|
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
|
||||||
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
|||||||
|
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,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><title>vSeeBox Stockroom</title><script>try{const t=localStorage.getItem("stockroom-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
|
<!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"><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" 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><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button class="secondary" id="logout">Log out</button></div></header>
|
<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>
|
<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>
|
<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>
|
<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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -28,8 +28,10 @@ th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.back
|
|||||||
.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)}
|
.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)}
|
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)}
|
#toast{background:var(--text-strong);color:var(--surface);box-shadow:0 12px 30px var(--shadow)}
|
||||||
.user-menu select{width:118px;border:1px solid var(--border-strong);border-radius:9px;padding:9px 30px 9px 10px;font-size:12px;font-weight:650}
|
#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}
|
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 (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 (pointer:coarse){button,.button,.upload-backup,.user-menu select{min-height:44px}}
|
||||||
@media (max-width:1050px){
|
@media (max-width:1050px){
|
||||||
@@ -38,7 +40,7 @@ button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}but
|
|||||||
}
|
}
|
||||||
@media (max-width:820px){
|
@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{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:70px}
|
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}
|
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}
|
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)}
|
.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)}
|
||||||
@@ -49,7 +51,7 @@ button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}but
|
|||||||
.auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline}
|
.auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline}
|
||||||
}
|
}
|
||||||
@media (max-width:430px){
|
@media (max-width:430px){
|
||||||
.user-menu{width:100%;justify-content:flex-end!important}.user-menu select{width:88px;max-width:88px;padding-inline:8px}.user-menu #currentUser small{font-size:10px}.user-menu #logout{padding-inline:10px}
|
.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}
|
.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}
|
td{grid-template-columns:82px 1fr}.backup-list article>div:last-child,.user-list article>div:last-child{grid-template-columns:1fr}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { backup, DatabaseSync } from "node:sqlite";
|
|||||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||||
|
|
||||||
const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
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();
|
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
|
||||||
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
||||||
let db=new DatabaseSync(databasePath);
|
let db=new DatabaseSync(databasePath);
|
||||||
@@ -47,16 +47,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();
|
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))]}))}
|
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");
|
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 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).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 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 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 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 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}
|
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}
|
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 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)}
|
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 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()}}
|
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 +79,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(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/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==="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)}
|
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)$/);
|
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)}}
|
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 +105,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))}
|
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"});
|
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"};
|
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(`Stockroom listening on port ${port}`));
|
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}`));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
test("authentication, roles, inventory, sale, and restock", async t => {
|
test("authentication, roles, inventory, sale, and restock", async t => {
|
||||||
const data=await mkdtemp(join(tmpdir(),"stockroom-")),port=31991;
|
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)}});
|
const processHandle=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});
|
||||||
t.after(()=>processHandle.kill());
|
t.after(()=>processHandle.kill());
|
||||||
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
|
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<Container version="2">
|
<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>
|
<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>
|
<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>
|
<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>
|
<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="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/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="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="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Container timezone" Type="Variable" Display="advanced" Required="false" Mask="false">America/New_York</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>
|
</Container>
|
||||||