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_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 };

View file

@ -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));

View file

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

View file

@ -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 {

View file

@ -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,24 +266,31 @@ 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;
function tryInsert(attemptsLeft) {
const id = generateReaderId();
db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => { db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => {
if (err) { if (err) {
console.error('Failed to check for existing reader:', err.message); console.error('Failed to check for existing reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
if (existing) { if (existing) {
return res.status(409).json({ success: false, message: "A reader with that id already exists" }); if (attemptsLeft <= 0) {
return res.status(500).json({ success: false, message: "Failed to generate a unique reader id" });
}
return tryInsert(attemptsLeft - 1);
} }
db.run( db.run(
@ -294,10 +301,13 @@ router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
console.error('Failed to create reader:', err.message); console.error('Failed to create reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
res.json({ success: true }); res.json({ success: true, id });
} }
); );
}); });
}
tryInsert(5);
}); });
// Update a reader's settings // Update a reader's settings