diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97e4f15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsqlcipher-dev sqlcipher gcc python3-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +RUN mkdir -p /data +EXPOSE 8765 + +CMD ["gunicorn", "-b", "0.0.0.0:8765", "--timeout", "120", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..4c2058e --- /dev/null +++ b/app.py @@ -0,0 +1,435 @@ +from flask import Flask, render_template, request, jsonify +import hashlib +import os +import random +import secrets +import shutil +import string +import threading +from datetime import datetime, timedelta, timezone + +import pysqlcipher3.dbapi2 as sqlcipher + +app = Flask(__name__) +app.secret_key = secrets.token_hex(32) + +DB_PATH = os.environ.get("PINVAULT_DB", "/data/pinvault.db") +LOCAL_BACKUP_DIR = "/data/backups" +NAS_BACKUP_DIR = "/mnt/aidata/pinvault" +BACKUP_INTERVAL = 3600 +MAX_BACKUPS = 168 +RECOVERY_CODE_COUNT = 4 +RECOVERY_CODE_LENGTH = 64 +ALPHANUMERIC = string.ascii_letters + string.digits + + +def dict_factory(cursor, row): + d = {} + for idx, col in enumerate(cursor.description): + d[col[0]] = row[idx] + return d + + +def _derive_sqlcipher_key(bcrypt_hash: str) -> str: + return hashlib.sha256(bcrypt_hash.encode()).hexdigest()[:64] + + +def get_conn(): + conn = sqlcipher.connect(DB_PATH) + conn.row_factory = dict_factory + conn.execute(f'PRAGMA key = "{app.master_key}"') + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("SELECT count(*) FROM sqlite_master") + return conn + + +def hash_val(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def generate_recovery_code(): + return "".join(secrets.choice(ALPHANUMERIC) for _ in range(RECOVERY_CODE_LENGTH)) + + +def now_utc(): + return datetime.now(timezone.utc) + + +def init_db(): + with get_conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS pins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL DEFAULT '', + pin_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + lock_until TEXT NOT NULL, + bypassed_count INTEGER NOT NULL DEFAULT 0, + revealed INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS recovery_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pin_id INTEGER NOT NULL, + code_hash TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (pin_id) REFERENCES pins(id) + ) + """) + try: + conn.execute("ALTER TABLE pins ADD COLUMN revealed INTEGER NOT NULL DEFAULT 0") + conn.commit() + except Exception: + pass + + +def _backup_db(dest_path: str) -> bool: + try: + shutil.copy2(DB_PATH, dest_path) + return True + except Exception: + if os.path.exists(dest_path): + os.remove(dest_path) + return False + + +def _cleanup_backups(directory: str): + try: + files = sorted( + [f for f in os.listdir(directory) if f.startswith("pinvault-backup-") and f.endswith(".db")], + reverse=True, + ) + for old in files[MAX_BACKUPS:]: + os.remove(os.path.join(directory, old)) + except Exception: + pass + + +def _do_backup(): + ts = now_utc().strftime("%Y%m%d-%H%M%S") + filename = f"pinvault-backup-{ts}.db" + + os.makedirs(LOCAL_BACKUP_DIR, exist_ok=True) + os.makedirs(NAS_BACKUP_DIR, exist_ok=True) + + local_path = os.path.join(LOCAL_BACKUP_DIR, filename) + nas_path = os.path.join(NAS_BACKUP_DIR, filename) + + ok = _backup_db(local_path) + if ok: + shutil.copy2(local_path, nas_path) + _cleanup_backups(LOCAL_BACKUP_DIR) + _cleanup_backups(NAS_BACKUP_DIR) + return ok + return False + + +def _backup_loop(): + while True: + _do_backup() + timer = threading.Timer(BACKUP_INTERVAL, _backup_loop) + timer.daemon = True + timer.start() + break + + +def _get_backup_list(): + result = [] + for d in [LOCAL_BACKUP_DIR, NAS_BACKUP_DIR]: + location = "local" if d == LOCAL_BACKUP_DIR else "nas" + try: + for f in sorted(os.listdir(d)): + if f.startswith("pinvault-backup-") and f.endswith(".db"): + path = os.path.join(d, f) + size = os.path.getsize(path) + mtime = datetime.fromtimestamp(os.path.getmtime(path), tz=timezone.utc) + result.append({ + "filename": f, + "location": location, + "size": size, + "created": mtime.isoformat(), + }) + except Exception: + pass + return sorted(result, key=lambda x: x["created"], reverse=True) + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/status", methods=["GET"]) +def api_status(): + return jsonify({"locked": not getattr(app, "master_key", None)}) + + +@app.route("/api/backups", methods=["GET"]) +def api_list_backups(): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + return jsonify(_get_backup_list()) + + +@app.route("/api/backups", methods=["POST"]) +def api_trigger_backup(): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + ok = _do_backup() + return jsonify({"ok": ok, "backups": _get_backup_list()}) + + +@app.route("/api/backups//restore", methods=["POST"]) +def api_restore_backup(filename): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + import re + if not re.match(r'^pinvault-backup-\d{8}-\d{6}\.db$', filename): + return jsonify({"error": "Invalid backup filename."}), 400 + + src = None + for d in [LOCAL_BACKUP_DIR, NAS_BACKUP_DIR]: + candidate = os.path.join(d, filename) + if os.path.exists(candidate): + src = candidate + break + + if not src: + return jsonify({"error": "Backup file not found."}), 404 + + try: + test_conn = sqlcipher.connect(src) + test_conn.execute(f'PRAGMA key = "{app.master_key}"') + test_conn.execute("SELECT count(*) FROM sqlite_master") + test_conn.close() + except Exception as e: + return jsonify({"error": f"Backup verification failed: {e}"}), 500 + + shutil.copy2(src, DB_PATH) + return jsonify({"ok": True, "restored": filename}) + + +@app.route("/api/pins", methods=["GET"]) +def api_list_pins(): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + with get_conn() as conn: + rows = conn.execute( + """ + SELECT p.id, p.label, p.created_at, p.lock_until, p.bypassed_count, p.revealed, + (SELECT COUNT(*) FROM recovery_codes rc WHERE rc.pin_id = p.id AND rc.used = 0) AS remaining_codes + FROM pins p ORDER BY p.id + """ + ).fetchall() + now = now_utc() + result = [] + for row in rows: + lock_until = datetime.fromisoformat(row["lock_until"]) + locked = now < lock_until + days_left = max(0, (lock_until - now).days) if locked else 0 + result.append({ + "id": row["id"], + "label": row["label"], + "created_at": row["created_at"], + "lock_until": row["lock_until"], + "locked": locked, + "days_remaining": days_left, + "revealed": bool(row["revealed"]), + "bypassed_count": row["bypassed_count"], + "remaining_codes": row["remaining_codes"], + }) + return jsonify(result) + + +@app.route("/api/pins", methods=["POST"]) +def api_add_pin(): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + data = request.get_json() + label = data.get("label", "").strip() + lock_days = data.get("lock_days", 30) + if lock_days < 0: + return jsonify({"error": "lock_days must be >= 0"}), 400 + + pin = f"{random.randint(0, 9999):04d}" + pin_hash = hash_val(pin) + now = now_utc() + lock_until = (now + timedelta(days=lock_days)).isoformat() + + codes = [generate_recovery_code() for _ in range(RECOVERY_CODE_COUNT)] + + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pins (label, pin_hash, created_at, lock_until) VALUES (?, ?, ?, ?)", + (label, pin_hash, now.isoformat(), lock_until), + ) + pin_id = cur.lastrowid + for code in codes: + conn.execute( + "INSERT INTO recovery_codes (pin_id, code_hash) VALUES (?, ?)", + (pin_id, hash_val(code)), + ) + conn.commit() + + return jsonify({ + "id": pin_id, + "label": label, + "pin": pin, + "lock_until": lock_until, + "recovery_codes": codes, + }), 201 + + +@app.route("/api/pins//access", methods=["POST"]) +def api_access_pin(pin_id): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + data = request.get_json() or {} + bypass_code = data.get("bypass_code", "").strip() if data.get("bypass_code") else None + + with get_conn() as conn: + row = conn.execute("SELECT * FROM pins WHERE id = ?", (pin_id,)).fetchone() + if not row: + return jsonify({"error": "PIN entry not found"}), 404 + + lock_until = datetime.fromisoformat(row["lock_until"]) + now = now_utc() + + if bypass_code: + code_hash = hash_val(bypass_code) + code_row = conn.execute( + "SELECT * FROM recovery_codes WHERE pin_id = ? AND code_hash = ? AND used = 0", + (pin_id, code_hash), + ).fetchone() + if not code_row: + return jsonify({"error": "Invalid or already-used recovery code"}), 403 + conn.execute( + "UPDATE recovery_codes SET used = 1 WHERE id = ?", + (code_row["id"],), + ) + conn.execute( + "UPDATE pins SET lock_until = ?, bypassed_count = bypassed_count + 1, revealed = 1 WHERE id = ?", + (now.isoformat(), pin_id), + ) + conn.commit() + pin = recover_pin(row["pin_hash"]) + return jsonify({"pin": pin}) + + if now >= lock_until: + conn.execute("UPDATE pins SET revealed = 1 WHERE id = ?", (pin_id,)) + conn.commit() + pin = recover_pin(row["pin_hash"]) + return jsonify({"pin": pin}) + + return jsonify({"error": "PIN is locked. Use a recovery code to bypass."}), 423 + + return jsonify({"error": "Unexpected error"}), 500 + + +@app.route("/api/pins/", methods=["DELETE"]) +def api_delete_pin(pin_id): + if not getattr(app, "master_key", None): + return jsonify({"error": "Vault not configured."}), 401 + with get_conn() as conn: + row = conn.execute("SELECT revealed FROM pins WHERE id = ?", (pin_id,)).fetchone() + if not row: + return jsonify({"error": "PIN entry not found"}), 404 + if not row["revealed"]: + return jsonify({"error": "Cannot delete unrevealed PIN. Access it first."}), 409 + conn.execute("DELETE FROM recovery_codes WHERE pin_id = ?", (pin_id,)) + cur = conn.execute("DELETE FROM pins WHERE id = ?", (pin_id,)) + conn.commit() + return jsonify({"ok": True}) + + +def recover_pin(pin_hash): + for i in range(10000): + candidate = f"{i:04d}" + if hash_val(candidate) == pin_hash: + return candidate + return None + + +def _verify_db(): + try: + conn = sqlcipher.connect(DB_PATH) + conn.execute(f'PRAGMA key = "{app.master_key}"') + conn.execute("SELECT count(*) FROM sqlite_master") + conn.close() + return True + except Exception: + return False + + +def _latest_backup(): + candidates = [] + for d in [LOCAL_BACKUP_DIR, NAS_BACKUP_DIR]: + try: + for f in os.listdir(d): + if f.startswith("pinvault-backup-") and f.endswith(".db"): + path = os.path.join(d, f) + candidates.append((os.path.getmtime(path), path)) + except Exception: + pass + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +def _auto_restore(): + if _verify_db(): + return True + + print("DB is corrupted or unreadable. Attempting auto-restore...", flush=True) + backup = _latest_backup() + if not backup: + print("No backups found. Cannot auto-restore.", file=sys.stderr, flush=True) + return False + + try: + test = sqlcipher.connect(backup) + test.execute(f'PRAGMA key = "{app.master_key}"') + test.execute("SELECT count(*) FROM sqlite_master") + test.close() + except Exception: + print(f"Backup {backup} also unreadable. Cannot auto-restore.", file=sys.stderr, flush=True) + return False + + shutil.copy2(backup, DB_PATH) + print(f"Restored DB from backup: {backup}", flush=True) + return True + + +def bootstrap(): + import sys + bcrypt_hash = os.environ.get("PINVAULT_MASTER_HASH", "").strip() + os.environ.pop("PINVAULT_MASTER_HASH", None) + + if not bcrypt_hash: + print("ERROR: PINVAULT_MASTER_HASH environment variable is required.", file=sys.stderr) + print("Generate one with: python3 -c \"import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())\"", file=sys.stderr) + sys.exit(1) + + if not bcrypt_hash.startswith("$2"): + print("ERROR: PINVAULT_MASTER_HASH must be a bcrypt hash (starts with $2b$ or $2a$)", file=sys.stderr) + sys.exit(1) + + app.master_key = _derive_sqlcipher_key(bcrypt_hash) + + if os.path.exists(DB_PATH): + if not _auto_restore(): + print("FATAL: Cannot open database and no valid backup found.", file=sys.stderr, flush=True) + sys.exit(1) + + init_db() + _do_backup() + t = threading.Thread(target=_backup_loop, daemon=True) + t.start() + + +bootstrap() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8765) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed6f792 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + pinvault: + build: . + ports: + - "8765:8765" + volumes: + - pinvault-data:/data + - /mnt/aidata:/mnt/aidata + environment: + - PINVAULT_MASTER_HASH=${PINVAULT_MASTER_HASH} + restart: unless-stopped + +volumes: + pinvault-data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7bda9a8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.* +gunicorn==23.0.* +pysqlcipher3==1.2.* \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..658231c --- /dev/null +++ b/static/app.js @@ -0,0 +1,284 @@ +const api = (path, opts = {}) => + fetch(path, { + headers: { "Content-Type": "application/json" }, + ...opts, + }).then(r => { + if (r.status === 204 || !r.body) return { ok: true }; + return r.json().then(d => { + if (!r.ok) throw new Error(d.error || "Request failed"); + return d; + }); + }); + +const pinTimers = {}; + +function fmtCountdown(ms) { + if (ms <= 0) return "00:00:00:00"; + const d = Math.floor(ms / 86400000); + const totalSec = Math.floor((ms % 86400000) / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const dd = String(d).padStart(2, "0"); + const hh = String(h).padStart(2, "0"); + const mm = String(m).padStart(2, "0"); + const ss = String(s).padStart(2, "0"); + return `${dd}:${hh}:${mm}:${ss}`; +} + +async function loadPins() { + const el = document.getElementById("pins-list"); + try { + const pins = await api("/api/pins"); + if (!pins.length) { + el.innerHTML = '

No PINs stored yet.

'; + return; + } + el.innerHTML = ` + + + + + + + + + + + + + ${pins.map(p => ` + + + + + + + + + `).join("")} + +
IDLabelStatusCountdownCodes
#${p.id}${esc(p.label) || "(none)"}${p.locked + ? `LOCKED` + : `UNLOCKED`}${fmtCountdown(new Date(p.lock_until) - Date.now())}${p.remaining_codes}/${4} + + +
`; + + pins.forEach(p => { + if (pinTimers[p.id]) clearInterval(pinTimers[p.id]); + pinTimers[p.id] = setInterval(() => { + const cell = document.getElementById(`cd-${p.id}`); + if (!cell) { + clearInterval(pinTimers[p.id]); + return; + } + const remaining = new Date(p.lock_until) - Date.now(); + cell.textContent = fmtCountdown(remaining); + }, 1000); + }); + } catch (e) { + el.innerHTML = `

${esc(e.message)}

`; + } +} + +document.getElementById("add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const label = document.getElementById("label").value; + const lockDays = parseInt(document.getElementById("lock-days").value) || 30; + + try { + const result = await api("/api/pins", { + method: "POST", + body: JSON.stringify({ label, lock_days: lockDays }), + }); + + const content = document.getElementById("new-pin-content"); + const pinId = "pin-" + result.id; + content.innerHTML = ` +

Your PIN

+
••••
+
+ +
+
+ Save your recovery codes now. They will not be shown again. +
+
+ ${result.recovery_codes.map((c, i) => ` +
+ [${i + 1}] + ${c} + +
+ `).join("")} +
+

+ Access locked until: ${new Date(result.lock_until).toLocaleDateString()} +

+
+ +
`; + + schedulePrint(result); + document.getElementById("new-pin-result").classList.remove("hidden"); + document.getElementById("label").value = ""; + loadPins(); + } catch (err) { + alert(err.message); + } +}); + +function accessPin(id) { + const title = document.getElementById("modal-title"); + const body = document.getElementById("modal-body"); + title.textContent = `Access PIN #${id}`; + body.innerHTML = ` +

+ Enter a recovery code to bypass the lock: +

+
+
+ +
+
+ +
`; + document.getElementById("modal-overlay").classList.remove("hidden"); + + document.getElementById("access-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const code = document.getElementById("bypass-input").value.trim(); + const errEl = document.getElementById("access-error"); + errEl.textContent = ""; + + try { + const result = await api(`/api/pins/${id}/access`, { + method: "POST", + body: JSON.stringify({ bypass_code: code }), + }); + body.innerHTML = ` +
+

Your PIN

+
${result.pin}
+ +
`; + loadPins(); + } catch (err) { + errEl.textContent = err.message; + } + }); +} + +async function deletePin(id, label) { + const lbl = label || `#${id}`; + if (!confirm(`Delete PIN for "${lbl}"?`)) return; + try { + await api(`/api/pins/${id}`, { method: "DELETE" }); + loadPins(); + } catch (e) { + alert(e.message); + } +} + +function closeModal() { + document.getElementById("modal-overlay").classList.add("hidden"); +} + +document.getElementById("modal-overlay").addEventListener("click", (e) => { + if (e.target.id === "modal-overlay") closeModal(); +}); + +function copyText(elId) { + const text = document.getElementById(elId).textContent; + navigator.clipboard.writeText(text); +} + +function togglePin(elId, pin, btnId) { + const el = document.getElementById(elId); + const btn = document.getElementById(btnId); + if (el.classList.contains("masked")) { + el.textContent = pin; + el.classList.remove("masked"); + btn.innerHTML = "👁 Hide PIN"; + } else { + el.textContent = "\u2022\u2022\u2022\u2022"; + el.classList.add("masked"); + btn.innerHTML = "👁 Reveal PIN"; + } +} + +function copyTextDirect(text) { + navigator.clipboard.writeText(text); +} + +let _printData = null; + +function schedulePrint(result) { + _printData = result; +} + +function doPrint() { + if (!_printData) return; + const r = _printData; + const w = window.open("", "_blank", "width=600,height=600"); + const dateStr = new Date(r.lock_until).toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric"}); + const labelStr = r.label || "(no label)"; + const codeRows = r.recovery_codes.map((c, i) => + `
${i+1}
${c}
` + ).join("\n"); + w.document.write(` +PinVault - PIN #${r.id} + +`); + w.document.close(); + setTimeout(() => { w.print(); }, 400); +} + +function esc(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +loadPins(); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..40da7dd --- /dev/null +++ b/static/style.css @@ -0,0 +1,282 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { +--bg: #0f1117; +--surface: #1a1d27; +--surface2: #242734; +--border: #2e3245; +--text: #e4e5eb; +--muted: #8b8fa3; +--accent: #6c5ce7; +--accent-hover: #7e70f0; +--danger: #e74c3c; +--danger-hover: #c0392b; +--success: #2ecc71; +--warning: #f39c12; +--radius: 10px; +} + +body { +font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +background: var(--bg); +color: var(--text); +line-height: 1.6; +min-height: 100vh; +} + +.container { +max-width: 720px; +margin: 0 auto; +padding: 2rem 1rem; +} + +header { +text-align: center; +margin-bottom: 2rem; +} + +header h1 { +font-size: 2rem; +font-weight: 700; +letter-spacing: -0.5px; +} + +.subtitle { +color: var(--muted); +font-size: 0.9rem; +} + +.card { +background: var(--surface); +border: 1px solid var(--border); +border-radius: var(--radius); +margin-bottom: 1.5rem; +overflow: hidden; +} + +.card-header { +display: flex; +justify-content: space-between; +align-items: center; +padding: 1rem 1.25rem; +border-bottom: 1px solid var(--border); +} + +.card-header h2 { +font-size: 1rem; +font-weight: 600; +} + +.card-body { +padding: 1.25rem; +} + +.form-group { +margin-bottom: 1rem; +} + +.form-group label { +display: block; +font-size: 0.8rem; +font-weight: 500; +color: var(--muted); +margin-bottom: 0.4rem; +} + +.form-group input, .form-group textarea { +width: 100%; +padding: 0.6rem 0.8rem; +background: var(--surface2); +border: 1px solid var(--border); +border-radius: 6px; +color: var(--text); +font-size: 0.9rem; +outline: none; +transition: border-color 0.2s; +} + +.form-group input:focus, .form-group textarea:focus { +border-color: var(--accent); +} + +.form-group textarea { +resize: vertical; +min-height: 60px; +font-family: inherit; +} + +.btn { +display: inline-flex; +align-items: center; +gap: 0.4rem; +padding: 0.6rem 1.2rem; +border: none; +border-radius: 6px; +font-size: 0.85rem; +font-weight: 500; +cursor: pointer; +transition: background 0.2s, opacity 0.2s; +} + +.btn:hover { opacity: 0.85; } +.btn:active { opacity: 0.7; } + +.btn-primary { background: var(--accent); color: #fff; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-success { background: var(--success); color: #fff; } +.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; background: var(--surface2); color: var(--muted); } + +.btn-close { +background: none; +border: none; +color: var(--muted); +font-size: 1.2rem; +cursor: pointer; +padding: 0.2rem; +} + +.pin-table { +width: 100%; +border-collapse: collapse; +font-size: 0.85rem; +} + +.pin-table th, .pin-table td { +text-align: left; +padding: 0.7rem 0.6rem; +border-bottom: 1px solid var(--border); +} + +.pin-table th { +font-size: 0.75rem; +text-transform: uppercase; +color: var(--muted); +font-weight: 500; +} + +.pin-table tr:last-child td { border-bottom: none; } + +.badge { +display: inline-block; +padding: 0.15rem 0.5rem; +border-radius: 4px; +font-size: 0.75rem; +font-weight: 600; +} + +.badge-locked { background: rgba(231,76,60,0.15); color: var(--danger); } +.badge-unlocked { background: rgba(46,204,113,0.15); color: var(--success); } + +.pin-display { +font-family: 'SF Mono', 'Fira Code', monospace; +font-size: 2.5rem; +letter-spacing: 0.5rem; +text-align: center; +padding: 1rem 0; +font-weight: 700; +user-select: all; +} + +.pin-display.masked { +color: var(--muted); +cursor: default; +} + +.countdown { +font-family: 'SF Mono', 'Fira Code', monospace; +font-size: 0.85rem; +font-weight: 600; +letter-spacing: 0.05em; +white-space: nowrap; +} + +.recovery-list { +margin: 0.75rem 0; +} + +.recovery-item { +display: flex; +align-items: center; +gap: 0.5rem; +padding: 0.4rem 0; +font-size: 0.78rem; +} + +.recovery-item code { +flex: 1; +background: var(--surface2); +padding: 0.3rem 0.5rem; +border-radius: 4px; +font-family: 'SF Mono', 'Fira Code', monospace; +font-size: 0.72rem; +word-break: break-all; +overflow: hidden; +text-overflow: ellipsis; +max-height: 1.5em; +line-height: 1.5; +} + +.copy-btn { +background: none; +border: none; +color: var(--muted); +cursor: pointer; +font-size: 0.85rem; +padding: 0.2rem; +flex-shrink: 0; +} + +.copy-btn:hover { color: var(--text); } + +.warning-box { +background: rgba(243,156,18,0.1); +border: 1px solid rgba(243,156,18,0.3); +border-radius: 6px; +padding: 0.75rem 1rem; +font-size: 0.82rem; +color: var(--warning); +margin: 0.75rem 0; +} + +.muted { color: var(--muted); } +.text-center { text-align: center; } +.hidden { display: none !important; } +.error-msg { color: var(--danger); font-size: 0.85rem; } + +#modal-overlay { +position: fixed; +inset: 0; +background: rgba(0,0,0,0.6); +display: flex; +align-items: center; +justify-content: center; +z-index: 100; +} + +.modal { +background: var(--surface); +border: 1px solid var(--border); +border-radius: var(--radius); +width: 90%; +max-width: 500px; +max-height: 90vh; +overflow-y: auto; +} + +.modal-header { +display: flex; +justify-content: space-between; +align-items: center; +padding: 1rem 1.25rem; +border-bottom: 1px solid var(--border); +} + +.modal-header h2 { font-size: 1rem; } +.modal-body { padding: 1.25rem; } + +.empty-state { +text-align: center; +padding: 2rem 1rem; +color: var(--muted); +font-size: 0.9rem; +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..94dd7b7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,68 @@ + + + + + +PinVault + + + +
+
+

PinVault

+

Encrypted, time-locked PIN storage

+
+ +
+
+
+

New PIN

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + + +
+
+

Stored PINs

+ +
+
+

Loading...

+
+
+
+
+ + + + + + \ No newline at end of file