Restrict manual place id/apiKey entry, add regenerate/reveal, show place owner
This commit is contained in:
parent
5fe386ff63
commit
8e69420295
22
lib/generators.js
Normal file
22
lib/generators.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -170,6 +170,19 @@ button.danger {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: var(--muted);
|
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 {
|
.place-list li:hover {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@
|
||||||
<div class="card" style="margin-top: 16px;">
|
<div class="card" style="margin-top: 16px;">
|
||||||
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
|
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
|
||||||
<form id="add-place-form">
|
<form id="add-place-form">
|
||||||
<label for="place-id">Place ID</label>
|
<div id="add-place-custom-fields" class="hidden">
|
||||||
<input type="text" id="place-id" required>
|
<label for="place-id">Place ID <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
|
||||||
<label for="place-api-key">API Key</label>
|
<input type="text" id="place-id">
|
||||||
<input type="text" id="place-api-key" required>
|
<label for="place-api-key">API Key <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
|
||||||
|
<input type="text" id="place-api-key">
|
||||||
|
</div>
|
||||||
<button type="submit" class="primary">Add place</button>
|
<button type="submit" class="primary">Add place</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,6 +41,26 @@
|
||||||
<button class="danger" id="delete-place-btn">Delete place</button>
|
<button class="danger" id="delete-place-btn">Delete place</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>API Key</h3>
|
||||||
|
<div class="inline-form">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="place-api-key-display">Current key</label>
|
||||||
|
<input type="password" id="place-api-key-display" readonly>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary" id="toggle-api-key-btn">Show</button>
|
||||||
|
<button type="button" class="secondary" id="copy-api-key-btn">Copy</button>
|
||||||
|
<button type="button" class="secondary" id="regenerate-api-key-btn">Regenerate</button>
|
||||||
|
</div>
|
||||||
|
<form id="set-api-key-form" class="inline-form hidden" style="margin-top:12px;">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="place-api-key-custom">Set custom key <span style="color:var(--muted);font-weight:normal;">(leave blank to auto-generate)</span></label>
|
||||||
|
<input type="text" id="place-api-key-custom">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Add reader</h3>
|
<h3>Add reader</h3>
|
||||||
<form id="add-reader-form" class="inline-form">
|
<form id="add-reader-form" class="inline-form">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ async function init() {
|
||||||
document.getElementById('username-display').textContent = me.user.username;
|
document.getElementById('username-display').textContent = me.user.username;
|
||||||
document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
|
document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
|
||||||
document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3);
|
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();
|
await loadPlaces();
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +52,20 @@ function renderPlaceList() {
|
||||||
|
|
||||||
places.forEach((place) => {
|
places.forEach((place) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = place.id;
|
|
||||||
li.className = place.id === currentPlaceId ? 'active' : '';
|
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));
|
li.addEventListener('click', () => selectPlace(place.id));
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
@ -70,6 +84,12 @@ async function selectPlace(placeId) {
|
||||||
document.getElementById('place-view').classList.remove('hidden');
|
document.getElementById('place-view').classList.remove('hidden');
|
||||||
document.getElementById('place-title').textContent = placeId;
|
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';
|
const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass';
|
||||||
document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership);
|
document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership);
|
||||||
document.getElementById('shared-access-card').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();
|
e.preventDefault();
|
||||||
const id = document.getElementById('place-id').value.trim();
|
const id = document.getElementById('place-id').value.trim();
|
||||||
const apiKey = document.getElementById('place-api-key').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', {
|
const data = await api('/dashboard/places', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ id, apiKey })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('place-id').value = '';
|
document.getElementById('place-id').value = '';
|
||||||
document.getElementById('place-api-key').value = '';
|
document.getElementById('place-api-key').value = '';
|
||||||
await loadPlaces();
|
await loadPlaces();
|
||||||
selectPlace(id);
|
selectPlace(data.place.id);
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || 'Failed to add place');
|
alert(data.message || 'Failed to add place');
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +230,54 @@ document.getElementById('delete-place-btn').addEventListener('click', async () =
|
||||||
await loadPlaces();
|
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 () => {
|
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||||
await api('/auth/logout', { method: 'POST' });
|
await api('/auth/logout', { method: 'POST' });
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +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');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -61,19 +62,24 @@ function requireOwnerOrBypass(req, res, next) {
|
||||||
router.use(requireAuth(() => db));
|
router.use(requireAuth(() => db));
|
||||||
|
|
||||||
// List places owned by, or shared with, the current user. Elevated/admin users see every place.
|
// 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) => {
|
router.get('/places', (req, res) => {
|
||||||
if (req.user.role >= ROLES.ELEVATED) {
|
if (req.user.role >= ROLES.ELEVATED) {
|
||||||
return db.all(`SELECT * FROM places`, [], (err, places) => {
|
return db.all(
|
||||||
if (err) {
|
`SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId`,
|
||||||
console.error('Failed to retrieve places:', err.message);
|
[],
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
(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(
|
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],
|
[req.user.id, req.user.id],
|
||||||
(err, places) => {
|
(err, places) => {
|
||||||
if (err) {
|
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) => {
|
router.post('/places', (req, res) => {
|
||||||
const { id, apiKey, settings } = req.body || {};
|
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
||||||
if (!id || !apiKey) {
|
const { settings } = req.body || {};
|
||||||
return res.status(400).json({ success: false, message: "Place id and apiKey are required" });
|
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) => {
|
db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to check for existing place:', err.message);
|
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) => {
|
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;
|
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) => {
|
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)
|
// Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants)
|
||||||
router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
||||||
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue