Added accounting and more example laptops

This commit is contained in:
2026-08-27 01:16:16 +03:00
parent c608a8aa63
commit 7d38d03d62
10 changed files with 282 additions and 13 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Rebuild Stockroom Docker app",
"type": "shell",
"command": "docker",
"args": [
"compose",
"up",
"--build",
"-d"
],
"isBackground": false,
"problemMatcher": []
}
]
}
+5
View File
@@ -21,9 +21,14 @@ Open http://localhost:5000. The SQLite database is stored in the project-local `
- Media field: format - Media field: format
- CSV export of the currently visible inventory - CSV export of the currently visible inventory
- Responsive web UI for desktop and mobile - Responsive web UI for desktop and mobile
- Account login with read-only, read-and-write, and admin access levels
- Amin and admins can export inventory and backup or restore data
- Admins can add users and manage access levels
The project-local `data` folder is mounted at `/data`. The SQLite database is stored at `data/inventory.db`, and uploaded pictures are stored in `data/uploads`. Keep this folder with the project when moving it to another computer. Existing databases are migrated automatically when the application starts. The project-local `data` folder is mounted at `/data`. The SQLite database is stored at `data/inventory.db`, and uploaded pictures are stored in `data/uploads`. Keep this folder with the project when moving it to another computer. Existing databases are migrated automatically when the application starts.
On first startup, the app creates an `admin` account with password `admin` and an `Amin` account with password `amin`. Set `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `AMIN_PASSWORD`, and `SECRET_KEY` in the environment before starting the app, then change these credentials for any shared deployment. Admins can add further users from **Manage users**. Read-only users can view inventory, read-and-write users can add and edit items, and admins can add, edit, and delete items as well as manage users. Export, backup, and restore are visible to admins and Amin.
Use **Backup data** in the sidebar to download a ZIP containing the database and uploaded pictures. On another computer, start the application and use **Restore data** to upload that ZIP. Restoring replaces the current inventory and pictures. Use **Backup data** in the sidebar to download a ZIP containing the database and uploaded pictures. On another computer, start the application and use **Restore data** to upload that ZIP. Restoring replaces the current inventory and pictures.
## Local development ## Local development
+148 -2
View File
@@ -5,16 +5,23 @@ import tempfile
import uuid import uuid
import zipfile import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, render_template, request, send_file, send_from_directory from flask import Flask, jsonify, redirect, render_template, request, send_file, send_from_directory, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "change-this-secret-key")
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db"))) DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db")))
UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads"))) UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads")))
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
ROLE_READ_ONLY = "read_only"
ROLE_READ_WRITE = "read_write"
ROLE_ADMIN = "admin"
VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN}
def get_db(): def get_db():
@@ -64,6 +71,14 @@ def initialize_db():
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id); CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'read_only',
can_data_tools INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
""" """
) )
columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")} columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")}
@@ -86,6 +101,119 @@ def initialize_db():
WHERE image_filename IS NOT NULL WHERE image_filename IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)""" AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)"""
) )
user_count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count == 0:
now = datetime.now(timezone.utc).isoformat()
connection.execute(
"INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)",
(os.environ.get("ADMIN_USERNAME", "admin"), generate_password_hash(os.environ.get("ADMIN_PASSWORD", "admin")), ROLE_ADMIN, 1, now),
)
connection.execute(
"INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)",
("Amin", generate_password_hash(os.environ.get("AMIN_PASSWORD", "amin")), ROLE_READ_ONLY, 1, now),
)
def current_user():
user_id = session.get("user_id")
if not user_id:
return None
with get_db() as connection:
return connection.execute("SELECT id, username, role, can_data_tools FROM users WHERE id = ?", (user_id,)).fetchone()
def json_auth_error(message, status):
return jsonify({"error": message}), status
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not current_user():
if request.path.startswith("/api/"):
return json_auth_error("Login required.", 401)
return redirect(url_for("login"))
return view(*args, **kwargs)
return wrapped
def permission_required(permission):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
user = current_user()
if not user:
return json_auth_error("Login required.", 401)
allowed = {
"write": user["role"] in {ROLE_READ_WRITE, ROLE_ADMIN},
"delete": user["role"] == ROLE_ADMIN,
"admin": user["role"] == ROLE_ADMIN,
"data_tools": user["role"] == ROLE_ADMIN or bool(user["can_data_tools"]),
}.get(permission, False)
if not allowed:
return json_auth_error("You do not have permission for this action.", 403)
return view(*args, **kwargs)
return wrapped
return decorator
@app.get("/login")
def login():
if current_user():
return redirect(url_for("index"))
return render_template("login.html")
@app.post("/login")
def login_submit():
username = clean(request.form.get("username"))
password = request.form.get("password", "")
with get_db() as connection:
user = connection.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
if not user or not check_password_hash(user["password_hash"], password):
return render_template("login.html", error="Invalid username or password."), 401
session.clear()
session["user_id"] = user["id"]
return redirect(url_for("index"))
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.get("/api/me")
@login_required
def me():
user = current_user()
return jsonify({"id": user["id"], "username": user["username"], "role": user["role"], "can_data_tools": bool(user["can_data_tools"])})
@app.get("/api/users")
@permission_required("admin")
def list_users():
with get_db() as connection:
users = connection.execute("SELECT id, username, role, can_data_tools, created_at FROM users ORDER BY username COLLATE NOCASE").fetchall()
return jsonify([{**dict(user), "can_data_tools": bool(user["can_data_tools"])} for user in users])
@app.post("/api/users")
@permission_required("admin")
def create_user():
data = request.get_json(silent=True) or {}
username = clean(data.get("username"))
password = data.get("password", "")
role = clean(data.get("role")) or ROLE_READ_ONLY
can_data_tools = bool(data.get("can_data_tools"))
if not username or len(password) < 6 or role not in VALID_ROLES:
return jsonify({"error": "Username, a password of at least 6 characters, and a valid level are required."}), 400
try:
with get_db() as connection:
cursor = connection.execute("INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)", (username, generate_password_hash(password), role, int(can_data_tools), datetime.now(timezone.utc).isoformat()))
return jsonify({"id": cursor.lastrowid, "username": username, "role": role, "can_data_tools": can_data_tools}), 201
except sqlite3.IntegrityError:
return jsonify({"error": "That username already exists."}), 409
def clean(value): def clean(value):
@@ -209,16 +337,26 @@ def restore_backup(backup):
@app.route("/") @app.route("/")
@login_required
def index(): def index():
return render_template("index.html") user = current_user()
return render_template("index.html", user=dict(user))
@app.get("/users")
@permission_required("admin")
def users_page():
return render_template("users.html", user=dict(current_user()))
@app.get("/media/<path:filename>") @app.get("/media/<path:filename>")
@login_required
def media(filename): def media(filename):
return send_from_directory(UPLOAD_FOLDER, filename) return send_from_directory(UPLOAD_FOLDER, filename)
@app.get("/api/items") @app.get("/api/items")
@login_required
def list_items(): def list_items():
query = clean(request.args.get("q")) query = clean(request.args.get("q"))
item_type = clean(request.args.get("type")) item_type = clean(request.args.get("type"))
@@ -241,6 +379,7 @@ def list_items():
@app.get("/api/summary") @app.get("/api/summary")
@login_required
def summary(): def summary():
with get_db() as connection: with get_db() as connection:
total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0] total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0]
@@ -251,6 +390,7 @@ def summary():
@app.get("/api/backup") @app.get("/api/backup")
@permission_required("data_tools")
def backup(): def backup():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database:
temporary_database_path = Path(temporary_database.name) temporary_database_path = Path(temporary_database.name)
@@ -271,6 +411,7 @@ def backup():
@app.post("/api/restore") @app.post("/api/restore")
@permission_required("data_tools")
def restore(): def restore():
backup_file = request.files.get("backup") backup_file = request.files.get("backup")
if not backup_file or not backup_file.filename: if not backup_file or not backup_file.filename:
@@ -283,6 +424,7 @@ def restore():
@app.post("/api/items") @app.post("/api/items")
@permission_required("write")
def create_item(): def create_item():
data = item_payload(request_data()) data = item_payload(request_data())
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
@@ -315,6 +457,7 @@ def create_item():
@app.put("/api/items/<int:item_id>") @app.put("/api/items/<int:item_id>")
@permission_required("write")
def update_item(item_id): def update_item(item_id):
data = item_payload(request_data()) data = item_payload(request_data())
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
@@ -349,6 +492,7 @@ def update_item(item_id):
@app.delete("/api/items/<int:item_id>") @app.delete("/api/items/<int:item_id>")
@permission_required("delete")
def delete_item(item_id): def delete_item(item_id):
with get_db() as connection: with get_db() as connection:
images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall() images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall()
@@ -361,6 +505,7 @@ def delete_item(item_id):
@app.post("/api/items/<int:item_id>/images") @app.post("/api/items/<int:item_id>/images")
@permission_required("write")
def add_images(item_id): def add_images(item_id):
files = files_from_request() files = files_from_request()
with get_db() as connection: with get_db() as connection:
@@ -383,6 +528,7 @@ def add_images(item_id):
@app.delete("/api/items/<int:item_id>/images/<int:image_id>") @app.delete("/api/items/<int:item_id>/images/<int:image_id>")
@permission_required("write")
def remove_image(item_id, image_id): def remove_image(item_id, image_id):
with get_db() as connection: with get_db() as connection:
image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone() image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone()
BIN
View File
Binary file not shown.
+14 -7
View File
@@ -1,4 +1,7 @@
const state = { items: [], editingId: null }; const state = { items: [], editingId: null };
const role = document.body.dataset.role;
const canWrite = role === 'admin' || role === 'read_write';
const canDelete = role === 'admin';
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) { async function request(url, options = {}) {
@@ -23,8 +26,9 @@ async function load() {
function renderItems(items) { function renderItems(items) {
const body = $('#inventoryBody'); const body = $('#inventoryBody');
const actions = (itemId) => `${canWrite ? `<button class="small-action" onclick="editItem(${itemId})">Edit</button>` : ''}${canDelete ? `<button class="small-action delete" onclick="removeItem(${itemId})">Delete</button>` : ''}`;
$('#emptyState').style.display = items.length ? 'none' : 'block'; $('#emptyState').style.display = items.length ? 'none' : 'block';
body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join(''); body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions">${actions(item.id)}</div></td></tr>`).join('');
} }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
@@ -38,8 +42,8 @@ window.editItem = (id) => openModal(state.items.find((item) => item.id === id));
window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } }; window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } };
window.removeItem = async (id) => { const item = state.items.find((entry) => entry.id === id); if (!confirm(`Delete "${item.name}"?`)) return; try { await request(`/api/items/${id}`, { method: 'DELETE' }); toast('Item removed from inventory.'); await load(); } catch (error) { toast(error.message); } }; window.removeItem = async (id) => { const item = state.items.find((entry) => entry.id === id); if (!confirm(`Delete "${item.name}"?`)) return; try { await request(`/api/items/${id}`, { method: 'DELETE' }); toast('Item removed from inventory.'); await load(); } catch (error) { toast(error.message); } };
$('#addButton').addEventListener('click', () => openModal()); if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal());
$('#emptyAddButton').addEventListener('click', () => openModal()); if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal());
$('#closeModal').addEventListener('click', closeModal); $('#closeModal').addEventListener('click', closeModal);
$('#cancelButton').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); });
@@ -47,8 +51,11 @@ $('#modal').addEventListener('click', (event) => { if (event.target === $('#moda
ensureImageControls(); ensureImageControls();
$('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; }); $('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; });
$('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } }); $('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } });
$('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','destination','checkout_date','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); }); if ($('#exportButton')) $('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','destination','checkout_date','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); });
$('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } }); if ($('#backupButton')) $('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } });
$('#restoreButton').addEventListener('click', () => $('#restoreInput').click()); if ($('#restoreButton')) $('#restoreButton').addEventListener('click', () => $('#restoreInput').click());
$('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; }); if ($('#restoreInput')) $('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; });
async function loadUsers() { const users = await request('/api/users'); $('#usersList').innerHTML = users.map((user) => `<p class="user-row"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}${user.can_data_tools ? ' · data tools' : ''}</span></p>`).join(''); }
if ($('#usersButton')) $('#usersButton').addEventListener('click', async () => { $('#usersPanel').classList.toggle('hidden'); if (!$('#usersPanel').classList.contains('hidden')) { try { await loadUsers(); } catch (error) { toast(error.message); } } });
if ($('#userForm')) $('#userForm').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); try { await request('/api/users', { method: 'POST', body: JSON.stringify({ username: form.get('username'), password: form.get('password'), role: form.get('role'), can_data_tools: form.get('can_data_tools') === 'on' }) }); event.target.reset(); await loadUsers(); toast('User added.'); } catch (error) { toast(error.message); } });
load().catch((error) => toast(error.message)); load().catch((error) => toast(error.message));
+2
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) {
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
return data;
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character]));
}
function toast(message) {
const element = $('#toast');
element.textContent = message;
element.classList.add('show');
setTimeout(() => element.classList.remove('show'), 2500);
}
async function loadUsers() {
const users = await request('/api/users');
$('#userCount').textContent = `${users.length} ${users.length === 1 ? 'user' : 'users'}`;
$('#usersList').innerHTML = users.map((user) => `<div class="account-row"><div><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}</span></div><span class="account-permission">${user.can_data_tools ? 'Export, backup, restore' : 'Inventory access only'}</span></div>`).join('');
}
$('#userForm').addEventListener('submit', async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await request('/api/users', { method: 'POST', body: JSON.stringify({ username: form.get('username'), password: form.get('password'), role: form.get('role'), can_data_tools: form.get('can_data_tools') === 'on' }) });
event.target.reset();
await loadUsers();
toast('User added.');
} catch (error) {
toast(error.message);
}
});
loadUsers().catch((error) => toast(error.message));
+5 -4
View File
@@ -8,14 +8,15 @@
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body data-role="{{ user.role }}" data-tools="{{ 1 if user.can_data_tools or user.role == 'admin' else 0 }}">
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div> <div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div>
<nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button><button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button><button class="nav-item" id="backupButton"><span class="nav-icon">⇩</span>Backup data</button><button class="nav-item" id="restoreButton"><span class="nav-icon">⇧</span>Restore data</button><input id="restoreInput" class="hidden" type="file" accept=".zip,application/zip"></nav> <nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button>{% if user.role == 'admin' or user.can_data_tools %}<button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button><button class="nav-item" id="backupButton"><span class="nav-icon">⇩</span>Backup data</button><button class="nav-item" id="restoreButton"><span class="nav-icon">⇧</span>Restore data</button><input id="restoreInput" class="hidden" type="file" accept=".zip,application/zip">{% endif %}{% if user.role == 'admin' %}<a class="nav-item" href="{{ url_for('users_page') }}"><span class="nav-icon">♙</span>Manage users</a>{% endif %}</nav>
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Project data</small></div> <div class="sidebar-footer"><span class="status-dot"></span><span>{{ user.username }} · {{ user.role|replace('_', ' ')|title }}</span><small>SQLite · Project data</small><form method="post" action="{{ url_for('logout') }}"><button class="logout-button" type="submit">Sign out</button></form></div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
<header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div><button class="primary-button" id="addButton"><span>+</span> Add item</button></header> <header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div>{% if user.role in ['admin', 'read_write'] %}<button class="primary-button" id="addButton"><span>+</span> Add item</button>{% endif %}</header>
<section class="stats-grid"> <section class="stats-grid">
<article class="stat-card accent"><div class="stat-label">Total assets <span class="stat-symbol">◉</span></div><strong id="totalCount">0</strong><small>Across all categories</small></article> <article class="stat-card accent"><div class="stat-label">Total assets <span class="stat-symbol">◉</span></div><strong id="totalCount">0</strong><small>Across all categories</small></article>
<article class="stat-card"><div class="stat-label">Available <span class="stat-symbol green">●</span></div><strong id="availableCount">0</strong><small>Ready to use</small></article> <article class="stat-card"><div class="stat-label">Available <span class="stat-symbol green">●</span></div><strong id="availableCount">0</strong><small>Ready to use</small></article>
@@ -25,7 +26,7 @@
<section class="inventory-panel"> <section class="inventory-panel">
<div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div> <div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div>
<div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select><select id="statusFilter"><option value="">All status</option><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></div> <div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select><select id="statusFilter"><option value="">All status</option><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></div>
<div class="table-wrap"><table><thead><tr><th>Item</th><th>Type</th><th>Identifiers</th><th>Location</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody id="inventoryBody"></tbody></table><div class="empty-state" id="emptyState"><div class="empty-icon">⌁</div><h3>No items found</h3><p>Adjust your search or add the first item to your inventory.</p><button class="secondary-button" id="emptyAddButton">Add first item</button></div></div> <div class="table-wrap"><table><thead><tr><th>Item</th><th>Type</th><th>Identifiers</th><th>Location</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody id="inventoryBody"></tbody></table><div class="empty-state" id="emptyState"><div class="empty-icon">⌁</div><h3>No items found</h3><p>Adjust your search or add the first item to your inventory.</p>{% if user.role in ['admin', 'read_write'] %}<button class="secondary-button" id="emptyAddButton">Add first item</button>{% endif %}</div></div>
</section> </section>
</main> </main>
</div> </div>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stockroom | Sign in</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="login-page">
<main class="login-card">
<div class="brand-mark">S</div>
<p class="eyebrow">Stockroom</p>
<h1>Sign in</h1>
{% if error %}<p class="login-error">{{ error }}</p>{% endif %}
<form method="post">
<label>Username<input name="username" autocomplete="username" required autofocus></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary-button" type="submit">Sign in</button>
</form>
</main>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stockroom | User accounts</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="account-page">
<div class="app-shell">
<aside class="sidebar">
<div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div>
<nav><a class="nav-item" href="{{ url_for('index') }}"><span class="nav-icon">▦</span>Inventory</a><a class="nav-item active" href="{{ url_for('users_page') }}"><span class="nav-icon">♙</span>Manage users</a></nav>
<div class="sidebar-footer"><span class="status-dot"></span><span>{{ user.username }} · Admin</span><small>SQLite · Project data</small><form method="post" action="{{ url_for('logout') }}"><button class="logout-button" type="submit">Sign out</button></form></div>
</aside>
<main class="main-content">
<header class="topbar"><div><p class="eyebrow">Administration / Access</p><h1>User accounts</h1></div></header>
<section class="users-panel account-form-panel"><div class="panel-heading"><div><h2>Add a user</h2><p>Create an account and choose what it can do.</p></div></div><form id="userForm" class="user-form"><label>Username<input name="username" autocomplete="off" required></label><label>Temporary password<input name="password" type="password" minlength="6" required></label><label>Access level<select name="role"><option value="read_only">Read only</option><option value="read_write">Read and write</option><option value="admin">Admin</option></select></label><label class="checkbox-label"><input name="can_data_tools" type="checkbox"> Allow export, backup, and restore</label><button class="primary-button" type="submit">Add user</button></form></section>
<section class="users-panel"><div class="panel-heading"><div><h2>Accounts</h2><p>Current users and their access.</p></div><span class="item-count" id="userCount">0 users</span></div><div id="usersList" class="accounts-list"></div></section>
</main>
</div>
<div class="toast" id="toast"></div>
<script src="{{ url_for('static', filename='users.js') }}"></script>
</body>
</html>