diff --git a/lib/generators.js b/lib/generators.js new file mode 100644 index 0000000..4d69e2d --- /dev/null +++ b/lib/generators.js @@ -0,0 +1,22 @@ +const crypto = require('crypto'); + +// Characters used when generating a random API key: letters, digits and a handful of symbols. +const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'; +const API_KEY_LENGTH = 64; + +// Generates a new place id as a random UUID (v4). +function generatePlaceId() { + return crypto.randomUUID(); +} + +// Generates a random alphanumeric+symbol API key of the given length (default 64 characters). +function generateApiKey(length = API_KEY_LENGTH) { + const bytes = crypto.randomBytes(length); + let key = ''; + for (let i = 0; i < length; i++) { + key += API_KEY_CHARS[bytes[i] % API_KEY_CHARS.length]; + } + return key; +} + +module.exports = { generatePlaceId, generateApiKey }; diff --git a/public/css/style.css b/public/css/style.css index 7ba063c..cbcf5a5 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -170,6 +170,19 @@ button.danger { font-size: 14px; margin-bottom: 4px; color: var(--muted); + display: flex; + flex-direction: column; + gap: 2px; +} + +.place-list-id { + font-size: 14px; +} + +.place-list-owner { + font-size: 11px; + color: var(--muted); + opacity: 0.75; } .place-list li:hover { diff --git a/public/dashboard/index.html b/public/dashboard/index.html index b36d986..dea4ded 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -19,10 +19,12 @@

Add place

- - - - +
@@ -39,6 +41,26 @@ +
+

API Key

+
+
+ + +
+ + + +
+ +
+

Add reader

diff --git a/public/js/dashboard.js b/public/js/dashboard.js index b23968b..efe52d0 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -34,6 +34,8 @@ async function init() { document.getElementById('username-display').textContent = me.user.username; document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User'; document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3); + document.getElementById('add-place-custom-fields').classList.toggle('hidden', me.user.role < 3); + document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3); await loadPlaces(); } @@ -50,8 +52,20 @@ function renderPlaceList() { places.forEach((place) => { const li = document.createElement('li'); - li.textContent = place.id; li.className = place.id === currentPlaceId ? 'active' : ''; + + const idSpan = document.createElement('span'); + idSpan.className = 'place-list-id'; + idSpan.textContent = place.id; + li.appendChild(idSpan); + + if (place.ownerUsername) { + const ownerSpan = document.createElement('span'); + ownerSpan.className = 'place-list-owner'; + ownerSpan.textContent = `Owner: ${place.ownerUsername}`; + li.appendChild(ownerSpan); + } + li.addEventListener('click', () => selectPlace(place.id)); list.appendChild(li); }); @@ -70,6 +84,12 @@ async function selectPlace(placeId) { document.getElementById('place-view').classList.remove('hidden'); document.getElementById('place-title').textContent = placeId; + const apiKeyInput = document.getElementById('place-api-key-display'); + apiKeyInput.value = data.place.apiKey || ''; + apiKeyInput.type = 'password'; + document.getElementById('toggle-api-key-btn').textContent = 'Show'; + document.getElementById('place-api-key-custom').value = ''; + const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass'; document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership); document.getElementById('shared-access-card').classList.toggle('hidden', !canManageOwnership); @@ -157,18 +177,21 @@ document.getElementById('add-place-form').addEventListener('submit', async (e) = e.preventDefault(); const id = document.getElementById('place-id').value.trim(); const apiKey = document.getElementById('place-api-key').value.trim(); - if (!id || !apiKey) return; + + const body = {}; + if (id) body.id = id; + if (apiKey) body.apiKey = apiKey; const data = await api('/dashboard/places', { method: 'POST', - body: JSON.stringify({ id, apiKey }) + body: JSON.stringify(body) }); if (data.success) { document.getElementById('place-id').value = ''; document.getElementById('place-api-key').value = ''; await loadPlaces(); - selectPlace(id); + selectPlace(data.place.id); } else { alert(data.message || 'Failed to add place'); } @@ -207,6 +230,54 @@ document.getElementById('delete-place-btn').addEventListener('click', async () = await loadPlaces(); }); +document.getElementById('toggle-api-key-btn').addEventListener('click', () => { + const input = document.getElementById('place-api-key-display'); + const btn = document.getElementById('toggle-api-key-btn'); + const revealed = input.type === 'text'; + input.type = revealed ? 'password' : 'text'; + btn.textContent = revealed ? 'Show' : 'Hide'; +}); + +document.getElementById('copy-api-key-btn').addEventListener('click', async () => { + const value = document.getElementById('place-api-key-display').value; + if (!value) return; + try { + await navigator.clipboard.writeText(value); + } catch (err) { + // Clipboard API may be unavailable; fail silently. + } +}); + +document.getElementById('regenerate-api-key-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + if (!confirm('Regenerate the API key for this place? The old key will stop working immediately.')) return; + + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/regenerate-key`, { method: 'POST' }); + if (data.success) { + document.getElementById('place-api-key-display').value = data.apiKey; + } else { + alert(data.message || 'Failed to regenerate API key'); + } +}); + +document.getElementById('set-api-key-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentPlaceId) return; + + const apiKey = document.getElementById('place-api-key-custom').value.trim(); + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, { + method: 'PUT', + body: JSON.stringify({ apiKey }) + }); + + if (data.success) { + document.getElementById('place-api-key-custom').value = ''; + selectPlace(currentPlaceId); + } else { + alert(data.message || 'Failed to set API key'); + } +}); + document.getElementById('logout-btn').addEventListener('click', async () => { await api('/auth/logout', { method: 'POST' }); window.location.href = '/login'; diff --git a/routes/dashboard.js b/routes/dashboard.js index 0154728..d0741e6 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { requireAuth, ROLES } = require('../lib/auth'); +const { generatePlaceId, generateApiKey } = require('../lib/generators'); let db; @@ -61,19 +62,24 @@ function requireOwnerOrBypass(req, res, next) { router.use(requireAuth(() => db)); // List places owned by, or shared with, the current user. Elevated/admin users see every place. +// Includes the owner's username (as `ownerUsername`) so the sidebar can display who owns each place. router.get('/places', (req, res) => { if (req.user.role >= ROLES.ELEVATED) { - return db.all(`SELECT * FROM places`, [], (err, places) => { - if (err) { - console.error('Failed to retrieve places:', err.message); - return res.status(500).json({ success: false, message: "Internal server error" }); + return db.all( + `SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId`, + [], + (err, places) => { + if (err) { + console.error('Failed to retrieve places:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, places }); } - res.json({ success: true, places }); - }); + ); } db.all( - `SELECT * FROM places WHERE ownerId = ? OR id IN (SELECT placeId FROM place_access WHERE userId = ?)`, + `SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId WHERE places.ownerId = ? OR places.id IN (SELECT placeId FROM place_access WHERE userId = ?)`, [req.user.id, req.user.id], (err, places) => { if (err) { @@ -85,13 +91,24 @@ router.get('/places', (req, res) => { ); }); -// Create a new place +// Create a new place. Only admins (and superadmins) may supply a custom id/apiKey - everyone else +// always gets a generated (random UUID / random 64-char) id and apiKey. Admins that leave either +// field blank also get one generated for them. router.post('/places', (req, res) => { - const { id, apiKey, settings } = req.body || {}; - if (!id || !apiKey) { - return res.status(400).json({ success: false, message: "Place id and apiKey are required" }); + const canSetCustomValues = req.user.role >= ROLES.ADMIN; + const { settings } = req.body || {}; + let { id, apiKey } = req.body || {}; + + if (!canSetCustomValues && (id || apiKey)) { + return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" }); } + id = canSetCustomValues && id ? String(id).trim() : ''; + apiKey = canSetCustomValues && apiKey ? String(apiKey).trim() : ''; + + if (!id) id = generatePlaceId(); + if (!apiKey) apiKey = generateApiKey(); + db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => { if (err) { console.error('Failed to check for existing place:', err.message); @@ -122,9 +139,20 @@ router.get('/places/:placeId', loadPlace, (req, res) => { }); }); -// Update a place's apiKey/settings +// Update a place's apiKey/settings. Only admins (and superadmins) may set a custom apiKey value - +// everyone else must use the regenerate-key endpoint below. Admins that explicitly send a blank +// apiKey get a freshly generated one instead of clearing it. router.put('/places/:placeId', loadPlace, (req, res) => { - const apiKey = req.body.apiKey !== undefined ? req.body.apiKey : req.place.apiKey; + const canSetCustomValues = req.user.role >= ROLES.ADMIN; + + if (req.body.apiKey !== undefined && !canSetCustomValues) { + return res.status(403).json({ success: false, message: "Only admins can set a custom API key; use regenerate instead" }); + } + + let apiKey = req.place.apiKey; + if (req.body.apiKey !== undefined) { + apiKey = String(req.body.apiKey).trim() || generateApiKey(); + } const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings; db.run(`UPDATE places SET apiKey = ?, settings = ? WHERE id = ?`, [apiKey, settings, req.place.id], (err) => { @@ -136,6 +164,21 @@ router.put('/places/:placeId', loadPlace, (req, res) => { }); }); +// Regenerate a place's API key to a new random value. Anyone with access to the place (owner, +// shared access, or elevated/admin bypass) may do this - unlike setting a custom value, this +// doesn't require admin privileges since it never lets the caller choose the resulting key. +router.post('/places/:placeId/regenerate-key', loadPlace, (req, res) => { + const apiKey = generateApiKey(); + + db.run(`UPDATE places SET apiKey = ? WHERE id = ?`, [apiKey, req.place.id], (err) => { + if (err) { + console.error('Failed to regenerate API key:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, apiKey }); + }); +}); + // Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants) router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => { db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {