From 7d38d03d6248b687cf5f4421086452569effbaa6 Mon Sep 17 00:00:00 2001 From: Tero Huttunen Date: Thu, 27 Aug 2026 01:16:16 +0300 Subject: [PATCH] Added accounting and more example laptops --- .vscode/tasks.json | 18 ++++++ README.md | 5 ++ app.py | 150 ++++++++++++++++++++++++++++++++++++++++++- data/inventory.db | Bin 49152 -> 69632 bytes static/app.js | 21 ++++-- static/style.css | 2 + static/users.js | 40 ++++++++++++ templates/index.html | 9 +-- templates/login.html | 22 +++++++ templates/users.html | 28 ++++++++ 10 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 static/users.js create mode 100644 templates/login.html create mode 100644 templates/users.html diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..220ccc9 --- /dev/null +++ b/.vscode/tasks.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index d698b05..dea2c47 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,14 @@ Open http://localhost:5000. The SQLite database is stored in the project-local ` - Media field: format - CSV export of the currently visible inventory - 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. +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. ## Local development diff --git a/app.py b/app.py index 7aeaae2..c487d2f 100644 --- a/app.py +++ b/app.py @@ -5,16 +5,23 @@ import tempfile import uuid import zipfile from datetime import datetime, timezone +from functools import wraps 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 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"))) UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads"))) ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} 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(): @@ -64,6 +71,14 @@ def initialize_db(): created_at TEXT NOT NULL ); 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)")} @@ -86,6 +101,119 @@ def initialize_db(): WHERE image_filename IS NOT NULL 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): @@ -209,16 +337,26 @@ def restore_backup(backup): @app.route("/") +@login_required 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/") +@login_required def media(filename): return send_from_directory(UPLOAD_FOLDER, filename) @app.get("/api/items") +@login_required def list_items(): query = clean(request.args.get("q")) item_type = clean(request.args.get("type")) @@ -241,6 +379,7 @@ def list_items(): @app.get("/api/summary") +@login_required def summary(): with get_db() as connection: total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0] @@ -251,6 +390,7 @@ def summary(): @app.get("/api/backup") +@permission_required("data_tools") def backup(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database: temporary_database_path = Path(temporary_database.name) @@ -271,6 +411,7 @@ def backup(): @app.post("/api/restore") +@permission_required("data_tools") def restore(): backup_file = request.files.get("backup") if not backup_file or not backup_file.filename: @@ -283,6 +424,7 @@ def restore(): @app.post("/api/items") +@permission_required("write") def create_item(): data = item_payload(request_data()) if not data["name"] or not data["item_type"]: @@ -315,6 +457,7 @@ def create_item(): @app.put("/api/items/") +@permission_required("write") def update_item(item_id): data = item_payload(request_data()) if not data["name"] or not data["item_type"]: @@ -349,6 +492,7 @@ def update_item(item_id): @app.delete("/api/items/") +@permission_required("delete") def delete_item(item_id): with get_db() as connection: 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//images") +@permission_required("write") def add_images(item_id): files = files_from_request() with get_db() as connection: @@ -383,6 +528,7 @@ def add_images(item_id): @app.delete("/api/items//images/") +@permission_required("write") def remove_image(item_id, image_id): with get_db() as connection: image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone() diff --git a/data/inventory.db b/data/inventory.db index 6333c75196653522d724f76b49a711d32958de5b..88f0d91bcc28dc8b5ffa78e51e5a5239d8c4ea98 100644 GIT binary patch literal 69632 zcmeHQ3ve9CS>Dx3+Fk9=jNj{*tnv9NTaSLsQ{yt zJ-t1n*)^%+68F}fr2p@of9Cu8@9w{Q{%$W`oU7Cwxm2yKT6G!6w#DM{*h8`$i^U!y z|8FP%w>|^pkN%cGe#d=(hyD3bY~raKLiVk(%%BoWzE}Ev>8b3)nGa-UhCVCigkKm` zgi6?@T+bQc3~&ZG11~iOp1wJh%;j?NpM11#6;_{Pd-(@}v2y^7Q4Ug>&bLh57vXr7`9z&)S-`>c~s^ zD@*eE1@iypxjFgr`EwU9=jF2tb93IE&o7*vUd*#suUi`%PgZNi*kEy*Wpj#XT)uC3fY!Cq%uYs*EeZY|fV)s>Al$o8|gjDUbkiQT$WTqa{M zg^!|P^!3N%$=vDF@n;ha;wx)6owa(kc6*r&;j0@RVjoo*9fjYsYGJE{1?N58a{cyt z2dV5A3pH!4*dTx{TdfwIm5?&x8Wn40d2MsG;MAG~HvNJ%L;S$0FW0T|)?eGGTlLM2 z=69I-J28FJs;qdfAK(NlRokjps%s&Sy1rSni4QuSqcr(opK+~PcbfNR%k1^d5X&`d zHKe2*dd&v$9;+Po9IH;TVlCeZ4L!FEc$^TwWxu((?jerw@G-fvN}gxAR9SJ_o;4<~ z*DAInlfGqcOfEVb^~#!$V%fgt*w?F@^}Cdgt|XH=^>o}FsH_#8Tb1Ii<-7jhtlGPB zqwS>bG?sJcXYyC%pbO-M^K!>UC!3~5uMZ}3klZa9a5vBGJ1$FZaj!-z3Aj_c35~it z2AbF|2Hb7Kv50$h+;4D;`!z2J!sgLGCO2B-nA~W5V=~pw#^iQe8okgz==Doi8{N{z zjTQ2^%Nx#(O=rz^I>Z60Tk0r0*|;mYa#VOr?9(r`aqs{1JfoSx+=IDztWj%sU|XB@ zYC~RbjFro<@k^Ax9ZPxlx}8quUNIhbcLvmSqn&ItI(}budyQvfXp8#xUDi?QjZ059 z{H#(OefsfKGB-XRe}=d?>E}9c(J7~?O6@HDo((p4o{k+^FXbQ3U&^09n_pzwC;{Uk zoypJTNqj(}gR|2!0RaNlQMAQ*w@SDu;cOsaj%Atd6W7+>6@OrNz!+ke|Q4_a0WO7oB_@NXMi)n8Q=_X z1~>zp0nPwtfHUv|z(DpG0g>4?M1JDTW=#4I=>_Q{(r-#XFTGZ(NSCA^l8#D(_)YOe z@nhl##J7vD6IaB`;>*Po;x^&Ggnt%3A$(ByMd9^ARk$Kd3#Wvg+3#jQpZ!$!ceB5o zePgzkePwngJD%N>`Jc=eGM~=;LFS#AXUT2&hcmz#;0$mEI0Kvk&H!hCGjRVguqOfI z`76`&7v}QgbJG`=7A}kf6=*?f#Z&{;h7zdOaiCg5foc^7sud8Z#*=|+G!dwV!;Xt)=Fi?#T1gg>gKsDSKs0Mq3)X>ZYs*OE?YJGR0TH6(Xz!~5Sa0WO7oB_@NXMi)n8F+~?urqNwK9iqc@UKlUII{X|>6q1Nh}Ef( z)hU40;Yn79CRiODXLVqV(UGMGR%e`Ib^0i)(@wHF^#rR^jIO; zM-~@YoguS2eT3C%hgh9D%<7bbtPUSwb!b1UgZo$=*vsh1@&T(e_OLpAH>=Zju{w1p zt5bHcI=r3Lp>3=VN~{iujE*c2usS2l>huh&(}q}`nr3xMiq+vHt3!jV4h|#|v9ut4 zDVC5v6q7zAeOCIzp0nPwtfHS}u;0$mEI0Kx4CIdU;WVNni zcfYo)FEQ>{HV$|2&hAS$E=ng7xp?EOi9{b!#HIIm>g~;6jc@QDoB_@NXMi)n8Q=_X z1~>zp0nPwtfHS}u;0*jfF|h4Ya&jLeCynNBS*z>hjN&z@MXHCYPn~X4^@_I0MpppMCJQ0(=A^n^5x6&uc4uId1-X*}q630bZ+$j!{ z69T>@yeRyo@DZ{*;Mav;65cF4BRolN!#|t>&H!hCGr$?(3~&ZG1DpZQ0B7JnU?7_q zj-Rcru5Z?zn&uZ&zo7U9>=%$ z@C)1h!ZyDk`32E02!0{!7czcf$SEygCZ)sDPH8~=miR^S@5E1ve?m?Kd?%Uvzd^h$UKd|2 zE{M}27LSQJF)e&Y_%fOKe_Hr6;kSi%3vUzNB>XrzBhV5Sg}k8O2hE&A(BdnqYLNqZ=1 zHzn<&q@9$sgOavW(l$zxC`qIwfs(S6l%b>{N=j2wijtC)G)PH}^Z(kuD@bpNCB(mp zNiUP0C%^cIGr$?(3~&ZG1DpZQ0B3+Rz!~5Sa0WO7-(L(^{e!XeU0O4ZsutkM()CI5uxWtpEYgu!d|!DVpSFmQu7;mHb)M^@c6)7T5$vpk0h7$Z7mg zSDSVT(acHtk0qM-!o;dlgizB|hkz-V7Pc%C6HKU7H1*P5I8YC~mx3ouQvoJu-fKwu zE;;}2%Q5L&($~o^{^1O81~>zp0nPwtfHS}u;0$mEI0Kvk&H!iN`<;RQL^|H@{YZE} zyrcj61@dEi>`F}hD%rI^oK-T94i(b3Q_m&eJ^051pXvYi{#as9-&p(%`ASb8x4bJC zn;iCzD;PO_SU8ar4h!@1v6v9+IJ^LAt*rJll8K+1YAq>;i9q<4EH*eg<`sT$J4>(pQFmc=fS zf(WFn_tFQ*KoElTZjS_dMGDFKIohI5{pD^+$i>FH7-h@|pp5eZld4i+h5&7MNWknF zC@@s*&Z~r=4hj0o-BPwYphg5x#&>~112sZ`w)-Ps^a>O}6PsJqsK4ATWx4~ZM*wA9 z7w9xlJp^dGI|6!7K%qH7UMWFS4{4+wF$6=g-5LG(%}(^439u>;Sm9%s|pSsN;}HkQmQ+kQPC0Wx^#2|BBLYFTXaN# zXc|;;*!a>>?v|3>0gVcdSl6Y4BM=!Jf!=~60z@@|ijD9#>MwUogWUm*ij7#;rDG!y z85@D#Vj~YI#)i5TP+z%Q8t4vaRA|JyE*%4(vWWN~WdrGtP=rcUB!)Zk(aFPR`Kt4$<3ao}$+=(zNwR!Q{0)!gu?f z#m2mInvCXP96<8l*_vfv-;k?ovUgCQUA^hlr;wOY8GOJs@YaKo1@SSEZXJj6iEwEG^`qT=6uch{~Ke{ z9&t=Klf9Bz8+s=F_SE~6A02#Q;Ohga{zD0+?_zu<_Qsy3|Gj_Nef*dgwC+9;wP1kT zy~_0B<;DE1b*EN8Q>|W?�ZVN=$brIHEg20_0f;7?5;Vf+M;T0W?0M+i0By>ikxAg9%ont34})I#KU94SMHWax4-o zMoqF<@1-YM=x(q>ueP^)B-Gm^iy$TOINA!SzuYY!>=J3zEQ|GCdX|MEXIZH0fwa}* zD2fWqE$Y-??v@X9iL@OMg}gywy_b%QP()k=$5GegX$vXIQ&o(BxG1RHE${CVX*(_o zLCShB9UP&E;D{jYZJtHEm#n<%TOswAyXAddB5empAxK&8rDG%%5hD?#J&&hQGqxOM z{N-+WZ55rWiiT>JNcN{$DwF_l zATAYbSkmpHYFoh3OAaa+TEQ|DrJy^o0I{VY$1WHJ6X6oHp{Y5JQPPPWgdGzg3!2bI zM$NH`%hk1&+x~@U@@foDVQm6n09&tF+#l&n#dZ^P>G|o^%GyRJvigl%SMxP<^YSB= zC(0{xmybeXSuc|OFhrgZ+8E(NNhtxW6>WfuMICFZ;aFtTzNTxIWkZ9!EidX&cM2-P zwpy}v&2|V}!?GP)!zHJn85IAayj2W>R=Iicj9J^XpLptqGJA1m^{8nAa^yg%WRp_? zNW~!w6-z~j`a0WO7oB_@NXMi)n8Q=_X1~>zp0nPwt;HAxg_b>mo7Mc>N#O}DaF4LDv zZ_@HnpyOw#w(0kG!PJJ=epFB1=HE^}Rns`&+^YM?wKKIgwxz*FT zuhG10j@?J1^6u4kF4DB^)E1wtl;fAgK{4NT`RjvSz|L*0cMuluKBD;E+trH5JZlpw{8=>*;Q^;BvRk9h$2uL>yDtF7s z!x()mh!U2PeFe&Aty-bF*4ZW%Iwf0)NXaHhyG|i%AyE*5ARt*TsN8)G^pFl8h^)`;iH{@ z{m7>Eq*IwE`xP4l$czj%rajWB*Gfd{H38XmI(-WyIkH*DTeL@ixqD^Y1GzoAJ>E^d zo}H~(t3mZg=UOWfxz+?)*ZK2JXvPHDRY*KAtR>RrZegs^5|2f?XFI-Gv{pAZ*UGcj zigoL@?A0NYlB>`$juIK;z^+s0TObXMy!Q(@0VAl~edTEnOkhmS=%rBmsuNIx! zT0j+II=sP=;SKCDyfG%cL&t*-r)i<{m%GJNjVg>B`*AzGd3f`QN_}H|R^6zU>WiDT5_yp=-@(cR<(Q6f zaAb_r{b4O_sDSLS3ytQC3DsBbzWRg*HFw8rheHo}!|fPndVPJxnYZl5urXb!$@8qw z(s7P&dwt-JGqT%PZ&)R!wm90Yg^wJw5A}ep9YN*pi>uS=lWlk_-f)E zi7Vpm#8BTy`)>7t_zUr$iN7ND_1L?_6T)8!uM;4-4gYWkI0Kvk&cOY}z@_A*x84(P zjox>H8)SxTZDoc`=BS9nVJ3|blg!Ra=1c}T%%mP-k_khZ!w*KnOllz}S>SZIhqOas zCe;v=EO0uwLh5jsNhQQ23!IK z!0BZG1owrR1R*9_;Pj*$fxRInVOZ;7fHRwjb73aKS`P!9*{!=L%w$;WVSqE+Vt0p` z3~N0saJuzCZC9Adu-3x@r&|wHcZQh^YdtJ*y7fS1N0`a5*24m)TMxwB!%T*?9u_#= zdLY^sW-_exu)yio1ECaVGOYEm!0FZlff$R$zjS|%w)ZdEi^=hzI3ujtFg_0GW&?0C z#9~;hVO$*0tp=dM5Q|}rhVgJfHyQv3UY11F-i05 zIWbTrw!FMN#+K202A(oUo{PLrWNhj8km`m*CX`BNO?01AhDh7+Qo5MuQ+rL`f{V#X zFL#Quq}+R!6qrp;dPz!*P25{L(&Ri@vJLVMbT4^76K9Ek1aSuEx=yeeCr@;MULY_7+#ehFmhEwhG$$|Ff_W_c z9?~jCiN}tRtOmxIcJKKMCrQ0}=>v>S^~93)Vz}XqRRNp+{Ep`yq0%!Ox+4YW document.querySelector(selector); async function request(url, options = {}) { @@ -23,8 +26,9 @@ async function load() { function renderItems(items) { const body = $('#inventoryBody'); + const actions = (itemId) => `${canWrite ? `` : ''}${canDelete ? `` : ''}`; $('#emptyState').style.display = items.length ? 'none' : 'block'; - body.innerHTML = items.map((item) => `
${item.images?.length ? `${item.images.length > 1 ? `+${item.images.length - 1}` : ''}` : '
◌
'}
${escapeHtml(item.name)}${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}
${escapeHtml(item.item_type)}${escapeHtml(item.serial_number || 'No serial')}${escapeHtml(item.asset_tag || 'No asset tag')}${escapeHtml(item.location || 'Unassigned')}${escapeHtml(item.status)}
`).join(''); + body.innerHTML = items.map((item) => `
${item.images?.length ? `${item.images.length > 1 ? `+${item.images.length - 1}` : ''}` : '
◌
'}
${escapeHtml(item.name)}${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}
${escapeHtml(item.item_type)}${escapeHtml(item.serial_number || 'No serial')}${escapeHtml(item.asset_tag || 'No asset tag')}${escapeHtml(item.location || 'Unassigned')}${escapeHtml(item.status)}
${actions(item.id)}
`).join(''); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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.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()); -$('#emptyAddButton').addEventListener('click', () => openModal()); +if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal()); +if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal()); $('#closeModal').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); @@ -47,8 +51,11 @@ $('#modal').addEventListener('click', (event) => { if (event.target === $('#moda 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'; }); $('#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); }); -$('#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()); -$('#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 ($('#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); }); +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); } }); +if ($('#restoreButton')) $('#restoreButton').addEventListener('click', () => $('#restoreInput').click()); +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) => `

${escapeHtml(user.username)}${escapeHtml(user.role.replace('_', ' '))}${user.can_data_tools ? ' · data tools' : ''}

`).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)); diff --git a/static/style.css b/static/style.css index 35d5e82..3991d2b 100644 --- a/static/style.css +++ b/static/style.css @@ -1,3 +1,5 @@ .item-cell{display:flex;align-items:center;gap:11px}.item-thumb{width:38px;height:38px;object-fit:cover;border-radius:6px;background:#e8efeb;flex:none}.item-thumb.placeholder{display:grid;place-items:center;color:#8aa39b;font-size:20px}.image-count{background:#e7f3f0;color:var(--teal);font-size:10px;font-weight:700;padding:4px 5px;border-radius:4px;margin-left:-7px}.image-field input{padding:7px 0!important;border:0!important}.image-field span{display:block;color:#8c9994;font-size:10px;font-weight:400;margin-top:4px}.image-gallery{display:flex;flex-wrap:wrap;gap:9px;margin-top:11px}.gallery-item{position:relative;width:82px;height:64px}.gallery-item img{width:100%;height:100%;object-fit:cover;border-radius:6px;border:1px solid var(--line)}.gallery-delete{position:absolute;top:-7px;right:-7px;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:#193f3b;color:#fff;cursor:pointer;line-height:18px}.hidden{display:none!important} .row-actions{opacity:1!important} +.login-page{min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#f6f8f5,#dcece5)}.login-card{width:min(390px,calc(100% - 32px));padding:34px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:22px}.login-card h1{font-family:'Space Grotesk';margin:0 0 24px}.login-card form{display:grid;gap:16px}.login-card label,.user-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.user-form input,.user-form select{width:100%;padding:11px;border:1px solid var(--line);border-radius:6px;background:#fff}.login-card .primary-button{margin-top:8px}.login-error{padding:10px;background:#fff0ed;color:#a33e2b;border-radius:6px;font-size:12px}.logout-button{margin-top:12px;border:0;background:transparent;color:#90afa9;padding:0;cursor:pointer;font-size:11px}.users-panel{margin-top:24px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:8px}.user-form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.user-form label{min-width:150px}.user-form .checkbox-label{display:flex;align-items:center;gap:7px;min-width:auto}.user-form .checkbox-label input{width:auto}.user-row{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 0;margin:18px 0 0;font-size:12px}.user-row span{color:var(--muted)} +.account-page .nav-item{text-decoration:none}.account-form-panel{margin-top:0}.account-row{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding:15px 0;font-size:13px}.account-row strong,.account-row span{display:block}.account-row div span{color:var(--muted);font-size:11px;margin-top:4px;text-transform:capitalize}.account-permission{color:var(--teal);font-size:11px} *{box-sizing:border-box} :root{--ink:#17211f;--muted:#76817d;--line:#e5e9e5;--paper:#f6f8f5;--white:#fff;--teal:#0e7770;--teal-dark:#075c57;--orange:#e9823d;--shadow:0 14px 40px rgba(31,52,45,.07)} body{margin:0;background:var(--paper);color:var(--ink);font-family:'DM Sans',sans-serif}button,input,select,textarea{font:inherit}.app-shell{display:flex;min-height:100vh}.sidebar{width:242px;background:#193f3b;color:#d9e8e3;padding:26px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:11px;margin:0 10px 58px}.brand-mark{width:35px;height:35px;border-radius:10px;background:#f0a15d;color:#193f3b;display:grid;place-items:center;font-family:'Space Grotesk';font-weight:700;font-size:20px}.brand strong{display:block;font-family:'Space Grotesk';font-size:17px;color:#fff}.brand span{display:block;font-size:10px;color:#92b1aa;margin-top:2px}.nav-item{width:100%;padding:12px 13px;border:0;border-radius:8px;background:transparent;color:#9dbab4;text-align:left;cursor:pointer;font-weight:600;display:flex;gap:12px;align-items:center;margin-bottom:5px}.nav-item.active,.nav-item:hover{background:#275852;color:#fff}.nav-icon{font-size:20px;width:18px;text-align:center}.sidebar-footer{margin-top:auto;border-top:1px solid #2a5b55;padding:18px 10px 2px;color:#90afa9;font-size:11px}.sidebar-footer small{display:block;color:#6f958d;margin-top:8px}.status-dot{display:inline-block;width:7px;height:7px;background:#75d19a;border-radius:50%;margin-right:7px}.main-content{flex:1;padding:42px 48px;max-width:1500px}.topbar{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px}.eyebrow{color:var(--teal);text-transform:uppercase;letter-spacing:.13em;font-size:10px;font-weight:700;margin:0 0 8px}.topbar h1,.panel-heading h2,.modal h2{font-family:'Space Grotesk';margin:0;letter-spacing:-.03em}.topbar h1{font-size:34px}.primary-button,.secondary-button{border:0;border-radius:7px;padding:11px 16px;font-weight:700;cursor:pointer}.primary-button{background:var(--orange);color:#fff;box-shadow:0 5px 12px #e9823d35}.primary-button:hover{background:#d96d2c}.primary-button span{font-size:18px;margin-right:5px}.secondary-button{background:#eef2ee;color:var(--teal-dark)}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:30px}.stat-card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:19px 20px;box-shadow:var(--shadow)}.stat-card.accent{border-top:3px solid var(--orange);padding-top:17px}.stat-label{font-size:12px;color:var(--muted);font-weight:600}.stat-symbol{float:right;color:#aeb9b5}.stat-symbol.green{color:#5bbd84}.stat-symbol.amber{color:#e6a14d}.stat-symbol.red{color:#d86c62}.stat-card strong{font-family:'Space Grotesk';display:block;font-size:31px;margin:11px 0 3px}.stat-card small{font-size:11px;color:#a2aaa7}.inventory-panel{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;padding:24px 25px 19px}.panel-heading h2{font-size:21px}.panel-heading p{margin:5px 0 0;color:var(--muted);font-size:12px}.item-count{font-size:12px;color:var(--muted);background:#f1f4f1;border-radius:20px;padding:6px 11px}.toolbar{display:flex;gap:10px;padding:0 25px 19px}.search-box{border:1px solid var(--line);border-radius:6px;display:flex;align-items:center;flex:1;max-width:390px;padding:0 11px;color:#a1aca8}.search-box span{font-size:22px;margin-right:7px}.search-box input{border:0;outline:0;width:100%;padding:10px 0;font-size:12px}.toolbar select{border:1px solid var(--line);border-radius:6px;color:#697571;background:#fff;padding:0 10px;font-size:12px;min-width:125px;outline:none}.table-wrap{border-top:1px solid var(--line);overflow-x:auto}table{border-collapse:collapse;width:100%;min-width:750px}th{text-align:left;color:#96a19d;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700;padding:13px 25px}td{padding:15px 25px;border-top:1px solid #eef1ee;font-size:12px;vertical-align:middle}.item-title{font-weight:700;display:block;color:#23322e}.item-sub{display:block;color:#9aa49f;font-size:11px;margin-top:4px}.type-pill{display:inline-block;color:var(--teal);background:#e7f3f0;border-radius:4px;padding:5px 8px;font-size:10px;font-weight:700}.identifier{color:#5d6c67;font-family:monospace;font-size:11px}.identifier em{color:#a4ada9;font-style:normal;display:block;margin-top:4px}.location{color:#586762}.status{font-size:11px;font-weight:700;white-space:nowrap}.status:before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#65bd84;margin:0 7px 1px 0}.status.checked-out:before{background:#e4a04d}.status.maintenance:before{background:#da756b}.status.retired:before{background:#9ca7a3}.row-actions{opacity:0;display:flex;justify-content:flex-end;gap:4px}.inventory-row:hover .row-actions{opacity:1}.small-action{border:0;background:#f1f4f1;color:#52716b;border-radius:5px;padding:6px 8px;font-size:11px;cursor:pointer}.small-action.delete{color:#b65c55}.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}.empty-icon{font-size:38px;color:#bdd0c9}.empty-state h3{font-family:'Space Grotesk';color:var(--ink);margin:7px 0}.empty-state p{font-size:12px;margin:0 0 17px}.modal-backdrop{display:none;position:fixed;inset:0;background:#183d3b66;z-index:5;align-items:center;justify-content:center;padding:20px}.modal-backdrop.open{display:flex}.modal{background:#fff;width:min(680px,100%);max-height:92vh;overflow-y:auto;border-radius:11px;box-shadow:0 25px 70px #133f3b35}.modal-header{display:flex;justify-content:space-between;align-items:flex-start;padding:24px 28px 18px;border-bottom:1px solid var(--line)}.modal h2{font-size:24px}.icon-button{border:0;background:transparent;font-size:26px;color:#8c9994;cursor:pointer}.modal form{padding:23px 28px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}.form-grid label{font-size:11px;font-weight:700;color:#5d6d67}.form-grid input,.form-grid select,.form-grid textarea{display:block;width:100%;border:1px solid #dfe6e1;border-radius:6px;padding:9px 10px;margin-top:6px;outline:none;font-size:12px;background:#fff;color:var(--ink)}.form-grid input:focus,.form-grid select:focus,.form-grid textarea:focus{border-color:var(--teal)}.form-grid textarea{resize:vertical}.full{grid-column:1/-1}.form-section{border-top:1px solid var(--line);padding-top:17px;margin-top:3px;text-transform:uppercase;letter-spacing:.1em;color:var(--teal)!important}.form-section small{color:#9ba7a2;text-transform:none;letter-spacing:0;margin-left:5px;font-weight:400}.form-actions{display:flex;justify-content:flex-end;gap:9px;border-top:1px solid var(--line);margin-top:22px;padding-top:18px}.toast{position:fixed;right:25px;bottom:25px;background:#193f3b;color:white;padding:13px 17px;border-radius:7px;font-size:12px;box-shadow:var(--shadow);transform:translateY(20px);opacity:0;transition:.2s;z-index:10}.toast.show{transform:translateY(0);opacity:1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:800px){.sidebar{width:68px;padding:20px 10px}.brand{margin:0 7px 48px}.brand>div:last-child,.nav-item:not(.active)::after,.nav-item{font-size:0}.nav-item.active{font-size:0}.nav-icon{font-size:20px}.sidebar-footer{font-size:0;padding-left:4px}.sidebar-footer small{display:none}.main-content{padding:28px 18px}.stats-grid{grid-template-columns:1fr 1fr}.topbar h1{font-size:28px}.toolbar{flex-wrap:wrap}.search-box{max-width:none;flex-basis:100%}.toolbar select{flex:1}.panel-heading{padding-left:18px;padding-right:18px}.toolbar{padding-left:18px;padding-right:18px}th,td{padding-left:18px;padding-right:18px}.modal form,.modal-header{padding-left:20px;padding-right:20px}}@media(max-width:480px){.form-grid{grid-template-columns:1fr}.full{grid-column:auto}.stats-grid{gap:8px}.stat-card{padding:13px}.stat-card strong{font-size:24px}.stat-card small{font-size:9px}.topbar{align-items:flex-start;gap:10px;flex-direction:column}} diff --git a/static/users.js b/static/users.js new file mode 100644 index 0000000..b7b58fb --- /dev/null +++ b/static/users.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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) => ``).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)); diff --git a/templates/index.html b/templates/index.html index 61d96b6..ce61753 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,14 +8,15 @@ +
-

Operations / Stockroom

Inventory

+

Operations / Stockroom

Inventory

{% if user.role in ['admin', 'read_write'] %}{% endif %}
Total assets ◉
0Across all categories
Available ●
0Ready to use
@@ -25,7 +26,7 @@

All inventory

Search, filter, and manage every item in your stockroom.

0 items
-
ItemTypeIdentifiersLocationStatusActions
⌁

No items found

Adjust your search or add the first item to your inventory.

+
ItemTypeIdentifiersLocationStatusActions
⌁

No items found

Adjust your search or add the first item to your inventory.

{% if user.role in ['admin', 'read_write'] %}{% endif %}
diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..4aada4f --- /dev/null +++ b/templates/login.html @@ -0,0 +1,22 @@ + + + + + + Stockroom | Sign in + + + +
+
S
+

Stockroom

+

Sign in

+ {% if error %}{% endif %} +
+ + + +
+
+ + diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..bf11f2c --- /dev/null +++ b/templates/users.html @@ -0,0 +1,28 @@ + + + + + + Stockroom | User accounts + + + + + + +
+ +
+

Administration / Access

User accounts

+ +

Accounts

Current users and their access.

0 users
+
+
+
+ + +