Demo app (web/):
- Collections: select, rename, remove (x button per row), delete
- Features: add point (lon/lat validation), remove, list in selected collection
- QR codes for pk (private) and pb (public) keys
- Restore public key from private key
- 409 Conflict handled for already-registered login
- Title: Momswap Geo Backend Use-Cases Test
Backend:
- PATCH /v1/collections/{id} for rename
- DELETE /v1/collections/{id}
- Clearer lon/lat validation errors (-180..180, -90..90)
Client:
- updateCollection, deleteCollection, derivePublicKey
Docs:
- docs/frontend-development.md (demo app, local dev)
- README links to all docs
Made-with: Cursor
This commit is contained in:
+185
-4
@@ -1,6 +1,7 @@
|
||||
import { createApiClient } from "./api.js";
|
||||
import { toDataURL } from "./qr.js";
|
||||
|
||||
const { createApp, ref, reactive, onMounted } = Vue;
|
||||
const { createApp, ref, reactive, onMounted, watch } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
@@ -11,7 +12,16 @@ createApp({
|
||||
accessToken: "",
|
||||
collections: [],
|
||||
newCollectionName: "",
|
||||
selectedCollectionId: "",
|
||||
editingCollectionName: "",
|
||||
featuresByCollection: {},
|
||||
newFeatureLon: "",
|
||||
newFeatureLat: "",
|
||||
newFeatureName: "",
|
||||
status: "Ready",
|
||||
qrPk: "",
|
||||
qrPb: "",
|
||||
showPrivateQR: false,
|
||||
});
|
||||
|
||||
let client = createApiClient(apiBase.value);
|
||||
@@ -22,12 +32,48 @@ createApp({
|
||||
state.status = `API base set to ${apiBase.value}`;
|
||||
};
|
||||
|
||||
const refreshQRCodes = async () => {
|
||||
if (!state.publicKey) {
|
||||
state.qrPk = "";
|
||||
state.qrPb = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.qrPb = await toDataURL(state.publicKey);
|
||||
if (state.privateKey) {
|
||||
state.qrPk = await toDataURL(state.privateKey, { dark: "#7f1d1d" });
|
||||
} else {
|
||||
state.qrPb = "";
|
||||
}
|
||||
} catch (err) {
|
||||
state.qrPk = "";
|
||||
state.qrPb = "";
|
||||
}
|
||||
};
|
||||
|
||||
const ensureKeys = async () => {
|
||||
try {
|
||||
const keys = await client.ensureKeysInStorage();
|
||||
state.publicKey = keys.publicKey;
|
||||
state.privateKey = keys.privateKey;
|
||||
state.status = "Keys loaded from localStorage.";
|
||||
await refreshQRCodes();
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const restorePublicKeyFromPrivate = async () => {
|
||||
if (!state.privateKey?.trim()) {
|
||||
state.status = "Enter private key (pk) first.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pb = await client.derivePublicKey(state.privateKey.trim());
|
||||
state.publicKey = pb;
|
||||
client.importKeys({ publicKey: pb, privateKey: state.privateKey.trim() });
|
||||
await refreshQRCodes();
|
||||
state.status = "Public key (pb) restored from private key (pk).";
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
@@ -47,13 +93,15 @@ createApp({
|
||||
try {
|
||||
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
|
||||
} catch (err) {
|
||||
if (!err.message.includes("already registered") && !err.message.includes("not configured") && !err.message.includes("ADMIN_PUBLIC_KEY")) {
|
||||
throw 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;
|
||||
// Proceed to login: already registered or registration disabled (invitation flow)
|
||||
}
|
||||
state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey);
|
||||
client.setAccessToken(state.accessToken);
|
||||
await listCollections();
|
||||
state.status = "Authenticated.";
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
@@ -81,19 +129,152 @@ createApp({
|
||||
}
|
||||
};
|
||||
|
||||
const removeCollection = async (collectionId) => {
|
||||
try {
|
||||
client.setAccessToken(state.accessToken);
|
||||
await client.deleteCollection(collectionId);
|
||||
if (state.selectedCollectionId === collectionId) {
|
||||
state.selectedCollectionId = "";
|
||||
}
|
||||
delete state.featuresByCollection[collectionId];
|
||||
await listCollections();
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const listFeatures = async (collectionId) => {
|
||||
if (!collectionId) return;
|
||||
try {
|
||||
client.setAccessToken(state.accessToken);
|
||||
const data = await client.listFeatures(collectionId);
|
||||
state.featuresByCollection[collectionId] = data.features || [];
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const createFeature = async (collectionId) => {
|
||||
if (!collectionId) return;
|
||||
const lon = parseFloat(state.newFeatureLon);
|
||||
const lat = parseFloat(state.newFeatureLat);
|
||||
if (isNaN(lon) || isNaN(lat)) {
|
||||
state.status = "Enter valid lon/lat numbers.";
|
||||
return;
|
||||
}
|
||||
if (lon < -180 || lon > 180) {
|
||||
state.status = "Longitude must be -180 to 180.";
|
||||
return;
|
||||
}
|
||||
if (lat < -90 || lat > 90) {
|
||||
state.status = "Latitude must be -90 to 90.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.setAccessToken(state.accessToken);
|
||||
await client.createPointFeature(collectionId, lon, lat, {
|
||||
name: state.newFeatureName || "Point",
|
||||
});
|
||||
state.newFeatureLon = "";
|
||||
state.newFeatureLat = "";
|
||||
state.newFeatureName = "";
|
||||
await listFeatures(collectionId);
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFeature = async (collectionId, featureId) => {
|
||||
try {
|
||||
client.setAccessToken(state.accessToken);
|
||||
await client.deleteFeature(featureId);
|
||||
await listFeatures(collectionId);
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const featuresFor = (collectionId) => state.featuresByCollection[collectionId] ?? [];
|
||||
|
||||
const formatFeature = (f) => {
|
||||
const coords = f.geometry?.coordinates;
|
||||
const lon = coords?.[0] ?? "—";
|
||||
const lat = coords?.[1] ?? "—";
|
||||
const name = f.properties?.name ?? f.id ?? "—";
|
||||
return { id: f.id, name, lon, lat };
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.publicKey, state.privateKey],
|
||||
() => refreshQRCodes(),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => state.selectedCollectionId,
|
||||
(id) => {
|
||||
if (id) listFeatures(id);
|
||||
}
|
||||
);
|
||||
|
||||
const selectCollection = (id) => {
|
||||
state.selectedCollectionId = id;
|
||||
const c = state.collections.find((x) => x.id === id);
|
||||
state.editingCollectionName = c ? c.name : "";
|
||||
};
|
||||
|
||||
const selectedCollection = () =>
|
||||
state.collections.find((c) => c.id === state.selectedCollectionId);
|
||||
|
||||
const updateCollectionName = async (collectionId, newName) => {
|
||||
if (!newName?.trim()) return;
|
||||
try {
|
||||
client.setAccessToken(state.accessToken);
|
||||
const updated = await client.updateCollection(collectionId, newName.trim());
|
||||
const idx = state.collections.findIndex((c) => c.id === collectionId);
|
||||
if (idx >= 0) state.collections[idx] = updated;
|
||||
state.editingCollectionName = updated.name;
|
||||
} catch (err) {
|
||||
state.status = err.message;
|
||||
}
|
||||
};
|
||||
|
||||
const onCollectionNameBlur = () => {
|
||||
const c = selectedCollection();
|
||||
if (c && state.editingCollectionName !== c.name) {
|
||||
updateCollectionName(c.id, state.editingCollectionName);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureKeys();
|
||||
});
|
||||
|
||||
const togglePrivateQR = () => {
|
||||
state.showPrivateQR = !state.showPrivateQR;
|
||||
};
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
state,
|
||||
rebuildClient,
|
||||
ensureKeys,
|
||||
restorePublicKeyFromPrivate,
|
||||
register,
|
||||
login,
|
||||
listCollections,
|
||||
createCollection,
|
||||
removeCollection,
|
||||
listFeatures,
|
||||
selectCollection,
|
||||
selectedCollection,
|
||||
updateCollectionName,
|
||||
onCollectionNameBlur,
|
||||
createFeature,
|
||||
removeFeature,
|
||||
formatFeature,
|
||||
featuresFor,
|
||||
togglePrivateQR,
|
||||
};
|
||||
},
|
||||
}).use(Vuetify.createVuetify()).mount("#app");
|
||||
|
||||
+72
-12
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Momswap Geo Console</title>
|
||||
<title>Momswap Geo Backend Use-Cases Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/vuetify@3.7.7/dist/vuetify.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
body {
|
||||
@@ -23,7 +23,7 @@
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card class="glass rounded-xl pa-6">
|
||||
<v-card-title class="text-h4 mb-2">Momswap Geo Backend Console</v-card-title>
|
||||
<v-card-title class="text-h4 mb-2">Momswap Geo Backend Use-Cases Test</v-card-title>
|
||||
<v-card-subtitle>Ed25519 auth, invitation onboarding, and user-scoped feature collections</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -37,28 +37,88 @@
|
||||
<v-btn color="primary" @click="rebuildClient">Apply API URL</v-btn>
|
||||
<v-divider class="my-4"></v-divider>
|
||||
<v-btn color="secondary" @click="ensureKeys">Ensure Keys in localStorage</v-btn>
|
||||
<v-textarea v-model="state.publicKey" class="mt-3" label="Public Key" rows="2"></v-textarea>
|
||||
<v-textarea v-model="state.privateKey" label="Private Key (local only)" rows="2"></v-textarea>
|
||||
<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-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>
|
||||
<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-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>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-col v-if="state.accessToken" cols="12" md="6">
|
||||
<v-card class="glass rounded-xl pa-4">
|
||||
<v-card-title>Collections</v-card-title>
|
||||
<v-text-field v-model="state.newCollectionName" label="New collection name"></v-text-field>
|
||||
<v-btn color="primary" @click="createCollection">Create Collection</v-btn>
|
||||
<v-btn class="ml-2" @click="listCollections">Refresh</v-btn>
|
||||
<v-list class="mt-4">
|
||||
<v-list-item v-for="item in state.collections" :key="item.id">
|
||||
<v-text-field v-model="state.newCollectionName" label="New collection name" density="compact" hide-details class="mb-2"></v-text-field>
|
||||
<v-btn color="primary" @click="createCollection" size="small" class="mr-2">Create Collection</v-btn>
|
||||
<v-btn variant="outlined" @click="listCollections" size="small">Refresh</v-btn>
|
||||
<v-list class="mt-3" density="compact">
|
||||
<v-list-item
|
||||
v-for="item in state.collections"
|
||||
:key="item.id"
|
||||
:active="state.selectedCollectionId === item.id"
|
||||
@click="selectCollection(item.id)"
|
||||
class="cursor-pointer rounded"
|
||||
>
|
||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.id }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle class="text-caption">{{ item.id }}</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-btn icon variant="text" size="small" color="error" @click.stop="removeCollection(item.id)" aria-label="Remove">×</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card v-if="selectedCollection()" class="glass mt-4 pa-4" variant="tonal">
|
||||
<v-card-title class="d-flex align-center flex-wrap gap-2">
|
||||
<v-text-field
|
||||
v-model="state.editingCollectionName"
|
||||
@blur="onCollectionNameBlur"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Collection name"
|
||||
class="flex-grow-1"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
<v-btn variant="outlined" color="error" size="small" @click="removeCollection(selectedCollection().id)">Remove collection</v-btn>
|
||||
<v-btn variant="text" size="small" @click="listFeatures(selectedCollection().id)">Refresh</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="mb-2">Features (GeoJSON)</v-card-subtitle>
|
||||
<v-row dense class="mb-3">
|
||||
<v-col cols="6" sm="2">
|
||||
<v-text-field v-model="state.newFeatureLon" label="Lon" type="number" density="compact" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="2">
|
||||
<v-text-field v-model="state.newFeatureLat" label="Lat" type="number" density="compact" hide-details></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="8" sm="3">
|
||||
<v-text-field v-model="state.newFeatureName" label="Name" density="compact" hide-details placeholder="Point"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="4" sm="2">
|
||||
<v-btn color="primary" @click="createFeature(selectedCollection().id)" size="small">Add feature</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div v-for="f in featuresFor(selectedCollection().id)" :key="f.id" class="d-flex align-center py-2 mb-1 rounded px-2" style="background: rgba(255,255,255,0.05)">
|
||||
<span class="flex-grow-1">{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }})</span>
|
||||
<v-btn variant="outlined" color="error" size="small" @click="removeFeature(selectedCollection().id, f.id)">Remove</v-btn>
|
||||
</div>
|
||||
<div v-if="featuresFor(selectedCollection().id).length === 0" class="text-medium-emphasis text-caption py-2">No features. Add a point above.</div>
|
||||
</v-card>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* QR code generation for key display (pk = private key, pb = public key).
|
||||
* Uses qrcode package from CDN.
|
||||
*/
|
||||
import QRCode from "https://esm.sh/qrcode?bundle";
|
||||
|
||||
const DEFAULT_SIZE = 180;
|
||||
const DARK = "#0f172a";
|
||||
const LIGHT = "#ffffff";
|
||||
|
||||
/**
|
||||
* Generate a QR code data URL for the given text.
|
||||
* @param {string} text - Text to encode (e.g. public key or private key).
|
||||
* @param {{ size?: number, dark?: string, light?: string }} [opts] - Options.
|
||||
* @returns {Promise<string>} Data URL for use as img src.
|
||||
*/
|
||||
export async function toDataURL(text, opts = {}) {
|
||||
const size = opts.size ?? DEFAULT_SIZE;
|
||||
const color = { dark: opts.dark ?? DARK, light: opts.light ?? LIGHT };
|
||||
return QRCode.toDataURL(text, { width: size, margin: 2, color });
|
||||
}
|
||||
Reference in New Issue
Block a user