Add QR Code for Keypasses
This commit is contained in:
@@ -7,3 +7,4 @@ cryptography>=42.0.0
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
fake-useragent>=1.5.0
|
fake-useragent>=1.5.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async def _security_headers(request: Request, call_next) -> Response:
|
|||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Content-Security-Policy"] = (
|
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
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.config import utcnow
|
from core.config import utcnow
|
||||||
@@ -88,3 +90,28 @@ async def revoke_keypass(
|
|||||||
kp.revoked = True
|
kp.revoked = True
|
||||||
kp.revoked_at = utcnow()
|
kp.revoked_at = utcnow()
|
||||||
db.commit()
|
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")
|
||||||
|
|||||||
@@ -418,6 +418,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ───────────────────────────────────────────────────────────── -->
|
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ async function loadKeypasses() {
|
|||||||
<td>${expiresCell}</td>
|
<td>${expiresCell}</td>
|
||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
|
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
|
||||||
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
${!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>` : ""}
|
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"
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
||||||
@@ -189,6 +191,33 @@ async function loadKeypasses() {
|
|||||||
document.getElementById("kp-edit-modal").classList.remove("hidden");
|
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 => {
|
tbody.querySelectorAll("[data-kp-id]").forEach(btn => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
if (!confirm("Revoke this keypass?")) return;
|
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-cancel").addEventListener("click", () => {
|
||||||
document.getElementById("kp-edit-modal").classList.add("hidden");
|
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 => {
|
document.getElementById("kp-edit-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById("kp-edit-id").value;
|
const id = document.getElementById("kp-edit-id").value;
|
||||||
|
|||||||
@@ -204,6 +204,30 @@ document.getElementById("logout-btn").addEventListener("click", () => {
|
|||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
(function 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();
|
const t = getToken();
|
||||||
if (tokenValid(t)) {
|
if (tokenValid(t)) {
|
||||||
showGatesView();
|
showGatesView();
|
||||||
|
|||||||
Reference in New Issue
Block a user