diff --git a/public/app.js b/public/app.js index 1f4f6af..46ae44b 100644 --- a/public/app.js +++ b/public/app.js @@ -96,6 +96,7 @@ async function load(){ state.products=await api("/api/products"); render(); } async function renderAdmin(){ const panel=$("#adminPanel"); panel.innerHTML='
Loading backups…
'; try{const [backups,users,audit]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit")]);panel.innerHTML=`

User accounts

Create administrators or read-only accounts. New and reset passwords must be changed at next login.

${users.map(u=>`
${esc(u.username)}${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}
`).join("")}

Database backups

Create snapshots inside /data/backups, download an off-server copy, or restore a previous database.

Restore replaces the active database and all user accounts. Your current password is required. Everyone will be signed out afterward.
${backups.length?backups.map(b=>`
${esc(b.name)}${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB
Download
`).join(""):"

No local backups yet.

"}

Audit log

The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.

${audit.map(a=>`
${esc(a.username)}${esc(a.action.replaceAll("_"," "))}${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}
`).join("")||"

No audit events yet.

"}
`; + panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'
vBoxStock

Administration

Manage access, protect your data, and review account activity.

'); $("#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);}}); @@ -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='

Restore complete

The container is restarting. Everyone has been signed out.

';}catch(e){storageStatus("critical","Database error");toast(e.message);}} function showLogin(message=""){ - state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`

Sign in

Use your Stockroom account to continue.

${message?`
${esc(message)}
`:""}
`; + state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`

Sign in

Use your vBoxStock account to continue.

${message?`
${esc(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){ - state.user=user;document.body.className="auth-required";$("#authBody").innerHTML=`

Change your password

Your temporary password must be replaced before you can use Stockroom.

Passwords must contain at least 8 characters.
`; + state.user=user;document.body.className="auth-required";$("#authBody").innerHTML=`

Change your password

Your temporary password must be replaced before you can use vBoxStock.

Passwords must contain at least 8 characters.
`; $("#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=`Logged in as${esc(user.username)}${user.role==="admin"?"Admin":"Read-Only"}`;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(); diff --git a/public/index.html b/public/index.html index 882173b..7d60926 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ -vSeeBox Stockroom -
SStockroom
SStockroom
+vBoxStock +
vBoxStock
vBoxStock

Inventory Overview

Available products0Ready to sell
Sold this month0No sales recorded
Inventory value$0Based on purchase cost
diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..3c1e9da --- /dev/null +++ b/public/site.webmanifest @@ -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" } + ] +} diff --git a/public/theme.css b/public/theme.css index bc8dad8..7ca7041 100644 --- a/public/theme.css +++ b/public/theme.css @@ -31,6 +31,7 @@ dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#05 #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){