add pinvault app
This commit is contained in:
parent
740d31ce7f
commit
10baf2dfaa
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -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"]
|
||||||
435
app.py
Normal file
435
app.py
Normal file
@ -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/<filename>/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/<int:pin_id>/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/<int:pin_id>", 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)
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -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:
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask==3.1.*
|
||||||
|
gunicorn==23.0.*
|
||||||
|
pysqlcipher3==1.2.*
|
||||||
284
static/app.js
Normal file
284
static/app.js
Normal file
@ -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 = '<p class="empty-state">No PINs stored yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<table class="pin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Countdown</th>
|
||||||
|
<th>Codes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${pins.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>#${p.id}</td>
|
||||||
|
<td>${esc(p.label) || "<span class='muted'>(none)</span>"}</td>
|
||||||
|
<td>${p.locked
|
||||||
|
? `<span class="badge badge-locked">LOCKED</span>`
|
||||||
|
: `<span class="badge badge-unlocked">UNLOCKED</span>`}</td>
|
||||||
|
<td class="countdown" id="cd-${p.id}" data-until="${p.lock_until}">${fmtCountdown(new Date(p.lock_until) - Date.now())}</td>
|
||||||
|
<td>${p.remaining_codes}/${4}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm" onclick="accessPin(${p.id})">Access</button>
|
||||||
|
<button class="btn btn-sm" onclick="deletePin(${p.id}, '${esc(p.label)}')"
|
||||||
|
${p.revealed ? '' : 'disabled title="Must be revealed first"'}
|
||||||
|
style="${p.revealed ? 'color:var(--danger)' : 'color:var(--muted);opacity:0.4'}">
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
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 = `<p class="error-msg">${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p class="muted text-center" style="margin-bottom:0.3rem">Your PIN</p>
|
||||||
|
<div class="pin-display masked" id="${pinId}">••••</div>
|
||||||
|
<div class="text-center" style="margin-bottom:1rem">
|
||||||
|
<button class="btn btn-sm" id="${pinId}-btn" onclick="togglePin('${pinId}', '${result.pin}', '${pinId}-btn')">👁 Reveal PIN</button>
|
||||||
|
</div>
|
||||||
|
<div class="warning-box">
|
||||||
|
Save your recovery codes now. They will <strong>not</strong> be shown again.
|
||||||
|
</div>
|
||||||
|
<div class="recovery-list">
|
||||||
|
${result.recovery_codes.map((c, i) => `
|
||||||
|
<div class="recovery-item">
|
||||||
|
<strong>[${i + 1}]</strong>
|
||||||
|
<code id="rc-${i}">${c}</code>
|
||||||
|
<button class="copy-btn" onclick="copyText('rc-${i}')" title="Copy">📋</button>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
<p class="muted text-center" style="margin-top:0.75rem;font-size:0.8rem">
|
||||||
|
Access locked until: ${new Date(result.lock_until).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<div class="text-center" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="doPrint()">🖸 Print Sheet</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem">
|
||||||
|
Enter a recovery code to bypass the lock:
|
||||||
|
</p>
|
||||||
|
<form id="access-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea id="bypass-input" placeholder="Paste 64-character recovery code here..." rows="2" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="access-error" class="error-msg" style="margin-bottom:0.5rem"></div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%">Unlock PIN</button>
|
||||||
|
</form>`;
|
||||||
|
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 = `
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="muted" style="margin-bottom:0.3rem">Your PIN</p>
|
||||||
|
<div class="pin-display">${result.pin}</div>
|
||||||
|
<button class="btn btn-sm" onclick="copyTextDirect('${result.pin}')" style="margin-top:0.5rem">
|
||||||
|
Copy PIN 📋
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
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) =>
|
||||||
|
`<div class="code-row"><div class="code-num">${i+1}</div><div class="code-text">${c}</div></div>`
|
||||||
|
).join("\n");
|
||||||
|
w.document.write(`<!DOCTYPE html>
|
||||||
|
<html><head><title>PinVault - PIN #${r.id}</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Courier New',monospace;padding:0.5in}
|
||||||
|
.print-card{border:3px solid #000;border-radius:8px;padding:0.4in;max-width:5in}
|
||||||
|
.header{text-align:center;margin-bottom:0.3in}
|
||||||
|
.header h1{font-size:1.2rem;letter-spacing:0.1em;margin-bottom:0.1in}
|
||||||
|
.header .subtitle{font-size:0.65rem;color:#555}
|
||||||
|
.pin-section{text-align:center;margin:0.25in 0;padding:0.15in 0;border-top:1px dashed #999;border-bottom:1px dashed #999}
|
||||||
|
.pin-section .label{font-size:0.6rem;text-transform:uppercase;letter-spacing:0.15em;color:#333;margin-bottom:0.1in}
|
||||||
|
.pin-section .pin{font-size:2.5rem;font-weight:bold;letter-spacing:0.35rem}
|
||||||
|
.info{font-size:0.55rem;color:#666;text-align:center;margin:0.15in 0}
|
||||||
|
.codes-section{margin-top:0.2in}
|
||||||
|
.codes-section h2{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em;margin-bottom:0.15in;text-align:center}
|
||||||
|
.code-row{display:flex;align-items:stretch;margin-bottom:0.12in;page-break-inside:avoid}
|
||||||
|
.code-num{font-size:0.7rem;font-weight:bold;width:0.6in;text-align:center;border-right:2px solid #000;padding:0.1in 0.05in;display:flex;align-items:center}
|
||||||
|
.code-text{flex:1;padding:0.1in 0.15in;font-size:0.72rem;word-break:break-all;line-height:1.4}
|
||||||
|
.footer{text-align:center;margin-top:0.25in;font-size:0.5rem;color:#999;border-top:1px dashed #ccc;padding-top:0.1in}
|
||||||
|
@page{size:letter;margin:0.5in}
|
||||||
|
@media print{body{padding:0}.print-card{border:2px solid #000}}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="print-card">
|
||||||
|
<div class="header">
|
||||||
|
<h1>PINVAULT</h1>
|
||||||
|
<div class="subtitle">PIN #${r.id} · ${labelStr}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pin-section">
|
||||||
|
<div class="label">Access PIN</div>
|
||||||
|
<div class="pin">${r.pin}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info">Locked until: ${dateStr}</div>
|
||||||
|
<div class="codes-section">
|
||||||
|
<h2>Recovery Codes</h2>
|
||||||
|
${codeRows}
|
||||||
|
</div>
|
||||||
|
<div class="footer">One-time use. Store securely.</div>
|
||||||
|
</div></body></html>`);
|
||||||
|
w.document.close();
|
||||||
|
setTimeout(() => { w.print(); }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPins();
|
||||||
282
static/style.css
Normal file
282
static/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
68
templates/index.html
Normal file
68
templates/index.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PinVault</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>PinVault</h1>
|
||||||
|
<p class="subtitle">Encrypted, time-locked PIN storage</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>New PIN</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="add-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="label">Label</label>
|
||||||
|
<input type="text" id="label" placeholder="e.g. Banking app" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lock-days">Lock (days)</label>
|
||||||
|
<input type="number" id="lock-days" value="30" min="0" max="365">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Generate PIN</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="new-pin-result" class="card hidden">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>New PIN Generated</h2>
|
||||||
|
<button class="btn btn-sm btn-close" onclick="this.closest('.card').classList.add('hidden')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="new-pin-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Stored PINs</h2>
|
||||||
|
<button class="btn btn-sm" onclick="loadPins()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="pins-list"><p class="muted">Loading...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-overlay" class="hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title"></h2>
|
||||||
|
<button class="btn btn-sm btn-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user