Merge pull request #6 from ChrisChrome/copilot/make-reader-ids-16-char
Enforce server-generated reader IDs and condense reader UI
This commit is contained in:
commit
63c46b508f
|
|
@ -4,11 +4,25 @@ const crypto = require('crypto');
|
||||||
const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+';
|
const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+';
|
||||||
const API_KEY_LENGTH = 64;
|
const API_KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
// Characters used when generating a random reader id: letters and digits only.
|
||||||
|
const READER_ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const READER_ID_LENGTH = 16;
|
||||||
|
|
||||||
// Generates a new place id as a random UUID (v4).
|
// Generates a new place id as a random UUID (v4).
|
||||||
function generatePlaceId() {
|
function generatePlaceId() {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generates a random 16-character alphanumeric reader (access point) id.
|
||||||
|
// Uses crypto.randomInt for unbiased selection of each character.
|
||||||
|
function generateReaderId(length = READER_ID_LENGTH) {
|
||||||
|
let id = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
id += READER_ID_CHARS[crypto.randomInt(READER_ID_CHARS.length)];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
// Generates a random alphanumeric+symbol API key of the given length (default 64 characters).
|
// Generates a random alphanumeric+symbol API key of the given length (default 64 characters).
|
||||||
// Uses crypto.randomInt for unbiased selection of each character (rather than bytes % length,
|
// Uses crypto.randomInt for unbiased selection of each character (rather than bytes % length,
|
||||||
// which would skew towards characters at the start of the alphabet).
|
// which would skew towards characters at the start of the alphabet).
|
||||||
|
|
@ -20,4 +34,4 @@ function generateApiKey(length = API_KEY_LENGTH) {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generatePlaceId, generateApiKey };
|
module.exports = { generatePlaceId, generateApiKey, generateReaderId };
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,27 @@ button.danger {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-form {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-form .field-group {
|
||||||
|
flex: none;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.readers-grid {
|
.readers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -79,22 +79,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Add reader</h3>
|
<div class="card-header">
|
||||||
<form id="add-reader-form" class="inline-form">
|
<h3>Readers</h3>
|
||||||
<div class="field-group">
|
<form id="add-reader-form" class="inline-form card-header-form">
|
||||||
<label for="reader-id">Reader ID</label>
|
<div class="field-group">
|
||||||
<input type="text" id="reader-id" required>
|
<input type="text" id="reader-name" placeholder="Reader name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<button type="submit" class="primary">Add reader</button>
|
||||||
<label for="reader-name">Name</label>
|
</form>
|
||||||
<input type="text" id="reader-name" required>
|
</div>
|
||||||
</div>
|
|
||||||
<button type="submit" class="primary">Add reader</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>Readers</h3>
|
|
||||||
<div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div>
|
<div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div>
|
||||||
<div class="readers-grid" id="readers-grid"></div>
|
<div class="readers-grid" id="readers-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -207,17 +207,15 @@ document.getElementById('add-reader-form').addEventListener('submit', async (e)
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentPlaceId) return;
|
if (!currentPlaceId) return;
|
||||||
|
|
||||||
const id = document.getElementById('reader-id').value.trim();
|
|
||||||
const name = document.getElementById('reader-name').value.trim();
|
const name = document.getElementById('reader-name').value.trim();
|
||||||
if (!id || !name) return;
|
if (!name) return;
|
||||||
|
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points`, {
|
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ id, name })
|
body: JSON.stringify({ name })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('reader-id').value = '';
|
|
||||||
document.getElementById('reader-name').value = '';
|
document.getElementById('reader-name').value = '';
|
||||||
selectPlace(currentPlaceId);
|
selectPlace(currentPlaceId);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { requireAuth, ROLES } = require('../lib/auth');
|
const { requireAuth, ROLES } = require('../lib/auth');
|
||||||
const { generatePlaceId, generateApiKey } = require('../lib/generators');
|
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -266,38 +266,48 @@ function loadOwnedAccessPoint(req, res, next) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new reader (access point) for a place
|
// Create a new reader (access point) for a place. The reader id is always
|
||||||
|
// generated server-side as a random 16-character alphanumeric string; no
|
||||||
|
// caller-supplied id is ever accepted.
|
||||||
router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
|
router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
|
||||||
const { id, name } = req.body || {};
|
const { name } = req.body || {};
|
||||||
if (!id || !name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Reader id and name are required" });
|
return res.status(400).json({ success: false, message: "Reader name is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : 1;
|
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : 1;
|
||||||
const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : 8;
|
const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : 8;
|
||||||
const armState = req.body.armState !== undefined ? req.body.armState : 1;
|
const armState = req.body.armState !== undefined ? req.body.armState : 1;
|
||||||
|
|
||||||
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
|
function tryInsert(attemptsLeft) {
|
||||||
if (err) {
|
const id = generateReaderId();
|
||||||
console.error('Failed to check for existing reader:', err.message);
|
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
if (err) {
|
||||||
}
|
console.error('Failed to check for existing reader:', err.message);
|
||||||
if (existing) {
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
return res.status(409).json({ success: false, message: "A reader with that id already exists" });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO access_points (id, placeId, name, enabled, unlockTime, armState) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
[id, req.place.id, name, enabled, unlockTime, armState],
|
|
||||||
(err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Failed to create reader:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
|
||||||
}
|
}
|
||||||
);
|
if (existing) {
|
||||||
});
|
if (attemptsLeft <= 0) {
|
||||||
|
return res.status(500).json({ success: false, message: "Failed to generate a unique reader id" });
|
||||||
|
}
|
||||||
|
return tryInsert(attemptsLeft - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO access_points (id, placeId, name, enabled, unlockTime, armState) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, req.place.id, name, enabled, unlockTime, armState],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to create reader:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, id });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tryInsert(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a reader's settings
|
// Update a reader's settings
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue