Add QR Code for Keypasses

This commit is contained in:
Ettore
2026-05-10 00:41:36 +02:00
parent 0cb35a30cb
commit ff097b31d1
6 changed files with 107 additions and 3 deletions

View File

@@ -7,3 +7,4 @@ cryptography>=42.0.0
requests>=2.31.0
fake-useragent>=1.5.0
python-multipart>=0.0.9
qrcode[pil]>=7.4

View File

@@ -87,7 +87,7 @@ async def _security_headers(request: Request, call_next) -> Response:
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
)
return response

View File

@@ -1,10 +1,12 @@
import io
import json
import secrets
import string
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from core.config import utcnow
@@ -88,3 +90,28 @@ async def revoke_keypass(
kp.revoked = True
kp.revoked_at = utcnow()
db.commit()
@router.get("/{kp_id}/qr")
async def keypass_qr(
kp_id: int,
request: Request,
db: Session = Depends(get_db),
_: dict = Depends(require_manager),
):
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
if not kp:
raise HTTPException(404, "Keypass not found")
base_url = str(request.base_url).rstrip("/")
url = f"{base_url}/?k={kp.code}"
qr = qrcode.QRCode(box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return Response(content=buf.read(), media_type="image/png")

View File

@@ -418,6 +418,22 @@
</div>
</div>
<!-- ── QR Code modal ─────────────────────────────────────────────────────── -->
<div id="qr-modal" class="modal-backdrop hidden">
<div class="modal" style="text-align:center;max-width:340px">
<h3 style="margin-bottom:.2rem">Keypass QR Code</h3>
<p id="qr-modal-desc" style="color:var(--text-muted);font-size:.85rem;margin-bottom:1.25rem"></p>
<div style="display:flex;justify-content:center;align-items:center;min-height:220px;background:var(--surface2);border-radius:8px;padding:1rem">
<img id="qr-img" src="" alt="QR Code" style="max-width:100%;border-radius:4px;display:block" />
</div>
<p style="color:var(--text-muted);font-size:.78rem;margin-top:.75rem">Scan to open the app and login automatically.</p>
<div class="modal-actions" style="justify-content:center;margin-top:1rem">
<button type="button" id="qr-close" class="btn btn-ghost">Close</button>
<a id="qr-download" download="keypass-qr.png" class="btn btn-primary" style="text-decoration:none">Download</a>
</div>
</div>
</div>
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div>

View File

@@ -142,6 +142,8 @@ async function loadKeypasses() {
<td>${expiresCell}</td>
<td>${badge}</td>
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
@@ -189,6 +191,33 @@ async function loadKeypasses() {
document.getElementById("kp-edit-modal").classList.remove("hidden");
});
});
tbody.querySelectorAll("[data-qr-kp-id]").forEach(btn => {
btn.addEventListener("click", async () => {
const id = btn.dataset.qrKpId;
const desc = btn.dataset.qrKpDesc;
document.getElementById("qr-modal-desc").textContent = desc;
const img = document.getElementById("qr-img");
const dl = document.getElementById("qr-download");
img.src = "";
dl.removeAttribute("href");
document.getElementById("qr-modal").classList.remove("hidden");
try {
const res = await fetch(`/api/admin/keypasses/${id}/qr`, {
headers: { "Authorization": `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error("Failed to load QR code");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
img.src = blobUrl;
dl.href = blobUrl;
dl.download = `keypass-${id}-qr.png`;
} catch (e) {
showToast(e.message, true);
document.getElementById("qr-modal").classList.add("hidden");
}
});
});
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
btn.addEventListener("click", async () => {
if (!confirm("Revoke this keypass?")) return;
@@ -285,6 +314,13 @@ document.getElementById("kp-edit-gate-checks").addEventListener("change", () =>
document.getElementById("kp-edit-cancel").addEventListener("click", () => {
document.getElementById("kp-edit-modal").classList.add("hidden");
});
document.getElementById("qr-close").addEventListener("click", () => {
const img = document.getElementById("qr-img");
if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src);
img.src = "";
document.getElementById("qr-modal").classList.add("hidden");
});
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
e.preventDefault();
const id = document.getElementById("kp-edit-id").value;

View File

@@ -204,6 +204,30 @@ document.getElementById("logout-btn").addEventListener("click", () => {
// ── Init ──────────────────────────────────────────────────────────────────────
(function init() {
// Auto-login when the URL contains ?k=CODE (e.g. scanned from a QR code)
const params = new URLSearchParams(window.location.search);
const k = params.get("k");
if (k) {
// Remove the code from the URL immediately so it doesn't linger in history
history.replaceState(null, "", window.location.pathname);
fetch("/api/auth/keypass", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: k.toUpperCase() }),
})
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => {
saveToken(data.token);
showGatesView();
loadGates();
})
.catch(() => {
clearToken();
showLogin();
});
return;
}
const t = getToken();
if (tokenValid(t)) {
showGatesView();