- Add Import pk from camera: scan QR → restore pb → auto login → refresh - Add scanner.js (jsQR) for camera QR decode - QR visibility: pk shown by default, pb hidden by default (toggles) - Update docs/frontend-development.md with scanner, Import pk, QR behavior Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+66
-1
@@ -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");
|
||||
|
||||
+24
-7
@@ -40,21 +40,38 @@
|
||||
<v-textarea v-model="state.publicKey" class="mt-3" label="Public Key (pb)" rows="2"></v-textarea>
|
||||
<v-textarea v-model="state.privateKey" label="Private Key (pk, local only)" rows="2"></v-textarea>
|
||||
<v-btn size="small" variant="outlined" class="mt-2" @click="restorePublicKeyFromPrivate">Restore Public Key from Private Key</v-btn>
|
||||
<v-btn size="small" variant="outlined" color="primary" class="mt-2" @click="importPkFromCamera">Import pk from camera</v-btn>
|
||||
<v-dialog v-model="state.showCameraDialog" max-width="400" persistent @click:outside="closeCameraDialog">
|
||||
<v-card class="pa-4">
|
||||
<v-card-title>Scan private key (pk) QR</v-card-title>
|
||||
<v-card-text>
|
||||
<video id="camera-video" autoplay playsinline muted class="rounded" style="width:100%;max-height:300px;background:#000"></video>
|
||||
<v-alert v-if="state.cameraError" type="error" density="compact" class="mt-2">{{ state.cameraError }}</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="closeCameraDialog">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<v-row v-if="state.qrPk || state.qrPb" dense>
|
||||
<v-col v-if="state.qrPb" cols="auto">
|
||||
<div class="text-caption text-medium-emphasis mb-1">QR: Public Key (pb)</div>
|
||||
<img :src="state.qrPb" alt="Public key QR" width="140" height="140" class="rounded" />
|
||||
</v-col>
|
||||
<v-col v-if="state.qrPk" cols="auto">
|
||||
<div v-if="state.showPrivateQR">
|
||||
<div class="text-caption text-error mb-1">QR: Private Key (pk, secret backup)</div>
|
||||
<img :src="state.qrPk" alt="Private key QR" width="140" height="140" class="rounded" />
|
||||
</div>
|
||||
<img v-show="state.showPrivateQR" :src="state.qrPk" alt="Private key QR" width="140" height="140" class="rounded" />
|
||||
<v-btn size="small" variant="outlined" color="warning" @click="togglePrivateQR" class="mt-1">
|
||||
{{ state.showPrivateQR ? 'Hide' : 'Show' }} private key (pk) QR
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col v-if="state.qrPb" cols="auto">
|
||||
<div v-if="state.showPublicQR">
|
||||
<div class="text-caption text-medium-emphasis mb-1">QR: Public Key (pb)</div>
|
||||
<img :src="state.qrPb" alt="Public key QR" width="140" height="140" class="rounded" />
|
||||
</div>
|
||||
<v-btn size="small" variant="outlined" @click="togglePublicQR" class="mt-1">
|
||||
{{ state.showPublicQR ? 'Hide' : 'Show' }} public key (pb) QR
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-btn color="secondary" class="mt-2" @click="register">Register (sign service key)</v-btn>
|
||||
<v-btn color="success" class="mt-2" @click="login">Login</v-btn>
|
||||
|
||||
@@ -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<string>} 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user