diff --git a/docs/frontend-development.md b/docs/frontend-development.md index 3cb532b..6c5ee87 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -13,7 +13,8 @@ web/ ├── index.html # Entry page, Vue/Vuetify from CDN ├── app.js # Vue app, state, handlers ├── api.js # GeoApiClient wrapper for browser -└── qr.js # QR code generation (pk/pb keys) +├── qr.js # QR code generation (pk/pb keys) +└── scanner.js # QR scanner from camera (Import pk) ``` ### Running locally @@ -30,6 +31,7 @@ web/ - Vue 3 and Vuetify 3 from CDN (no npm install in `web/`) - `libs/geo-api-client/dist/index.js` — built ESM client - `qr.js` — imports `qrcode` from esm.sh +- `scanner.js` — imports `jsQR` from esm.sh for camera scan ### Build step for client @@ -44,7 +46,7 @@ With Docker, the image build runs this automatically. ### Features (use-cases test) -- Connection & Identity: API URL, key generation, pk/pb display, QR codes, restore pb from pk, register, login +- Connection & Identity: API URL, key generation, pk/pb display, QR codes (pk shown by default, pb behind toggle), restore pb from pk, **Import pk from camera** (scan QR → restore pb → auto login → refresh collections), register, login - Collections: create, select, rename, remove - Features: add point (lon/lat validation -180..180, -90..90), remove, list diff --git a/web/app.js b/web/app.js index e43bfaf..185176f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,6 @@ import { createApiClient } from "./api.js"; import { toDataURL } from "./qr.js"; +import { scanQRFromCamera } from "./scanner.js"; const { createApp, ref, reactive, onMounted, watch } = Vue; @@ -21,7 +22,11 @@ createApp({ status: "Ready", qrPk: "", qrPb: "", - showPrivateQR: false, + showPrivateQR: true, + showPublicQR: false, + showCameraDialog: false, + cameraError: "", + cameraAbortController: null, }); let client = createApiClient(apiBase.value); @@ -79,6 +84,59 @@ createApp({ } }; + const closeCameraDialog = () => { + state.showCameraDialog = false; + if (state.cameraAbortController) { + state.cameraAbortController.abort(); + state.cameraAbortController = null; + } + state.cameraError = ""; + }; + + const runCameraScan = async () => { + state.cameraError = ""; + state.cameraAbortController = new AbortController(); + const videoEl = document.getElementById("camera-video"); + if (!videoEl) { + state.cameraError = "Video element not found."; + return; + } + try { + const pk = await scanQRFromCamera(videoEl, state.cameraAbortController.signal); + const pkTrimmed = pk.trim(); + closeCameraDialog(); + state.privateKey = pkTrimmed; + const pb = await client.derivePublicKey(pkTrimmed); + state.publicKey = pb; + client.importKeys({ publicKey: pb, privateKey: pkTrimmed }); + await refreshQRCodes(); + try { + try { + await client.registerBySigningServiceKey(pb, pkTrimmed); + } catch (err) { + const msg = (err?.message || "").toLowerCase(); + const ignore = msg.includes("already registered") || msg.includes("not configured") || + msg.includes("admin_public_key") || msg.includes("409") || msg.includes("conflict"); + if (!ignore) throw err; + } + state.accessToken = await client.loginWithSignature(pb, pkTrimmed); + client.setAccessToken(state.accessToken); + await listCollections(); + state.status = "Imported pk from camera, restored pb, logged in."; + } catch (err) { + state.status = `Keys imported. Login failed: ${err.message}`; + } + } catch (err) { + if (err.name === "AbortError") return; + state.cameraError = err.message || "Camera access failed."; + } + }; + + const importPkFromCamera = () => { + state.showCameraDialog = true; + Vue.nextTick(() => runCameraScan()); + }; + const register = async () => { try { await client.registerBySigningServiceKey(state.publicKey, state.privateKey); @@ -254,12 +312,18 @@ createApp({ state.showPrivateQR = !state.showPrivateQR; }; + const togglePublicQR = () => { + state.showPublicQR = !state.showPublicQR; + }; + return { apiBase, state, rebuildClient, ensureKeys, restorePublicKeyFromPrivate, + importPkFromCamera, + closeCameraDialog, register, login, listCollections, @@ -275,6 +339,7 @@ createApp({ formatFeature, featuresFor, togglePrivateQR, + togglePublicQR, }; }, }).use(Vuetify.createVuetify()).mount("#app"); diff --git a/web/index.html b/web/index.html index 181274b..aa9687c 100644 --- a/web/index.html +++ b/web/index.html @@ -40,21 +40,38 @@ Restore Public Key from Private Key + Import pk from camera + + + Scan private key (pk) QR + + + {{ state.cameraError }} + + + + Cancel + + + - -
QR: Public Key (pb)
- Public key QR -
-
-
QR: Private Key (pk, secret backup)
- Private key QR -
+
QR: Private Key (pk, secret backup)
+ Private key QR {{ state.showPrivateQR ? 'Hide' : 'Show' }} private key (pk) QR
+ +
+
QR: Public Key (pb)
+ Public key QR +
+ + {{ state.showPublicQR ? 'Hide' : 'Show' }} public key (pb) QR + +
Register (sign service key) Login diff --git a/web/scanner.js b/web/scanner.js new file mode 100644 index 0000000..d301812 --- /dev/null +++ b/web/scanner.js @@ -0,0 +1,59 @@ +/** + * QR code scanner from camera. Decodes private key (pk) from QR. + */ +import jsQR from "https://esm.sh/jsqr@1.4.0"; + +/** + * Scan QR code from camera video stream. + * @param {HTMLVideoElement} videoEl - Video element to render the stream. + * @param {AbortSignal} [signal] - AbortSignal to stop scanning (e.g. when dialog closes). + * @returns {Promise} Decoded QR text. + */ +export async function scanQRFromCamera(videoEl, signal) { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + videoEl.srcObject = stream; + await videoEl.play(); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + stream.getTracks().forEach((t) => t.stop()); + reject(new DOMException("Aborted", "AbortError")); + return; + } + signal?.addEventListener("abort", () => { + stream.getTracks().forEach((t) => t.stop()); + videoEl.srcObject = null; + reject(new DOMException("Aborted", "AbortError")); + }); + + function tick() { + if (signal?.aborted) return; + if (videoEl.readyState !== videoEl.HAVE_ENOUGH_DATA) { + requestAnimationFrame(tick); + return; + } + canvas.width = videoEl.videoWidth; + canvas.height = videoEl.videoHeight; + if (canvas.width === 0 || canvas.height === 0) { + requestAnimationFrame(tick); + return; + } + ctx.drawImage(videoEl, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const result = jsQR(imageData.data, imageData.width, imageData.height); + if (result) { + stream.getTracks().forEach((t) => t.stop()); + videoEl.srcObject = null; + resolve(result.data); + return; + } + requestAnimationFrame(tick); + } + tick(); + }); +}