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:
Christopher Cookman 2026-07-02 10:24:18 -06:00 committed by GitHub
commit 63c46b508f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 82 additions and 46 deletions

View file

@ -4,11 +4,25 @@ const crypto = require('crypto');
const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+';
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).
function generatePlaceId() {
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).
// Uses crypto.randomInt for unbiased selection of each character (rather than bytes % length,
// which would skew towards characters at the start of the alphabet).
@ -20,4 +34,4 @@ function generateApiKey(length = API_KEY_LENGTH) {
return key;
}
module.exports = { generatePlaceId, generateApiKey };
module.exports = { generatePlaceId, generateApiKey, generateReaderId };

View file

@ -224,6 +224,27 @@ button.danger {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));

View file

@ -79,22 +79,15 @@
</div>
<div class="card">
<h3>Add reader</h3>
<form id="add-reader-form" class="inline-form">
<div class="field-group">
<label for="reader-id">Reader ID</label>
<input type="text" id="reader-id" required>
</div>
<div class="field-group">
<label for="reader-name">Name</label>
<input type="text" id="reader-name" required>
</div>
<button type="submit" class="primary">Add reader</button>
</form>
</div>
<div class="card">
<h3>Readers</h3>
<div class="card-header">
<h3>Readers</h3>
<form id="add-reader-form" class="inline-form card-header-form">
<div class="field-group">
<input type="text" id="reader-name" placeholder="Reader name" required>
</div>
<button type="submit" class="primary">Add reader</button>
</form>
</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>

View file

@ -207,17 +207,15 @@ document.getElementById('add-reader-form').addEventListener('submit', async (e)
e.preventDefault();
if (!currentPlaceId) return;
const id = document.getElementById('reader-id').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`, {
method: 'POST',
body: JSON.stringify({ id, name })
body: JSON.stringify({ name })
});
if (data.success) {
document.getElementById('reader-id').value = '';
document.getElementById('reader-name').value = '';
selectPlace(currentPlaceId);
} else {

View file

@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { requireAuth, ROLES } = require('../lib/auth');
const { generatePlaceId, generateApiKey } = require('../lib/generators');
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
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) => {
const { id, name } = req.body || {};
if (!id || !name) {
return res.status(400).json({ success: false, message: "Reader id and name are required" });
const { name } = req.body || {};
if (!name) {
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 unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : 8;
const armState = req.body.armState !== undefined ? req.body.armState : 1;
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
if (err) {
console.error('Failed to check for existing reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (existing) {
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 });
function tryInsert(attemptsLeft) {
const id = generateReaderId();
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
if (err) {
console.error('Failed to check for existing reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
);
});
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