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:
@@ -88,6 +88,15 @@ Then visit:
|
|||||||
- Production: `https://momswap.produktor.duckdns.org/web/`
|
- Production: `https://momswap.produktor.duckdns.org/web/`
|
||||||
- Local: `http://localhost:8122/web/`
|
- Local: `http://localhost:8122/web/`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Doc | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| [docs/frontend-development.md](docs/frontend-development.md) | Demo app (`web/`), client build, local dev |
|
||||||
|
| [docs/typescript-frontend-integration.md](docs/typescript-frontend-integration.md) | TypeScript client API, integration flow, examples |
|
||||||
|
| [docs/ed25519-security-use-cases.md](docs/ed25519-security-use-cases.md) | Ed25519 auth flows, registration, signatures |
|
||||||
|
| [docs/geo-auth-backend-plan.md](docs/geo-auth-backend-plan.md) | Architecture and planning |
|
||||||
|
|
||||||
## API client library
|
## API client library
|
||||||
|
|
||||||
Path: `libs/geo-api-client`
|
Path: `libs/geo-api-client`
|
||||||
@@ -99,11 +108,6 @@ bun test
|
|||||||
bun run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend TypeScript integration guide:
|
|
||||||
|
|
||||||
- `docs/typescript-frontend-integration.md`
|
|
||||||
- `docs/ed25519-security-use-cases.md`
|
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
Workflow: `.gitea/workflows/ci.yml`
|
Workflow: `.gitea/workflows/ci.yml`
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Frontend Development
|
||||||
|
|
||||||
|
Development guide for the Momswap Geo demo app (`web/`) and TypeScript client (`libs/geo-api-client`).
|
||||||
|
|
||||||
|
## Demo app (`web/`)
|
||||||
|
|
||||||
|
Vue 3 + Vuetify 3 single-page app, no bundler. Served by the backend at `/web/`.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
|
||||||
|
1. Start the API:
|
||||||
|
```bash
|
||||||
|
go run ./cmd/api
|
||||||
|
# or: docker compose up -d
|
||||||
|
```
|
||||||
|
2. Open `http://localhost:8122/web/`
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Build step for client
|
||||||
|
|
||||||
|
The demo app uses the pre-built client. After changing `libs/geo-api-client`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd libs/geo-api-client
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
- Collections: create, select, rename, remove
|
||||||
|
- Features: add point (lon/lat validation -180..180, -90..90), remove, list
|
||||||
|
|
||||||
|
## TypeScript client (`libs/geo-api-client`)
|
||||||
|
|
||||||
|
Reusable API client with Ed25519 signing. See [TypeScript Frontend Integration](typescript-frontend-integration.md) for full API and integration flow.
|
||||||
|
|
||||||
|
### Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd libs/geo-api-client
|
||||||
|
bun install
|
||||||
|
bun test
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage, integration flow, examples |
|
||||||
|
| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth flows, registration, signatures |
|
||||||
|
| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning |
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at `libs/geo-api-client`.
|
This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at `libs/geo-api-client`.
|
||||||
|
|
||||||
|
> **See also:** [Frontend Development](frontend-development.md) — demo app (`web/`), local dev, build steps.
|
||||||
|
|
||||||
Primary backend URL for integration:
|
Primary backend URL for integration:
|
||||||
|
|
||||||
- `https://momswap.produktor.duckdns.org/`
|
- `https://momswap.produktor.duckdns.org/`
|
||||||
@@ -43,6 +45,7 @@ Key methods:
|
|||||||
|
|
||||||
- `ensureKeysInStorage()`
|
- `ensureKeysInStorage()`
|
||||||
- `getStoredKeys()`
|
- `getStoredKeys()`
|
||||||
|
- `derivePublicKey(privateKey)` — restore public key from private key (Ed25519)
|
||||||
- `importKeys(keys)`
|
- `importKeys(keys)`
|
||||||
- `exportKeys()`
|
- `exportKeys()`
|
||||||
- `setAccessToken(token)`
|
- `setAccessToken(token)`
|
||||||
@@ -54,8 +57,11 @@ Key methods:
|
|||||||
- `registerWithInvitation(...)`
|
- `registerWithInvitation(...)`
|
||||||
- `listCollections()`
|
- `listCollections()`
|
||||||
- `createCollection(name)`
|
- `createCollection(name)`
|
||||||
|
- `updateCollection(collectionId, name)`
|
||||||
|
- `deleteCollection(collectionId)`
|
||||||
- `listFeatures(collectionId)`
|
- `listFeatures(collectionId)`
|
||||||
- `createPointFeature(collectionId, lon, lat, properties)`
|
- `createPointFeature(collectionId, lon, lat, properties)`
|
||||||
|
- `deleteFeature(featureId)`
|
||||||
|
|
||||||
## Recommended integration flow
|
## Recommended integration flow
|
||||||
|
|
||||||
|
|||||||
+32
-2
@@ -244,8 +244,11 @@ func validatePoint(point store.Point) error {
|
|||||||
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
|
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
|
||||||
}
|
}
|
||||||
lon, lat := point.Coordinates[0], point.Coordinates[1]
|
lon, lat := point.Coordinates[0], point.Coordinates[1]
|
||||||
if lon < -180 || lon > 180 || lat < -90 || lat > 90 {
|
if lon < -180 || lon > 180 {
|
||||||
return fmt.Errorf("%w: invalid lon/lat bounds", ErrBadRequest)
|
return fmt.Errorf("%w: longitude must be -180 to 180, got %.2f", ErrBadRequest, lon)
|
||||||
|
}
|
||||||
|
if lat < -90 || lat > 90 {
|
||||||
|
return fmt.Errorf("%w: latitude must be -90 to 90, got %.2f", ErrBadRequest, lat)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -272,6 +275,33 @@ func (s *Service) ListCollections(ownerKey string) []store.Collection {
|
|||||||
return s.store.ListCollectionsByOwner(ownerKey)
|
return s.store.ListCollectionsByOwner(ownerKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateCollection(ownerKey, collectionID, name string) (store.Collection, error) {
|
||||||
|
collection, err := s.store.GetCollection(collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return store.Collection{}, ErrCollectionMiss
|
||||||
|
}
|
||||||
|
if collection.OwnerKey != ownerKey {
|
||||||
|
return store.Collection{}, ErrForbidden
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest)
|
||||||
|
}
|
||||||
|
collection.Name = name
|
||||||
|
s.store.SaveCollection(collection)
|
||||||
|
return collection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteCollection(ownerKey, collectionID string) error {
|
||||||
|
collection, err := s.store.GetCollection(collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrCollectionMiss
|
||||||
|
}
|
||||||
|
if collection.OwnerKey != ownerKey {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.store.DeleteCollection(collectionID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) {
|
func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) {
|
||||||
collection, err := s.store.GetCollection(collectionID)
|
collection, err := s.store.GetCollection(collectionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ func (a *API) Routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("POST /v1/collections", a.createCollection)
|
mux.HandleFunc("POST /v1/collections", a.createCollection)
|
||||||
mux.HandleFunc("GET /v1/collections", a.listCollections)
|
mux.HandleFunc("GET /v1/collections", a.listCollections)
|
||||||
|
mux.HandleFunc("PATCH /v1/collections/{id}", a.updateCollection)
|
||||||
|
mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection)
|
||||||
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
||||||
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
|
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
|
||||||
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
||||||
@@ -272,6 +274,42 @@ func (a *API) listCollections(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"collections": a.service.ListCollections(user)})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"collections": a.service.ListCollections(user)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) updateCollection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := a.authUser(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionID := r.PathValue("id")
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeErr(w, app.ErrBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collection, err := a.service.UpdateCollection(user, collectionID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteCollection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := a.authUser(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionID := r.PathValue("id")
|
||||||
|
if err := a.service.DeleteCollection(user, collectionID); err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) createFeature(w http.ResponseWriter, r *http.Request) {
|
func (a *API) createFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.authUser(r)
|
user, err := a.authUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Store interface {
|
|||||||
SaveCollection(c Collection)
|
SaveCollection(c Collection)
|
||||||
ListCollectionsByOwner(owner string) []Collection
|
ListCollectionsByOwner(owner string) []Collection
|
||||||
GetCollection(id string) (Collection, error)
|
GetCollection(id string) (Collection, error)
|
||||||
|
DeleteCollection(id string) error
|
||||||
SaveFeature(f Feature)
|
SaveFeature(f Feature)
|
||||||
ListFeaturesByCollection(collectionID string) []Feature
|
ListFeaturesByCollection(collectionID string) []Feature
|
||||||
GetFeature(featureID string) (Feature, error)
|
GetFeature(featureID string) (Feature, error)
|
||||||
|
|||||||
@@ -157,6 +157,21 @@ func (s *MemoryStore) GetCollection(id string) (Collection, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) DeleteCollection(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.collections[id]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
for fid, f := range s.features {
|
||||||
|
if f.CollectionID == id {
|
||||||
|
delete(s.features, fid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(s.collections, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) SaveFeature(f Feature) {
|
func (s *MemoryStore) SaveFeature(f Feature) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -204,6 +204,18 @@ func (s *PostgresStore) GetCollection(id string) (Collection, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStore) DeleteCollection(id string) error {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM collections WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostgresStore) SaveFeature(f Feature) {
|
func (s *PostgresStore) SaveFeature(f Feature) {
|
||||||
geom, _ := json.Marshal(f.Geometry)
|
geom, _ := json.Marshal(f.Geometry)
|
||||||
props, _ := json.Marshal(f.Properties)
|
props, _ := json.Marshal(f.Properties)
|
||||||
|
|||||||
Vendored
+24
-1
@@ -470,6 +470,11 @@ async function generateKeyPair() {
|
|||||||
privateKey: bytesToBase64Url(privateKey)
|
privateKey: bytesToBase64Url(privateKey)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function getPublicKeyFromPrivate(privateKeyBase64) {
|
||||||
|
const privateKey = base64UrlToBytes(privateKeyBase64);
|
||||||
|
const publicKey = await getPublicKeyAsync(privateKey);
|
||||||
|
return bytesToBase64Url(publicKey);
|
||||||
|
}
|
||||||
async function signMessage(privateKeyBase64, message) {
|
async function signMessage(privateKeyBase64, message) {
|
||||||
const privateKey = base64UrlToBytes(privateKeyBase64);
|
const privateKey = base64UrlToBytes(privateKeyBase64);
|
||||||
const signature = await signAsync(textToBytes(message), privateKey);
|
const signature = await signAsync(textToBytes(message), privateKey);
|
||||||
@@ -519,6 +524,9 @@ class GeoApiClient {
|
|||||||
getStoredKeys() {
|
getStoredKeys() {
|
||||||
return loadKeys(this.storage, this.storageKey);
|
return loadKeys(this.storage, this.storageKey);
|
||||||
}
|
}
|
||||||
|
async derivePublicKey(privateKey) {
|
||||||
|
return getPublicKeyFromPrivate(privateKey);
|
||||||
|
}
|
||||||
importKeys(keys) {
|
importKeys(keys) {
|
||||||
saveKeys(this.storage, keys, this.storageKey);
|
saveKeys(this.storage, keys, this.storageKey);
|
||||||
}
|
}
|
||||||
@@ -538,7 +546,9 @@ class GeoApiClient {
|
|||||||
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
|
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const maybeJson = await res.json().catch(() => ({}));
|
const maybeJson = await res.json().catch(() => ({}));
|
||||||
const msg = maybeJson.error ?? `HTTP ${res.status}`;
|
let msg = maybeJson.error ?? `HTTP ${res.status}`;
|
||||||
|
if (maybeJson.hint)
|
||||||
|
msg += `. ${maybeJson.hint}`;
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
@@ -604,6 +614,15 @@ class GeoApiClient {
|
|||||||
async createCollection(name) {
|
async createCollection(name) {
|
||||||
return this.request("/v1/collections", { method: "POST", body: { name } });
|
return this.request("/v1/collections", { method: "POST", body: { name } });
|
||||||
}
|
}
|
||||||
|
async updateCollection(collectionId, name) {
|
||||||
|
return this.request(`/v1/collections/${collectionId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteCollection(collectionId) {
|
||||||
|
return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
async listFeatures(collectionId) {
|
async listFeatures(collectionId) {
|
||||||
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
||||||
}
|
}
|
||||||
@@ -616,11 +635,15 @@ class GeoApiClient {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async deleteFeature(featureId) {
|
||||||
|
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
signMessage,
|
signMessage,
|
||||||
saveKeys,
|
saveKeys,
|
||||||
loadKeys,
|
loadKeys,
|
||||||
|
getPublicKeyFromPrivate,
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
clearKeys,
|
clearKeys,
|
||||||
GeoApiClient,
|
GeoApiClient,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { generateKeyPair, signMessage } from "./keys";
|
import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||||
import { bytesToBase64Url, textToBytes } from "./encoding";
|
import { bytesToBase64Url, textToBytes } from "./encoding";
|
||||||
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
|
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
|
||||||
import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||||
@@ -31,6 +31,10 @@ export class GeoApiClient {
|
|||||||
return loadKeys(this.storage, this.storageKey);
|
return loadKeys(this.storage, this.storageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async derivePublicKey(privateKey: string): Promise<string> {
|
||||||
|
return getPublicKeyFromPrivate(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
importKeys(keys: StoredKeys): void {
|
importKeys(keys: StoredKeys): void {
|
||||||
saveKeys(this.storage, keys, this.storageKey);
|
saveKeys(this.storage, keys, this.storageKey);
|
||||||
}
|
}
|
||||||
@@ -135,6 +139,17 @@ export class GeoApiClient {
|
|||||||
return this.request("/v1/collections", { method: "POST", body: { name } });
|
return this.request("/v1/collections", { method: "POST", body: { name } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCollection(collectionId: string, name: string): Promise<{ id: string; name: string }> {
|
||||||
|
return this.request(`/v1/collections/${collectionId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCollection(collectionId: string): Promise<void> {
|
||||||
|
return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
|
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
|
||||||
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
||||||
}
|
}
|
||||||
@@ -153,4 +168,8 @@ export class GeoApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFeature(featureId: string): Promise<void> {
|
||||||
|
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { GeoApiClient } from "./GeoApiClient";
|
export { GeoApiClient } from "./GeoApiClient";
|
||||||
export { generateKeyPair, signMessage } from "./keys";
|
export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||||
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage";
|
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage";
|
||||||
export type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
export type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export async function generateKeyPair(): Promise<StoredKeys> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPublicKeyFromPrivate(privateKeyBase64: string): Promise<string> {
|
||||||
|
const privateKey = base64UrlToBytes(privateKeyBase64);
|
||||||
|
const publicKey = await getPublicKeyAsync(privateKey);
|
||||||
|
return bytesToBase64Url(publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
export async function signMessage(privateKeyBase64: string, message: string): Promise<string> {
|
export async function signMessage(privateKeyBase64: string, message: string): Promise<string> {
|
||||||
const privateKey = base64UrlToBytes(privateKeyBase64);
|
const privateKey = base64UrlToBytes(privateKeyBase64);
|
||||||
const signature = await signAsync(textToBytes(message), privateKey);
|
const signature = await signAsync(textToBytes(message), privateKey);
|
||||||
|
|||||||
+185
-4
@@ -1,6 +1,7 @@
|
|||||||
import { createApiClient } from "./api.js";
|
import { createApiClient } from "./api.js";
|
||||||
|
import { toDataURL } from "./qr.js";
|
||||||
|
|
||||||
const { createApp, ref, reactive, onMounted } = Vue;
|
const { createApp, ref, reactive, onMounted, watch } = Vue;
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
setup() {
|
||||||
@@ -11,7 +12,16 @@ createApp({
|
|||||||
accessToken: "",
|
accessToken: "",
|
||||||
collections: [],
|
collections: [],
|
||||||
newCollectionName: "",
|
newCollectionName: "",
|
||||||
|
selectedCollectionId: "",
|
||||||
|
editingCollectionName: "",
|
||||||
|
featuresByCollection: {},
|
||||||
|
newFeatureLon: "",
|
||||||
|
newFeatureLat: "",
|
||||||
|
newFeatureName: "",
|
||||||
status: "Ready",
|
status: "Ready",
|
||||||
|
qrPk: "",
|
||||||
|
qrPb: "",
|
||||||
|
showPrivateQR: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = createApiClient(apiBase.value);
|
let client = createApiClient(apiBase.value);
|
||||||
@@ -22,12 +32,48 @@ createApp({
|
|||||||
state.status = `API base set to ${apiBase.value}`;
|
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 () => {
|
const ensureKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await client.ensureKeysInStorage();
|
const keys = await client.ensureKeysInStorage();
|
||||||
state.publicKey = keys.publicKey;
|
state.publicKey = keys.publicKey;
|
||||||
state.privateKey = keys.privateKey;
|
state.privateKey = keys.privateKey;
|
||||||
state.status = "Keys loaded from localStorage.";
|
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) {
|
} catch (err) {
|
||||||
state.status = err.message;
|
state.status = err.message;
|
||||||
}
|
}
|
||||||
@@ -47,13 +93,15 @@ createApp({
|
|||||||
try {
|
try {
|
||||||
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
|
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!err.message.includes("already registered") && !err.message.includes("not configured") && !err.message.includes("ADMIN_PUBLIC_KEY")) {
|
const msg = (err?.message || "").toLowerCase();
|
||||||
throw err;
|
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)
|
// Proceed to login: already registered or registration disabled (invitation flow)
|
||||||
}
|
}
|
||||||
state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey);
|
state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey);
|
||||||
client.setAccessToken(state.accessToken);
|
client.setAccessToken(state.accessToken);
|
||||||
|
await listCollections();
|
||||||
state.status = "Authenticated.";
|
state.status = "Authenticated.";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.status = err.message;
|
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 () => {
|
onMounted(async () => {
|
||||||
await ensureKeys();
|
await ensureKeys();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const togglePrivateQR = () => {
|
||||||
|
state.showPrivateQR = !state.showPrivateQR;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiBase,
|
apiBase,
|
||||||
state,
|
state,
|
||||||
rebuildClient,
|
rebuildClient,
|
||||||
ensureKeys,
|
ensureKeys,
|
||||||
|
restorePublicKeyFromPrivate,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
listCollections,
|
listCollections,
|
||||||
createCollection,
|
createCollection,
|
||||||
|
removeCollection,
|
||||||
|
listFeatures,
|
||||||
|
selectCollection,
|
||||||
|
selectedCollection,
|
||||||
|
updateCollectionName,
|
||||||
|
onCollectionNameBlur,
|
||||||
|
createFeature,
|
||||||
|
removeFeature,
|
||||||
|
formatFeature,
|
||||||
|
featuresFor,
|
||||||
|
togglePrivateQR,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).use(Vuetify.createVuetify()).mount("#app");
|
}).use(Vuetify.createVuetify()).mount("#app");
|
||||||
|
|||||||
+72
-12
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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" />
|
<link href="https://cdn.jsdelivr.net/npm/vuetify@3.7.7/dist/vuetify.min.css" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-card class="glass rounded-xl pa-6">
|
<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-subtitle>Ed25519 auth, invitation onboarding, and user-scoped feature collections</v-card-subtitle>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -37,28 +37,88 @@
|
|||||||
<v-btn color="primary" @click="rebuildClient">Apply API URL</v-btn>
|
<v-btn color="primary" @click="rebuildClient">Apply API URL</v-btn>
|
||||||
<v-divider class="my-4"></v-divider>
|
<v-divider class="my-4"></v-divider>
|
||||||
<v-btn color="secondary" @click="ensureKeys">Ensure Keys in localStorage</v-btn>
|
<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.publicKey" class="mt-3" label="Public Key (pb)" rows="2"></v-textarea>
|
||||||
<v-textarea v-model="state.privateKey" label="Private Key (local only)" 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="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-btn color="success" class="mt-2" @click="login">Login</v-btn>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</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 class="glass rounded-xl pa-4">
|
||||||
<v-card-title>Collections</v-card-title>
|
<v-card-title>Collections</v-card-title>
|
||||||
<v-text-field v-model="state.newCollectionName" label="New collection name"></v-text-field>
|
<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">Create Collection</v-btn>
|
<v-btn color="primary" @click="createCollection" size="small" class="mr-2">Create Collection</v-btn>
|
||||||
<v-btn class="ml-2" @click="listCollections">Refresh</v-btn>
|
<v-btn variant="outlined" @click="listCollections" size="small">Refresh</v-btn>
|
||||||
<v-list class="mt-4">
|
<v-list class="mt-3" density="compact">
|
||||||
<v-list-item v-for="item in state.collections" :key="item.id">
|
<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-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-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
<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-col>
|
||||||
</v-row>
|
</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">
|
<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